From 4101cb721a29db01f78a9b7fb48521f8d9525a2e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 23 Apr 2026 11:11:30 -0700 Subject: [PATCH] test(shader-transitions): add midpoint (p=0.5) regression invariants for all shaders (#378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add four midpoint (`p=0.5`) regression invariants applied via a `describe` loop over `ALL_SHADERS`, so every existing and future shader transition automatically gets coverage at the most viewer-visible point in the animation. ## Why `Chunk 9G` of `plans/hdr-followups.md`. Existing smoke tests cover only the endpoints (`p=0 ≈ from`, `p=1 ≈ to`), which miss a class of regressions that surface specifically at the midpoint and let shaders silently rot in CI: - A shader becomes a no-op (returns input as-is) - A shader prematurely completes (returns target at midpoint) - A shader doesn't write to the output buffer at all - A shader loses determinism (`Math.random` / `Date.now` / leaked state) ## What changed `packages/engine/src/utils/shaderTransitions.test.ts`: a single `describe` loop over `ALL_SHADERS` that asserts at `p=0.5`: 1. `output ≠ from` — catches no-ops 2. `output ≠ to` — catches premature completion 3. `output` is non-zero — catches blank output 4. `output` is deterministic — catches accidental non-determinism Uses two distinct uniform input colors (40000/30000/20000 vs 10000/10000/10000) so equality checks have distinct byte patterns to compare against. Even shaders that warp UVs (which would be no-ops on uniform input alone) produce `mix16(from, to, 0.5)` at every pixel, distinct from both inputs. ## Test plan - [x] 60 new tests (4 invariants × 15 shaders), all passing. - [x] Any new transition added to the registry automatically picks up the same coverage. ## Stack Chunk 9G of `plans/hdr-followups.md`. Test-only change, independent of all other chunks. --- .../engine/src/services/chunkEncoder.test.ts | 1 + .../src/utils/shaderTransitions.test.ts | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 6a2fe93c5..dc2fe67e8 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -460,6 +460,7 @@ describe("buildEncoderArgs HDR color space", () => { expect.stringContaining("HDR is not supported with codec=h264"), ); warnSpy.mockRestore(); + }); it("uses range conversion for HDR CPU encoding", () => { diff --git a/packages/engine/src/utils/shaderTransitions.test.ts b/packages/engine/src/utils/shaderTransitions.test.ts index bb5eaaa43..0d8ecc4ba 100644 --- a/packages/engine/src/utils/shaderTransitions.test.ts +++ b/packages/engine/src/utils/shaderTransitions.test.ts @@ -576,6 +576,70 @@ describe("all transitions smoke test", () => { } }); +// ── all transitions: midpoint regressions (p=0.5) ─────────────────────────── +// +// Endpoint smoke tests above lock down p=0 (≈from) and p=1 (≈to). They miss +// regressions where a shader becomes a no-op, prematurely completes, returns +// garbage, or accidentally introduces non-determinism — specifically at the +// midpoint where the transition is most visible to viewers. Four invariants +// every shader must satisfy at p=0.5: +// +// 1. Output ≠ from catches "shader is a no-op, returns input as-is" +// 2. Output ≠ to catches "shader prematurely completes at midpoint" +// 3. Output is non-zero catches "shader didn't write anything to the buf" +// 4. Output is deterministic — catches accidental Math.random / Date.now / +// uninitialized-state regressions that would surface as flaky CI. +// +// Two distinct uniform colors give buffer-equality checks distinct byte +// patterns to compare against. Even shaders that warp UVs (which would be +// no-ops on uniform input alone) produce mix16(from, to, 0.5) = (25000, 20000, +// 15000), distinct from both inputs at every pixel. +describe("all transitions: midpoint regressions (p=0.5)", () => { + for (const name of ALL_SHADERS) { + describe(name, () => { + const w = 8; + const h = 8; + const from = makeBuffer(w, h, 40000, 30000, 20000); + const to = makeBuffer(w, h, 10000, 10000, 10000); + const zeros = Buffer.alloc(w * h * 6); + + it("output ≠ from (not a no-op at midpoint)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(from)).toBe(false); + }); + + it("output ≠ to (not premature completion at midpoint)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(to)).toBe(false); + }); + + it("output is non-zero (shader actually wrote pixels)", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out = Buffer.alloc(w * h * 6); + fn?.(from, to, out, w, h, 0.5); + expect(out.equals(zeros)).toBe(false); + }); + + it("output is deterministic across repeated calls", () => { + const fn = TRANSITIONS[name]; + expect(fn).toBeDefined(); + const out1 = Buffer.alloc(w * h * 6); + const out2 = Buffer.alloc(w * h * 6); + fn?.(from, to, out1, w, h, 0.5); + fn?.(from, to, out2, w, h, 0.5); + expect(out2.equals(out1)).toBe(true); + }); + }); + } +}); + // ── hdrToLinear / linearToHdr roundtrip ──────────────────────────────────── describe("hdrToLinear / linearToHdr", () => {