Migrate the specs worth keeping from playground/tests/e2e (retired in P1) into manifold/tests/e2e, adapted to Manifold's probe surface: - probe-api.spec.ts: the window.__nisps debug-probe contract (ready, bounded outputs, example count, randomise, setInputs inference, thumbsUp/Down, addExample, train loss non-increasing, async train, clearExamples, evalLoss, inferBatch, getLayerStats, getWeights). Retargeted to MLP<32,10,14,18,126> (weight_count 3148) and Manifold's direct addExample/routedOutputs surface; dropped the playground's __init/iml-poke escape hatches and stream-pending skips. - spine.spec.ts: the spine invariant — setInputs -> processed -> ml -> routed yields bounded, consistent routed outputs; the probe stays alive across dock output-mode switches (driven via the real selector UI, replacing the playground's localStorage-reload mode cycling). - helpers.ts: loadProbe(?debug=1 + cleared storage + __ready wait), settleInputs for EMA convergence, bounded/changed assertions. Dropped playground UI specs (ui-interactions, persistence, mode-registry list) that die with the playground chrome. No probe.ts changes needed.
104 lines
4.6 KiB
TypeScript
104 lines
4.6 KiB
TypeScript
/**
|
|
* Spine invariant.
|
|
*
|
|
* The engine spine (`src/engine/spine.ts`) is: setInputs → processed → ml →
|
|
* routed, derived EAGERLY + SYNCHRONOUSLY off React's render cycle. This spec
|
|
* asserts the two properties that make the spine the trustworthy core of the
|
|
* app:
|
|
*
|
|
* 1. Pushing inputs through the spine yields BOUNDED, CONSISTENT routed
|
|
* outputs — distinct inputs map to distinct outputs, the same input
|
|
* (once the input/output EMA smoothing settles) converges to a stable
|
|
* vector, and both the post-ML and routed vectors stay in [0, 1].
|
|
* 2. The probe (hence the WASM engine and the reactive spine) stays ALIVE
|
|
* across output-mode switches — flipping the convertible Console between
|
|
* Stages must never tear down or re-init the net.
|
|
*
|
|
* Distilled from `playground/tests/e2e/modes.spec.ts` ("cycling through all
|
|
* modes leaves probe alive"). The playground drove mode changes by reloading
|
|
* with a `nisps-mode-store` localStorage key; Manifold has no such store, so we
|
|
* drive the real dock mode selector instead — a genuine in-tab Stage switch,
|
|
* no reload, no probe teardown.
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import { loadProbe, getOutputs, getRouted, settleInputs, countChanged, allWithin } from './helpers';
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await loadProbe(page);
|
|
});
|
|
|
|
test.describe('spine — bounded, consistent routed outputs', () => {
|
|
test('setInputs yields bounded post-ML and routed vectors', async ({ page }) => {
|
|
await page.evaluate(() => window.__nisps!.setInputs(0.25, 0.75));
|
|
const outs = await getOutputs(page);
|
|
const routed = await getRouted(page);
|
|
|
|
expect(outs.length).toBeGreaterThan(0);
|
|
expect(routed.length).toBe(outs.length);
|
|
expect(allWithin(outs, 0, 1)).toBe(true);
|
|
expect(allWithin(routed, 0, 1)).toBe(true);
|
|
});
|
|
|
|
test('distinct inputs produce distinct outputs; the same input converges', async ({ page }) => {
|
|
// Settle at A, then read A again — smoothing has converged, so the two
|
|
// reads must be (near-)identical: the mapping is a stable function of the
|
|
// input once the pipeline state has caught up.
|
|
const a1 = await settleInputs(page, 0.3, 0.7);
|
|
const a2 = await settleInputs(page, 0.3, 0.7, 5);
|
|
expect(a1.length).toBe(a2.length);
|
|
expect(countChanged(a1, a2, 1e-3)).toBe(0);
|
|
expect(allWithin(a1, 0, 1)).toBe(true);
|
|
|
|
// A different input must move the mapping (spine actually propagates).
|
|
const b = await settleInputs(page, 0.8, 0.2);
|
|
expect(countChanged(a1, b, 1e-3)).toBeGreaterThan(0);
|
|
expect(allWithin(b, 0, 1)).toBe(true);
|
|
});
|
|
|
|
test('routed output tracks the input across a sweep, staying bounded', async ({ page }) => {
|
|
const points: Array<[number, number]> = [
|
|
[0.1, 0.1],
|
|
[0.5, 0.5],
|
|
[0.9, 0.9],
|
|
];
|
|
for (const [x, y] of points) {
|
|
await settleInputs(page, x, y);
|
|
const routed = await getRouted(page);
|
|
expect(routed.length).toBeGreaterThan(0);
|
|
expect(allWithin(routed, 0, 1)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('spine — probe stays alive across mode switches', () => {
|
|
/**
|
|
* Switch the dock's output-mode selector via the real UI. The "M" button
|
|
* (title `Mode: <label>`) opens the popover; each mode is a button named by
|
|
* its label. We assert the selector's title updates (deterministic wait, no
|
|
* sleep) so we know the switch landed before probing.
|
|
*/
|
|
async function switchMode(page: import('@playwright/test').Page, label: string): Promise<void> {
|
|
await page.getByTitle(/^Mode:/).click();
|
|
await page.getByRole('button', { name: label, exact: true }).click();
|
|
await expect(page.getByTitle(`Mode: ${label}`)).toBeVisible();
|
|
}
|
|
|
|
test('cycling output modes keeps the probe ready and the spine live', async ({ page }) => {
|
|
// Network-free modes only (OSC / CV need a bridge; Editor is a serial stub).
|
|
const modes = ['Built-in Synth', 'MIDI', 'Particle System'];
|
|
|
|
for (const label of modes) {
|
|
await switchMode(page, label);
|
|
|
|
// The probe (and thus the WASM engine + spine) must survive the switch.
|
|
const ready = await page.evaluate(() => window.__nisps?.__ready === true);
|
|
expect(ready, `probe not ready after switching to ${label}`).toBe(true);
|
|
|
|
// And the spine must still propagate a fresh input to bounded outputs.
|
|
await page.evaluate(() => window.__nisps!.setInputs(0.35, 0.65));
|
|
const outs = await getOutputs(page);
|
|
expect(outs.length, `no outputs after ${label}`).toBeGreaterThan(0);
|
|
expect(allWithin(outs, 0, 1), `unbounded outputs after ${label}`).toBe(true);
|
|
}
|
|
});
|
|
});
|