diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 49db00c5..957f1a42 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -10,14 +10,14 @@ means "keep the CSS ground". Everything below this section describes the opt-in function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels) onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in - tasks of about 6 ms (`setTimeout`, columns per task sized from the last slice's time). Per frame the only motion is the + tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`, 150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch. No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent. - `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the - sun (`sunDirection` from `sunAzimuth`: +x for azimuth 0.25-0.5); the face under it has a light-to-haze gradient, erosion + sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with `transmittance`; the base colour, haze and blur are the aerial perspective below, not redone. - Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played. diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 6e30a6be..864491f1 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRidgeScene } from './Ridge'; +import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; import { valueNoise, hash2 } from './noise'; import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe'; import { @@ -217,8 +218,21 @@ function fakeEl(tag = 'div') { this.children.push(c); return c; }, - animate: vi.fn(() => { - const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() }; + animate: vi.fn((keyframes: any[], options: any) => { + const a: any = { + currentTime: 0, + keyframes, + options, + onfinish: null, + play: vi.fn(), + pause: vi.fn(), + cancel: vi.fn(), + /** The animation reaches its end: what the compositor's finish event does. */ + finish() { + a.currentTime = options.duration; + a.onfinish?.(); + }, + }; el.anims.push(a); return a; }), @@ -236,6 +250,10 @@ const DAY: SkyColours = { sunAzimuth: 0.3, }; const draws: string[] = []; +/** Every ImageData put on any canvas, in order. */ +const puts: any[] = []; +/** For every drawImage: the source canvas's width as it was at the moment of the draw. */ +const drawnWidths: number[] = []; function ctx(withFilter = false) { const c: any = { globalCompositeOperation: 'source-over', @@ -243,12 +261,16 @@ function ctx(withFilter = false) { fillRect() { draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky'); }, + save() {}, + restore() {}, + rect() {}, + clip() {}, createImageData: (w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h, }), - putImageData() {}, + putImageData: (img: any) => puts.push(img), beginPath() {}, moveTo() {}, lineTo() {}, @@ -266,7 +288,10 @@ function ctx(withFilter = false) { if (c.arcing) c.arcing = false; else draws.push('fill'); }, - drawImage: () => draws.push('flatten'), + drawImage: (src: any) => { + drawnWidths.push(src.width); + draws.push('flatten'); + }, set fillStyle(_v: unknown) {}, }; if (withFilter) c.filter = 'none'; @@ -281,6 +306,8 @@ let search: string; beforeEach(() => { rafQueue = []; draws.length = 0; + puts.length = 0; + drawnWidths.length = 0; search = ''; motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }; doc = Object.assign(new EventTarget(), { @@ -675,3 +702,113 @@ describe('the starfield in the scene', () => { expect(rafQueue).toHaveLength(0); }); }); + +describe('endless drift: chunks baked on demand and recycled', () => { + const size = layerSize(400, 800, 2); // the fake host: 400 x 800 CSS px at DPR 2 + const until = async (cond: () => boolean) => { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > 10000) throw new Error('timed out waiting for the background bakes'); + await new Promise((r) => setTimeout(r, 5)); + } + }; + /** The pixels chunk `m` of layer `li` must hold: the pure bake of its world columns, no blur. */ + const expected = (li: number, m: number) => { + const p: BakeParams = { + spec: LAYERS[li], + sky: DAY, + period: size.period, + scale: 2, + viewH: 1600, + rows: size.height, + bandH: size.height, + px0: m * size.chunk, + cols: size.chunk, + }; + const out = new Uint8ClampedArray(p.cols * p.rows * 4); + bakeSlice(out, p, layerPaint(p.spec, DAY), 0, p.cols); + return Buffer.from(out.buffer); + }; + /** Each distinct ImageData put, in order of first appearance: one per baked chunk. */ + const baked = () => [...new Set(puts)].map((img) => Buffer.from(img.data.buffer)); + const start = () => { + const { parent, canvas } = host(); + const records: any[] = []; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => records.push(b) })!; + return { parent, s, records }; + }; + + it('bakes every chunk from its own world columns: no offset, the next chunk continues the last', async () => { + const { s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + // First view: chunk 0 of each layer; then, per layer in queue order, chunks 1 and 2. + const order = [ + ...LAYERS.map((l) => [l.index, 0]), + ...LAYERS.flatMap((l) => [ + [l.index, 1], + [l.index, 2], + ]), + ]; + expect(records.map((r) => [r.layer, r.chunk])).toEqual(order); + const imgs = baked(); + expect(imgs).toHaveLength(order.length); + order.forEach(([li, m], i) => expect(imgs[i].equals(expected(li, m))).toBe(true)); + s.dispose(); + }); + + it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => { + const { parent, s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const track = tracksOf(parent)[0]; + const [e0, e1, e2] = track.children; + const first = track.anims[0]; + // One chunk of drift, not one viewport: the track slides by the width of a chunk. + expect(first.keyframes[1].transform).toContain(`${-size.chunk / 2}px`); + expect(track.animate).toHaveBeenCalledTimes(1); + first.finish(); + expect(first.cancel).toHaveBeenCalled(); + expect(track.animate).toHaveBeenCalledTimes(2); + const w = size.chunk / 2; + expect([e1.style.left, e2.style.left, e0.style.left]).toEqual(['0px', `${w}px`, `${2 * w}px`]); + expect(track.anims[1].keyframes[1].transform).toContain(`${-w}px`); + s.dispose(); + }); + + it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => { + const { parent, s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const track = tracksOf(parent)[0]; + const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk); + for (let m = 3; m <= 5; m += 1) { + track.anims[track.anims.length - 1].finish(); + await until(() => done().includes(m)); + } + expect(done()).toEqual([0, 1, 2, 3, 4, 5]); + // The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one. + const imgs = baked(); + const layer0 = imgs.filter((_, i) => records[i].layer === 0); + [3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true)); + expect(layer0[3].equals(layer0[0])).toBe(false); + s.dispose(); + }); + + it('with the blur, each chunk is blurred from a scratch canvas of its own width: seams stay opaque and exact', async () => { + const orig = (document as any).createElement; + (document as any).createElement = (tag: string) => { + const el = orig(tag); + el.getContext = vi.fn(() => ctx(true)); + return el; + }; + const { s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const want = new Set( + LAYERS.filter((l) => edgeBlur(l) > 0).map( + (l) => size.chunk + 2 * Math.ceil(3 * edgeBlur(l) * 2) + ) + ); + expect(want.size).toBeGreaterThan(1); // the layers' pads differ, which is what a shared canvas gets wrong + // Every blur reads a source exactly as wide as that chunk's padded bake, at the moment of the draw. + expect(new Set(drawnWidths)).toEqual(want); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index cddf569e..72985d4c 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -61,7 +61,9 @@ function scatter( widthPx: number, heightPx: number, t0: number, - tPerPx: number + tPerPx: number, + bandX = 0, + bandW = widthPx ) { if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none const g = ctx.createLinearGradient(0, 0, widthPx, 0); @@ -72,7 +74,7 @@ function scatter( } ctx.globalCompositeOperation = 'source-atop'; ctx.fillStyle = g; - ctx.fillRect(0, 0, widthPx, heightPx); + ctx.fillRect(bandX, 0, bandW, heightPx); ctx.globalCompositeOperation = 'source-over'; } @@ -82,14 +84,16 @@ interface Job { } /** - * One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`. The pixels come from - * `bakeSlice`, a slice of columns per call, sized to the task budget by measuring the last one. A blur - * (where the 2D `filter` exists) needs neighbours, so the pixels are baked `pad` columns past both - * ends and bottom, then blurred once into the chunk canvas: seams show no fade. + * One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`, in three phases of + * column bands, each band sized to the task budget by measuring the last one: bake the pixels + * (`bakeSlice`), put them on a canvas with the in-scatter, and (where the 2D `filter` exists) blur them + * into `el`. The blur needs neighbours, so the pixels are baked `pad` columns past both ends and + * the bottom into a scratch canvas this job owns, and each blurred band reads `pad` columns either side of + * itself: seams show no fade. The scratch is made when the job first runs and dropped when it ends. */ function chunkJob( el: HTMLCanvasElement, - scratch: HTMLCanvasElement, + makeCanvas: () => HTMLCanvasElement, spec: LayerSpec, sky: SkyColours, size: Size, @@ -104,16 +108,6 @@ function chunkJob( const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0; const cols = size.chunk + 2 * pad; const rows = size.height + pad; - const sctx = pad > 0 ? context2d(scratch) : ctx; - if (!sctx) return null; - if (pad > 0) { - scratch.width = cols; - scratch.height = rows; - } else { - el.width = size.chunk; - el.height = size.height; - } - const img = sctx.createImageData(cols, rows); const params: BakeParams = { spec, sky, @@ -126,62 +120,89 @@ function chunkJob( cols, }; const paint = layerPaint(spec, sky); - let next = 0; - let stage = 0; + const t0World = params.px0 / size.period; + let scratch: HTMLCanvasElement | undefined; + let sctx: CanvasRenderingContext2D | null = ctx; + let img: ImageData | undefined; + /** 0 bake, 1 put and in-scatter, 2 blur into el; a chunk with no blur ends after 1. */ + let phase = 0; + let pos = 0; let sliceCols = 32; let total = 0; let longest = 0; + const last = pad > 0 ? 2 : 1; + const start = (): boolean => { + if (pad > 0) { + scratch = makeCanvas(); + scratch.width = cols; + scratch.height = rows; + sctx = context2d(scratch); + } else { + el.width = size.chunk; + el.height = size.height; + } + if (!sctx) return false; + img = sctx.createImageData(cols, rows); + return true; + }; + const work = (x: number, n: number) => { + if (!img || !sctx) return; + if (phase === 0) bakeSlice(img.data, params, paint, x, x + n); + else if (phase === 1) { + sctx.putImageData(img, 0, 0, x, 0, n, rows); + scatter(sctx, spec, sky, cols, rows, t0World, 1 / size.period, x, n); + } else { + // One draw: a blurred draw cut into bands costs more in the margins than it saves in task length. + ctx.drawImage(scratch as HTMLCanvasElement, -pad, 0); + } + }; return { step(budgetMs) { const t0 = clock(); - let done = false; - const baking = next < cols; - const putNow = stage === 0; - while (next < cols && clock() - t0 < budgetMs) { - const n = Math.min(cols - next, sliceCols); - const s0 = clock(); - bakeSlice(img.data, params, paint, next, next + n); - next += n; - const dt = Math.max(clock() - s0, 0.02); - if (Number.isFinite(budgetMs)) - sliceCols = Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt))); - else sliceCols = cols; - } - // Finishing is its own tasks, so no one task is longer than a slice: put and in-scatter, then the blur. - // A caller with no budget (the first view) runs it all in this one. - if (next >= cols && (!Number.isFinite(budgetMs) || !baking)) { - if (stage === 0) { - if (pad > 0) { - sctx.putImageData(img, 0, 0); - scatter(sctx, spec, sky, cols, rows, params.px0 / size.period, 1 / size.period); - } else { - ctx.putImageData(img, 0, 0); - scatter( - ctx, - spec, - sky, - size.chunk, - size.height, - params.px0 / size.period, - 1 / size.period - ); - } - stage = 1; - done = pad === 0; + // Allocating the canvases and the pixel buffer is a task of its own. + if (!img) { + if (!start()) return true; + if (Number.isFinite(budgetMs)) { + total += clock() - t0; + longest = Math.max(longest, clock() - t0); + return false; } - if (stage === 1 && !done && (!Number.isFinite(budgetMs) || !putNow)) { + } + while (phase <= last && clock() - t0 < budgetMs) { + const end = phase === 2 ? size.chunk : cols; + if (pos === 0 && phase === 2) { el.width = size.chunk; el.height = size.height; ctx.filter = `blur(${blur}px)`; - ctx.drawImage(scratch, -pad, 0); - ctx.filter = 'none'; - done = true; + } + const n = phase === 2 ? end : Math.min(end - pos, sliceCols); + const s0 = clock(); + work(pos, n); + pos += n; + const dt = Math.max(clock() - s0, 0.02); + sliceCols = Number.isFinite(budgetMs) + ? Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt))) + : cols; + if (pos >= end) { + if (phase === 2) ctx.filter = 'none'; + phase += 1; + pos = 0; + if (phase === 1 || phase === 2) sliceCols = Number.isFinite(budgetMs) ? 64 : cols; } } const dt = clock() - t0; total += dt; longest = Math.max(longest, dt); - if (done) record(total, longest); + const done = phase > last; + if (done) { + img = undefined; + if (scratch) { + scratch.width = 0; + scratch.height = 0; + } + scratch = undefined; + record(total, longest); + } return done; }, }; @@ -212,6 +233,8 @@ interface Built { /** CSS px to canvas px, and the viewport's height in canvas px, as baked. */ scale: number; viewH: number; + /** Makes a canvas, bound to the document the scene was built in. */ + makeCanvas: () => HTMLCanvasElement; /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ twinkle?: HTMLCanvasElement; } @@ -247,7 +270,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { let jobTimer: ReturnType | undefined; let queue: Chunk[] = []; let generation = 0; - let scratch: HTMLCanvasElement | undefined; let disposed = false; let raf = 0; let readout: HTMLElement | undefined; @@ -265,7 +287,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { clearTimeout(jobTimer); jobTimer = undefined; queue = []; - scratch = undefined; built?.sway?.cancel(); built?.layers.forEach((l) => l.anim?.cancel()); built?.rig.remove(); @@ -288,10 +309,10 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { */ const bake = (l: LayerState, c: Chunk, first: boolean): boolean => { if (!built) return false; - const { scale, viewH } = built; + const { scale, viewH, makeCanvas } = built; const job = chunkJob( c.el, - scratch as HTMLCanvasElement, + makeCanvas, l.spec, sky, built.size, @@ -333,7 +354,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const chunkSeconds = (spec: LayerSpec, size: Size) => (spec.seconds * size.chunk) / size.period; /** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */ - const startDrift = (l: LayerState) => { + const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => { if (!built) return; const { size, scale } = built; l.anim = l.track.animate?.( @@ -343,7 +364,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { ], { duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' } ); - if (l.anim) l.anim.onfinish = () => rotate(l); + if (l.anim) l.anim.onfinish = () => onEnd(l); }; /** @@ -363,7 +384,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { c.el.style.left = `${(i * size.chunk) / scale}px`; }); l.anim?.cancel(); - startDrift(l); + startDrift(l, rotate); bake(l, s0, false); pump(); syncPlayState(); @@ -429,7 +450,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { pointerEvents: 'none', willChange: 'transform', }); - scratch = document.createElement('canvas'); const layers: LayerState[] = LAYERS.map((spec) => { const track = document.createElement('div'); Object.assign(track.style, { @@ -442,6 +462,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }); const slots: Chunk[] = [0, 1, 2].map((i) => { const el = document.createElement('canvas'); + el.width = size.chunk; + el.height = size.height; Object.assign(el.style, { position: 'absolute', bottom: '0', @@ -452,7 +474,16 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }); return { spec, track, slots }; }); - built = { rig, layers, size, scale, viewH: canvas.height, twinkle }; + const doc = document; + built = { + rig, + layers, + size, + scale, + viewH: canvas.height, + makeCanvas: () => doc.createElement('canvas'), + twinkle, + }; // First view: only slot 0 of each layer, in this task. The window sees nothing else for the first // fifth of a chunk's drift; slots 1 and 2 are baked in short tasks behind it. if (!layers.every((l) => bake(l, l.slots[0], true))) { @@ -471,7 +502,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }); // DOM order is paint order: farthest first, so the nearest ridge ends up on top. parent.appendChild(rig); - layers.forEach((l) => startDrift(l)); + layers.forEach((l) => startDrift(l, rotate)); built.sway = startSway(rig, w, h); pump(); syncPlayState(); diff --git a/src/app/features/scene/ridge/ridgeBake.test.ts b/src/app/features/scene/ridge/ridgeBake.test.ts index 355272ef..7c452954 100644 --- a/src/app/features/scene/ridge/ridgeBake.test.ts +++ b/src/app/features/scene/ridge/ridgeBake.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake'; -import { LAYERS, profile } from './ridgeWorld'; +import { LACUNARITY, LAYERS, profile } from './ridgeWorld'; import { skyColours, SkyColours } from './skyColour'; const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x @@ -219,3 +219,85 @@ describe('bake cost', () => { ); }); }); + +describe('the ridged multifractal core, each rule on its own', () => { + const one = { ...LAYERS[0], octaves: 1, warp: 0, crag: 1 }; + + it('folds each octave at its crest: a creased, sharp ridge line, not a smooth swell', () => { + // Largest second difference over a fine scan. A smooth curve of this size stays in the hundreds; + // a fold leaves a crease where the noise crosses its midpoint. + const h = 1e-3; + let worst = 0; + for (let i = 1; i < 20000; i += 1) { + const t = i * h; + worst = Math.max( + worst, + Math.abs(profile(one, t + h) - 2 * profile(one, t) + profile(one, t - h)) / (h * h) + ); + } + expect(worst).toBeGreaterThan(2000); + }); + + it('domain-warps: the same layer without its warp is a measurably different line', () => { + const spec = LAYERS[1]; + let diff = 0; + for (let i = 0; i < 2000; i += 1) + diff += Math.abs(profile(spec, i / 100) - profile({ ...spec, warp: 0 }, i / 100)); + expect(diff / 2000).toBeGreaterThan(0.02); + }); + + it('weights each octave by the previous one: where the first octave is low, the later ones add almost nothing', () => { + const lo = { ...LAYERS[1], octaves: 1, warp: 0 }; + const hi = { ...LAYERS[1], octaves: 5, warp: 0 }; + let valley = 0; + let nv = 0; + let peak = 0; + let np = 0; + for (let i = 0; i < 10000; i += 1) { + const a = profile(lo, i / 500); + const d = Math.abs(profile(hi, i / 500) - a); + if (a < 0.12) { + valley += d; + nv += 1; + } else if (a > 0.35) { + peak += d; + np += 1; + } + } + expect(nv).toBeGreaterThan(200); + expect(np).toBeGreaterThan(200); + expect(valley / nv).toBeLessThan(0.25 * (peak / np)); + }); + + it('octave lattices do not line up: the lacunarity is not a power of two', () => { + expect(Number.isInteger(Math.log2(LACUNARITY))).toBe(false); + expect(Number.isInteger(LACUNARITY)).toBe(false); + }); + + it('distance flattens relief: contrast falls from the nearest layer to the farthest', () => { + const c = LAYERS.map((spec) => layerPaint(spec, SKY).contrast); + expect(c[LAYERS.length - 1]).toBe(1); + for (let i = 1; i < c.length; i += 1) expect(c[i]).toBeGreaterThan(c[i - 1]); + // and it shows in the pixels: the lit/shadow difference is smaller on the farthest layer than the nearest + const gap = (li: number) => { + const cols = 700; + const d = bake(params(li, 0, cols)); + const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; + const avg = (pick: (s: number) => boolean) => { + const xs = Array.from({ length: cols - 4 }, (_, k) => k + 2).filter((x) => pick(slope(x))); + return ( + xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / + Math.max(1, xs.length) + ); + }; + return ( + Math.abs(avg((s) => s < -0.8) - avg((s) => s > 0.8)) / + Math.max( + 1, + avg(() => true) + ) + ); + }; + expect(gap(0)).toBeLessThan(gap(3)); + }); +});