test(manifold): capture golden parity fixtures for TS pipelines/curves before P4

Records canonical gesture trace, curve catalog samples, and input/output
pipeline outputs from the current TS implementations, plus a bun-test drift
guard that re-runs them against the fixtures within 1e-9. Serves the P4
one-core-engine gate: same pointer trace -> same routed output pre/post the
C++/WASM migration.
This commit is contained in:
monkey-w1n5t0n 2026-07-13 23:25:31 +02:00
parent 29dc88be3a
commit fb0228e6c3
8 changed files with 34549 additions and 0 deletions

71
manifold/tests/fixtures/README.md vendored Normal file
View file

@ -0,0 +1,71 @@
# Pipeline golden fixtures
**Captured 2026-07-13** from the current **TypeScript** engine implementations,
**before** the P4 "one core engine" migration
(`docs/specs/plans/one-core-engine-refactor.md` §P4) replaces the TS
curve/input/output code with calls into the C++/WASM core.
P4's own gate reads: *"recorded-gesture regression: same pointer trace → same
routed output pre/post migration (capture fixture before starting)."* These
files are that capture.
## What is here
| File | What it pins | Source under test |
|---|---|---|
| `gesture-trace.json` | One canonical synthetic pointer trace (288 events, fixed 120 Hz dt) over the input pipeline's native `[0,1]²` domain: h/v sweeps, diagonal, spiral, figure-eight, dwell + abrupt corner jumps. Pure formula — no `Math.random`, no `Date.now`. | (input to the input pipeline) |
| `curves-golden.json` | `applyCurve(name, x)` for every curve id in `curves.ts`, 129 samples of `x ∈ [0,1]` inclusive, at each curve's default `param`. | `src/engine/curves.ts` |
| `input-pipeline-golden.json` | The gesture trace run through `processInput` under 14 representative configs (default, deadzone, zoom, sticky anchor, per-axis, curves, smoothing, invert, momentum gentle/strong, frozen axis, fully frozen, combined). Records `{x, y, frozen}` per event. Configs embedded. | `src/engine/input-pipeline.ts` |
| `output-pipeline-golden.json` | A deterministic raw-output sequence (120 vectors × 8 channels of offset sines, quantised to f32) run through `processOutput` under 8 configs (default, curves, smoothing, slew limiting, global-freeze toggled mid-sequence, per-output freeze mask, combined). Records the processed vector per step. Configs embedded. | `src/engine/output-pipeline.ts` |
## The drift guard
`../pipeline-golden.test.ts` (`bun test`) re-runs the **current** TS
implementations against these fixtures and asserts equality within **1e-9**. It
reads the trace, raw sequence, and configs **from the JSON** — the fixtures are
authoritative, so editing `pipeline-golden-lib.ts` config lists cannot mask a
regression. Any change to `curves.ts` / `input-pipeline.ts` / `output-pipeline.ts`
that alters numeric behaviour breaks this test until the goldens are
deliberately re-captured.
To re-capture (only when intended): `cd manifold && bun tests/fixtures/_generate.ts`.
## Contracts you must reproduce to consume these
### State contract (both pipelines are stateful)
- **Input:** EMA-smoothed x/y, a velocity ring, and a momentum-zoom multiplier.
- **Output:** `prev` + `smoothed` buffers driving slew/freeze.
Each config **run resets state** (`defaultInputState()` / `defaultOutputState()`)
at step 0. Runs are independent; do not carry state between them.
### Clock contract (input pipeline only)
`input-pipeline.ts`'s momentum-zoom path reads `performance.now()` (wall clock)
for its 150 ms velocity window. To make the momentum configs reproducible, the
capture pins `performance.now()` to each event's `t_ms` before processing it, so
the velocity window slides over the gesture's own timescale. `dt` passed to
`processInput` is the per-event `t_ms` delta in seconds (fixed `1000/120` ms).
A future consumer that ports this to C++ must feed the same per-event timestamps
(the trace's `t_ms`) into whatever owns the velocity ring, or the momentum runs
will not match. The output pipeline uses no wall clock; its `dtMs` is a fixed
`1000/60`.
### JSON encodings
- `slewRate: null` in an output spec means `Infinity` (JSON has no `Infinity`).
- Output raw values are pre-quantised with `Math.fround` so they equal exactly
what a `Float32Array` holds.
## How P4 should consume these
After the input/output/curve logic moves into the C++/WASM core, flip
`pipeline-golden.test.ts` to drive the **WASM** implementations (via the
main-thread `nisps` instance) instead of the TS `run*` helpers, keeping the same
fixtures as the expected values. That proves *same pointer trace → same routed
output* across the migration.
**Tolerance:** these goldens were produced in TS **f64**. The WASM core computes
in **f32** for many paths, so exact 1e-9 equality will not hold post-migration —
relax the comparison to about **1e-5** (and expect the sigmoid/exp/log curve tails
and long smoothing/slew accumulations to be the widest-drifting points). If any
value drifts materially beyond that, it is a real behavioural divergence, not
float noise, and must be reconciled in the core rather than by widening tolerance.

98
manifold/tests/fixtures/_generate.ts vendored Normal file
View file

@ -0,0 +1,98 @@
/**
* Regenerates the pipeline golden fixtures from the CURRENT TS implementations.
*
* Run once, from manifold/: `bun tests/fixtures/_generate.ts`
*
* This is the capture tool. It must only be re-run intentionally (it overwrites
* the goldens). The drift guard lives in tests/pipeline-golden.test.ts, which
* re-runs the same code against the committed fixtures without rewriting them.
*
* Captured 2026-07-13, before the P4 core migration
* (docs/specs/plans/one-core-engine-refactor.md §P4).
*/
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
CURVE_DEFAULT_PARAMS,
CURVE_SAMPLE_COUNT,
INPUT_DT_MS,
OUTPUT_DIMS,
OUTPUT_DT_MS,
buildGestureTrace,
buildOutputSequence,
inputRunSpecs,
outputRunSpecs,
runInputPipeline,
runOutputPipeline,
sampleAllCurves,
} from '../pipeline-golden-lib';
const DIR = dirname(fileURLToPath(import.meta.url));
const write = (name: string, data: unknown) => {
const path = join(DIR, name);
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
console.log('wrote', name);
};
const CAPTURED = '2026-07-13';
const SOURCE_NOTE =
'Captured from the TS engine implementations before the P4 one-core-engine migration. See tests/fixtures/README.md.';
// 1. Gesture trace ----------------------------------------------------------
const trace = buildGestureTrace();
write('gesture-trace.json', {
description: 'Canonical synthetic pointer trace over the input pipeline native [0,1]^2 domain.',
captured: CAPTURED,
note: SOURCE_NOTE,
dt_ms: INPUT_DT_MS,
count: trace.length,
domain: { x: [0, 1], y: [0, 1] },
segments: ['h-sweep', 'v-sweep', 'diagonal', 'spiral', 'figure-eight', 'dwell+jumps'],
events: trace,
});
// 2. Curves -----------------------------------------------------------------
write('curves-golden.json', {
description: 'applyCurve(name, x) sampled at 129 points x in [0,1] inclusive, using default params.',
captured: CAPTURED,
note: SOURCE_NOTE,
sampleCount: CURVE_SAMPLE_COUNT,
xStep: 1 / (CURVE_SAMPLE_COUNT - 1),
defaultParams: CURVE_DEFAULT_PARAMS,
curves: sampleAllCurves(),
});
// 3. Input pipeline ---------------------------------------------------------
write('input-pipeline-golden.json', {
description: 'Gesture trace (gesture-trace.json) run through processInput under representative configs.',
captured: CAPTURED,
note: SOURCE_NOTE,
traceRef: 'gesture-trace.json',
dt_ms: INPUT_DT_MS,
clockContract: 'performance.now() is pinned to each event t_ms during capture so momentum is deterministic.',
stateContract: 'State reset to defaultInputState() at step 0 of every run.',
runs: inputRunSpecs().map(({ id, config }) => ({
id,
config,
outputs: runInputPipeline(trace, config),
})),
});
// 4. Output pipeline --------------------------------------------------------
const sequence = buildOutputSequence();
write('output-pipeline-golden.json', {
description: 'Deterministic raw output vectors (offset sines, f32) run through processOutput under representative configs.',
captured: CAPTURED,
note: SOURCE_NOTE,
dt_ms: OUTPUT_DT_MS,
dims: OUTPUT_DIMS,
stateContract: 'State reset to defaultOutputState() at step 0 of every run. slewRate null = Infinity.',
sequence,
runs: outputRunSpecs().map((spec) => ({
id: spec.id,
spec,
outputs: runOutputPipeline(sequence, spec),
})),
});

1067
manifold/tests/fixtures/curves-golden.json vendored Normal file

File diff suppressed because it is too large Load diff

1467
manifold/tests/fixtures/gesture-trace.json vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,322 @@
/**
* Shared runner + generator library for the pipeline golden fixtures.
*
* Captured 2026-07-13, BEFORE the P4 "one core engine" migration
* (docs/specs/plans/one-core-engine-refactor.md §P4) replaces the TS
* curve/input/output implementations with C++/WASM calls.
*
* This module is imported by BOTH:
* - tests/fixtures/_generate.ts writes the *.json fixtures once, and
* - tests/pipeline-golden.test.ts re-runs the CURRENT TS implementations
* against the committed fixtures and asserts exact equality.
*
* The `run*` functions are the single source of truth for how a fixture was
* produced. The fixtures embed the trace / raw sequence / configs, so the test
* re-derives outputs purely from committed data no hidden inputs.
*
* --- Determinism / clock contract -----------------------------------------
* `input-pipeline.ts`'s momentum-zoom path reads `performance.now()` (wall
* clock) for its velocity ring. To make the momentum configs reproducible,
* `runInputPipeline` overrides `performance.now` with a synthetic clock driven
* by the trace's own `t_ms`: before processing event i, the clock is pinned to
* `events[i].t_ms`. The velocity window (150 ms) therefore slides over the
* gesture's own timescale, deterministically. The original `performance.now`
* is restored afterwards. `output-pipeline.ts` uses no wall clock.
*
* --- State contract --------------------------------------------------------
* Both pipelines are STATEFUL (input: EMA smoothing + velocity ring + momentum
* multiplier; output: prev + smoothed buffers for slew/freeze). Each config run
* RESETS state to `defaultInputState()` / `defaultOutputState()` at step 0, so
* runs are independent and order-free.
*/
import { CURVE_NAMES, applyCurve, type CurveName } from '../src/engine/curves';
import {
defaultInputConfig,
defaultInputState,
processInput,
type InputConfig,
} from '../src/engine/input-pipeline';
import {
defaultOutputState,
processOutput,
type OutputConfig,
} from '../src/engine/output-pipeline';
// ---------------------------------------------------------------------------
// Shared timebases
// ---------------------------------------------------------------------------
/** Input trace step: 120 Hz. */
export const INPUT_DT_MS = 1000 / 120; // 8.3333… ms
/** Output sequence step: 60 Hz. */
export const OUTPUT_DT_MS = 1000 / 60; // 16.6666… ms
/** Output vector width used by the synthetic raw sequence. */
export const OUTPUT_DIMS = 8;
// ---------------------------------------------------------------------------
// Fixture value types
// ---------------------------------------------------------------------------
export interface GestureEvent {
t_ms: number;
x: number;
y: number;
}
export interface InputRunOutput {
x: number;
y: number;
frozen: boolean;
}
/** JSON-serialisable input run: an id + the full InputConfig. */
export interface InputRunSpec {
id: string;
config: InputConfig;
}
/**
* JSON-serialisable output run spec. `slewRate: null` means `Infinity`
* (JSON has no Infinity). `freezeMaskIndices` freezes those output indices for
* the whole run. `freezeSteps: [start, end)` toggles the GLOBAL freeze gate on
* for that half-open step range.
*/
export interface OutputRunSpec {
id: string;
globalCurve: number;
smoothing: number;
slewRate: number | null;
freezeMaskIndices: number[] | null;
freezeSteps: [number, number] | null;
reuseBuffer: boolean;
}
// ---------------------------------------------------------------------------
// 1. Gesture trace generator (pure formula — no Math.random / Date.now)
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2;
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/**
* One canonical synthetic pointer trace over the pipeline's native [0,1]^2
* input domain. 288 events at a fixed 120 Hz dt. Six segments (48 events each)
* exercise: horizontal sweep, vertical sweep, diagonal corner-to-corner,
* growing spiral, a Lissajous figure-eight, and dwell periods punctuated by
* abrupt corner jumps. Endpoints (0 and 1) are visited so the full range is
* covered.
*/
export function buildGestureTrace(): GestureEvent[] {
const seg = 48;
const events: GestureEvent[] = [];
const push = (x: number, y: number) => {
const i = events.length;
events.push({ t_ms: i * INPUT_DT_MS, x: clamp01(x), y: clamp01(y) });
};
// Segment 1 — horizontal sweep left→right at mid height.
for (let i = 0; i < seg; i++) push(i / (seg - 1), 0.5);
// Segment 2 — vertical sweep bottom→top at mid width.
for (let i = 0; i < seg; i++) push(0.5, i / (seg - 1));
// Segment 3 — diagonal, corner (0,0) → (1,1).
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
push(t, t);
}
// Segment 4 — outward spiral around centre (radius 0 → 0.5).
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
const r = 0.5 * t;
const ang = TAU * 3 * t;
push(0.5 + r * Math.cos(ang), 0.5 + r * Math.sin(ang));
}
// Segment 5 — Lissajous figure-eight (1:2), amplitude 0.48.
for (let i = 0; i < seg; i++) {
const t = i / (seg - 1);
push(0.5 + 0.48 * Math.sin(TAU * t), 0.5 + 0.48 * Math.sin(TAU * 2 * t));
}
// Segment 6 — dwell + abrupt jumps. Hold a point for 8 frames, jump, repeat.
const stops: Array<[number, number]> = [
[0.5, 0.5],
[0.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
[1.0, 0.0],
[0.5, 0.5],
];
for (let s = 0; s < stops.length; s++) {
const [x, y] = stops[s]!;
for (let h = 0; h < seg / stops.length; h++) push(x, y);
}
return events;
}
// ---------------------------------------------------------------------------
// 2. Curve sampling
// ---------------------------------------------------------------------------
export const CURVE_SAMPLE_COUNT = 129; // 0..1 inclusive, step 1/128
/** Default `param` used per curve (mirrors applyCurve's `?? default`). */
export const CURVE_DEFAULT_PARAMS: Record<CurveName, number | null> = {
linear: null,
exp: 4.0,
log: 4.0,
square: null,
sqrt: null,
sigmoid: 8.0,
cubic: null,
centered_power: 1.0,
};
export function sampleCurve(name: CurveName): number[] {
const out: number[] = [];
for (let i = 0; i < CURVE_SAMPLE_COUNT; i++) {
const x = i / (CURVE_SAMPLE_COUNT - 1); // inclusive endpoints
out.push(applyCurve(name, x));
}
return out;
}
export function sampleAllCurves(): Record<string, number[]> {
const out: Record<string, number[]> = {};
for (const name of CURVE_NAMES) out[name] = sampleCurve(name);
return out;
}
// ---------------------------------------------------------------------------
// 3. Input pipeline configs + runner
// ---------------------------------------------------------------------------
function cfg(overrides: Partial<InputConfig>): InputConfig {
return { ...defaultInputConfig(), ...overrides };
}
/** Representative input configs. Exercises every branch of processInput. */
export function inputRunSpecs(): InputRunSpec[] {
return [
{ id: 'default', config: cfg({}) },
{ id: 'deadzone', config: cfg({ deadzone: 0.3 }) },
{ id: 'zoom-narrow', config: cfg({ zoom: 0.4 }) },
{ id: 'zoom-sticky-anchor', config: cfg({ zoom: 0.5, anchorMode: 'sticky', anchorX: 0.3, anchorY: 0.7 }) },
{ id: 'curve-pull-center', config: cfg({ inputCurve: 3.0 }) },
{ id: 'curve-push-extremes', config: cfg({ inputCurve: 0.4 }) },
{ id: 'smoothing', config: cfg({ smoothing: 0.8 }) },
{ id: 'invert-both', config: cfg({ invertX: true, invertY: true }) },
{ id: 'per-axis', config: cfg({ zoomX: 0.6, zoomY: 1.0, inputCurveX: 2.0, inputCurveY: 0.5 }) },
{ id: 'momentum-gentle', config: cfg({ momentumZoom: 'gentle' }) },
{ id: 'momentum-strong', config: cfg({ momentumZoom: 'strong', smoothing: 0.5 }) },
{ id: 'mixed-frozen-axis', config: cfg({ zoomX: 0.005, zoomY: 1.0 }) },
{ id: 'fully-frozen', config: cfg({ zoom: 0.005 }) },
{
id: 'combined',
config: cfg({ deadzone: 0.2, zoom: 0.7, inputCurve: 1.6, smoothing: 0.6, momentumZoom: 'gentle' }),
},
];
}
/**
* Run the gesture trace through the input pipeline under one config.
* Resets state at step 0. Drives a synthetic `performance.now` from the trace's
* t_ms so the momentum path is deterministic (see clock contract above).
*/
export function runInputPipeline(trace: readonly GestureEvent[], config: InputConfig): InputRunOutput[] {
const perf = globalThis.performance as { now(): number };
const realNow = perf.now;
let clock = 0;
perf.now = () => clock;
try {
let state = defaultInputState();
const outputs: InputRunOutput[] = [];
let prevT = trace.length > 0 ? trace[0]!.t_ms : 0;
for (const ev of trace) {
clock = ev.t_ms;
const dt = Math.max(0, (ev.t_ms - prevT) / 1000);
prevT = ev.t_ms;
const res = processInput([ev.x, ev.y], config, state, dt);
outputs.push({ x: res.x, y: res.y, frozen: res.frozen });
state = res.state;
}
return outputs;
} finally {
perf.now = realNow;
}
}
// ---------------------------------------------------------------------------
// 4. Output raw sequence + configs + runner
// ---------------------------------------------------------------------------
/**
* A deterministic raw output sequence: 120 vectors of width OUTPUT_DIMS, each
* channel an offset sine, quantised to f32 (Math.fround) so it matches exactly
* what the Float32Array pipeline input holds.
*/
export function buildOutputSequence(steps = 120, dims = OUTPUT_DIMS): number[][] {
const seq: number[][] = [];
for (let s = 0; s < steps; s++) {
const row: number[] = [];
for (let j = 0; j < dims; j++) {
const freq = (j + 1) * 0.5;
const phase = j / dims;
const v = 0.5 + 0.5 * Math.sin(TAU * (freq * (s / steps) + phase));
row.push(Math.fround(v));
}
seq.push(row);
}
return seq;
}
/** Representative output configs. */
export function outputRunSpecs(): OutputRunSpec[] {
return [
{ id: 'default', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'curve-pull', globalCurve: 2.5, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'curve-push', globalCurve: 0.4, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'smoothing', globalCurve: 1.0, smoothing: 0.85, slewRate: null, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'slew-limited', globalCurve: 1.0, smoothing: 0, slewRate: 0.5, freezeMaskIndices: null, freezeSteps: null, reuseBuffer: false },
{ id: 'freeze-toggled', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: null, freezeSteps: [40, 80], reuseBuffer: false },
{ id: 'freeze-mask', globalCurve: 1.0, smoothing: 0, slewRate: null, freezeMaskIndices: [0, 2, 4], freezeSteps: null, reuseBuffer: false },
{ id: 'combined', globalCurve: 1.8, smoothing: 0.7, slewRate: 1.0, freezeMaskIndices: null, freezeSteps: [90, 110], reuseBuffer: false },
];
}
function outputConfigForStep(spec: OutputRunSpec, step: number, dims: number): OutputConfig {
const frozen = spec.freezeSteps ? step >= spec.freezeSteps[0] && step < spec.freezeSteps[1] : false;
let mask: Uint8Array | null = null;
if (spec.freezeMaskIndices) {
mask = new Uint8Array(dims);
for (const i of spec.freezeMaskIndices) if (i >= 0 && i < dims) mask[i] = 1;
}
return {
globalCurve: spec.globalCurve,
smoothing: spec.smoothing,
slewRate: spec.slewRate === null ? Infinity : spec.slewRate,
freezeOutput: frozen,
freezeMask: mask,
reuseBuffer: spec.reuseBuffer,
};
}
/**
* Run the raw sequence through the output pipeline under one spec. Resets state
* at step 0. The global-freeze gate follows `spec.freezeSteps`.
*/
export function runOutputPipeline(sequence: readonly number[][], spec: OutputRunSpec): number[][] {
const dims = sequence.length > 0 ? sequence[0]!.length : 0;
let state = defaultOutputState();
const outputs: number[][] = [];
for (let s = 0; s < sequence.length; s++) {
const raw = Float32Array.from(sequence[s]!);
const config = outputConfigForStep(spec, s, dims);
const res = processOutput(raw, config, state, OUTPUT_DT_MS);
outputs.push(Array.from(res.processed));
state = res.state;
}
return outputs;
}

View file

@ -0,0 +1,111 @@
/**
* Pipeline golden drift guard (run with `bun test`).
*
* Re-runs the CURRENT TS curve / input-pipeline / output-pipeline
* implementations against the committed fixtures in ./fixtures and asserts
* exact equality (tolerance 1e-9). It pins:
* - the pointer trace (gesture-trace.json) routed input output,
* - applyCurve(name, x) over the curve catalog,
* - the raw-output sequence processed output.
*
* Purpose: guard the TS implementations against silent drift until P4
* (docs/specs/plans/one-core-engine-refactor.md §P4) flips these assertions to
* the C++/WASM implementations. See fixtures/README.md for the migration
* playbook and the f32-vs-f64 tolerance note.
*
* The fixtures are authoritative: configs, trace, and raw sequence are read
* FROM the JSON, so editing pipeline-golden-lib.ts config lists cannot mask a
* regression here only re-running fixtures/_generate.ts updates the goldens.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { expect, test } from 'bun:test';
import type { CurveName } from '../src/engine/curves';
import { applyCurve } from '../src/engine/curves';
import type { InputConfig } from '../src/engine/input-pipeline';
import {
runInputPipeline,
runOutputPipeline,
type GestureEvent,
type OutputRunSpec,
} from './pipeline-golden-lib';
const DIR = dirname(fileURLToPath(import.meta.url));
const readFixture = <T>(name: string): T =>
JSON.parse(readFileSync(join(DIR, 'fixtures', name), 'utf8')) as T;
const TOL = 1e-9;
const close = (a: number, b: number, ctx: string) => {
if (a === b) return;
expect(Math.abs(a - b), ctx).toBeLessThanOrEqual(TOL);
};
// ---------------------------------------------------------------------------
test('gesture trace fixture is well-formed (>=240 events, fixed dt, in-domain)', () => {
const trace = readFixture<{ dt_ms: number; count: number; events: GestureEvent[] }>('gesture-trace.json');
expect(trace.events.length).toBe(trace.count);
expect(trace.events.length).toBeGreaterThanOrEqual(240);
for (let i = 0; i < trace.events.length; i++) {
const ev = trace.events[i]!;
close(ev.t_ms, i * trace.dt_ms, `event ${i} t_ms`);
expect(ev.x).toBeGreaterThanOrEqual(0);
expect(ev.x).toBeLessThanOrEqual(1);
expect(ev.y).toBeGreaterThanOrEqual(0);
expect(ev.y).toBeLessThanOrEqual(1);
}
});
test('curves-golden: applyCurve matches captured samples', () => {
const fx = readFixture<{ sampleCount: number; curves: Record<string, number[]> }>('curves-golden.json');
const names = Object.keys(fx.curves) as CurveName[];
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
const golden = fx.curves[name]!;
expect(golden.length).toBe(fx.sampleCount);
for (let i = 0; i < golden.length; i++) {
const x = i / (fx.sampleCount - 1);
close(applyCurve(name, x), golden[i]!, `curve ${name}[${i}] (x=${x})`);
}
}
});
test('input-pipeline-golden: processInput matches captured outputs', () => {
const trace = readFixture<{ events: GestureEvent[] }>('gesture-trace.json').events;
const fx = readFixture<{
runs: Array<{ id: string; config: InputConfig; outputs: Array<{ x: number; y: number; frozen: boolean }> }>;
}>('input-pipeline-golden.json');
expect(fx.runs.length).toBeGreaterThan(0);
for (const run of fx.runs) {
const got = runInputPipeline(trace, run.config);
expect(got.length, `run ${run.id} length`).toBe(run.outputs.length);
for (let i = 0; i < got.length; i++) {
close(got[i]!.x, run.outputs[i]!.x, `input ${run.id}[${i}].x`);
close(got[i]!.y, run.outputs[i]!.y, `input ${run.id}[${i}].y`);
expect(got[i]!.frozen, `input ${run.id}[${i}].frozen`).toBe(run.outputs[i]!.frozen);
}
}
});
test('output-pipeline-golden: processOutput matches captured outputs', () => {
const fx = readFixture<{
sequence: number[][];
runs: Array<{ id: string; spec: OutputRunSpec; outputs: number[][] }>;
}>('output-pipeline-golden.json');
expect(fx.runs.length).toBeGreaterThan(0);
expect(fx.sequence.length).toBeGreaterThanOrEqual(100);
for (const run of fx.runs) {
const got = runOutputPipeline(fx.sequence, run.spec);
expect(got.length, `run ${run.id} length`).toBe(run.outputs.length);
for (let s = 0; s < got.length; s++) {
const gotRow = got[s]!;
const wantRow = run.outputs[s]!;
expect(gotRow.length, `output ${run.id}[${s}] width`).toBe(wantRow.length);
for (let j = 0; j < gotRow.length; j++) {
close(gotRow[j]!, wantRow[j]!, `output ${run.id}[${s}][${j}]`);
}
}
}
});