feat(tests): add Playwright e2e suite + debug probe for a-immersive
- Add window.__nisps debug probe (gated on ?debug=1) exposing iml state,
getOutputs/getLoss/getWeights/getExampleCount, and action triggers
(thumbsUp/thumbsDown/train/randomise/clearExamples/saveState)
- Fix WasmIML bug: this.dataset was a plain object; import Dataset and
use new Dataset(100) so computeWeights() is available for training
- Fix WasmIML.addExample/clearDataset to use Dataset API methods
- 44 Playwright e2e tests across 4 spec files:
- ml-engine.spec.js: WASM inference bounds, training loss, thumbs
up/down behavior, async training, example capture semantics
- ui-interactions.spec.js: drawer open/close, mode switching,
heatmap bar counts, preset chips, keyboard shortcuts (1/2/Z)
- input-pipeline.spec.js: input→output variation, clamping, joystick
drag, post-training output bounds across the full input space
- persistence.spec.js: URL params (?preset, ?spread), localStorage
round-trip, saveState probe
This commit is contained in:
parent
b5b90a623d
commit
44fc974425
10 changed files with 706 additions and 11 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -41,3 +41,9 @@ playground/osc-bridge/node_modules/
|
||||||
# Dolt database files (added by bd init)
|
# Dolt database files (added by bd init)
|
||||||
.dolt/
|
.dolt/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Node / Playwright
|
||||||
|
node_modules/
|
||||||
|
bun.lock
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
|
||||||
13
package.json
Normal file
13
package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "memlnaut-nisps-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:headed": "playwright test --headed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -869,6 +869,54 @@ async function init() {
|
||||||
// Auto-save every 10 seconds
|
// Auto-save every 10 seconds
|
||||||
setInterval(saveState, 10000);
|
setInterval(saveState, 10000);
|
||||||
|
|
||||||
|
// Debug probe — exposed on window when ?debug=1 is in the URL.
|
||||||
|
// Used by Playwright e2e tests. Zero footprint in production.
|
||||||
|
if (new URLSearchParams(window.location.search).has('debug')) {
|
||||||
|
window.__nisps = {
|
||||||
|
get iml() { return iml; },
|
||||||
|
get imlJoy() { return imlJoy; },
|
||||||
|
get imlHand(){ return imlHand; },
|
||||||
|
getOutputs: () => [...iml.getOutputs()],
|
||||||
|
getLoss: () => iml.lastLoss,
|
||||||
|
getWeights: () => iml._getFlatWeights(),
|
||||||
|
getExampleCount: () => iml.exampleCount,
|
||||||
|
setInputs: (x, y) => {
|
||||||
|
iml.setInput(0, x);
|
||||||
|
iml.setInput(1, y);
|
||||||
|
iml.process();
|
||||||
|
const outputs = iml.getOutputs();
|
||||||
|
routeOutputs(outputs);
|
||||||
|
updateHeatmap(outputs);
|
||||||
|
},
|
||||||
|
thumbsUp: () => onThumbsUp(),
|
||||||
|
thumbsDown: () => onThumbsDown(),
|
||||||
|
train: () => {
|
||||||
|
const loss = trainModel();
|
||||||
|
const outputs = iml.getOutputs();
|
||||||
|
routeOutputs(outputs);
|
||||||
|
updateHeatmap(outputs);
|
||||||
|
syncRawParamsFromOutputs(outputs);
|
||||||
|
updateStatus();
|
||||||
|
drawLossPlot();
|
||||||
|
return loss;
|
||||||
|
},
|
||||||
|
trainAsync: () => new Promise(resolve => trainModelAsync(resolve)),
|
||||||
|
randomise: () => {
|
||||||
|
iml.randomiseWeights(spreadLevel);
|
||||||
|
const outputs = iml.getOutputs();
|
||||||
|
routeOutputs(outputs);
|
||||||
|
updateHeatmap(outputs);
|
||||||
|
syncRawParamsFromOutputs(outputs);
|
||||||
|
updateStatus();
|
||||||
|
},
|
||||||
|
clearExamples: () => {
|
||||||
|
iml.clearDataset();
|
||||||
|
updateStatus();
|
||||||
|
},
|
||||||
|
saveState: () => saveState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Start animation
|
// Start animation
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
|
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
|
||||||
// Training runs in a Web Worker for non-blocking operation.
|
// Training runs in a Web Worker for non-blocking operation.
|
||||||
|
|
||||||
|
import { Dataset } from './dataset.js';
|
||||||
|
|
||||||
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
|
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
|
||||||
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
|
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
|
||||||
|
|
||||||
|
|
@ -116,8 +118,8 @@ export class WasmIML {
|
||||||
this.totalTrainingIterations = 0;
|
this.totalTrainingIterations = 0;
|
||||||
this.logFn = null;
|
this.logFn = null;
|
||||||
|
|
||||||
// Dataset (JS-side for persistence/visualization access)
|
// Dataset (JS-side for persistence/visualization access and sample weighting)
|
||||||
this.dataset = { features: [], labels: [], maxExamples: 100 };
|
this.dataset = new Dataset(100);
|
||||||
|
|
||||||
// Persistent WASM buffers for inference (avoid alloc/free per frame)
|
// Persistent WASM buffers for inference (avoid alloc/free per frame)
|
||||||
const inputDim = nInputs + BIAS;
|
const inputDim = nInputs + BIAS;
|
||||||
|
|
@ -200,18 +202,11 @@ export class WasmIML {
|
||||||
while (inVec.length < this.nInputs) inVec.push(0);
|
while (inVec.length < this.nInputs) inVec.push(0);
|
||||||
const outVec = outputs.slice(0, this.nOutputs);
|
const outVec = outputs.slice(0, this.nOutputs);
|
||||||
while (outVec.length < this.nOutputs) outVec.push(0);
|
while (outVec.length < this.nOutputs) outVec.push(0);
|
||||||
|
this.dataset.add(inVec, outVec);
|
||||||
if (this.dataset.features.length >= this.dataset.maxExamples) {
|
|
||||||
this.dataset.features.shift();
|
|
||||||
this.dataset.labels.shift();
|
|
||||||
}
|
|
||||||
this.dataset.features.push([...inVec]);
|
|
||||||
this.dataset.labels.push([...outVec]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDataset() {
|
clearDataset() {
|
||||||
this.dataset.features = [];
|
this.dataset.clear();
|
||||||
this.dataset.labels = [];
|
|
||||||
this.log('Dataset cleared.');
|
this.log('Dataset cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
27
playwright.config.js
Normal file
27
playwright.config.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:7331',
|
||||||
|
headless: true,
|
||||||
|
// WASM + AudioContext require a stable origin
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
},
|
||||||
|
// Start a static file server against the playground/ dir before tests run.
|
||||||
|
// python3 -m http.server serves directory listings and static assets fine.
|
||||||
|
// WASM files need correct MIME type — Python's server handles .wasm correctly.
|
||||||
|
webServer: {
|
||||||
|
command: 'python3 -m http.server 7331',
|
||||||
|
cwd: './playground',
|
||||||
|
url: 'http://localhost:7331',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
});
|
||||||
32
tests/e2e/helpers.js
Normal file
32
tests/e2e/helpers.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Shared helpers for e2e tests.
|
||||||
|
*/
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a-immersive with ?debug=1 and wait for the WASM engine to
|
||||||
|
* initialise and expose window.__nisps.
|
||||||
|
*
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
* @param {string} extraParams - additional query string, e.g. '&preset=beginner-1'
|
||||||
|
*/
|
||||||
|
async function loadApp(page, extraParams = '') {
|
||||||
|
// Clear app state but mark help as seen so the overlay doesn't block clicks.
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.removeItem('nisps-a-immersive');
|
||||||
|
localStorage.setItem('nisps-help-seen', '1');
|
||||||
|
});
|
||||||
|
await page.goto(`/a-immersive.html?debug=1${extraParams}`);
|
||||||
|
// Wait until the debug probe is ready (WASM init is async).
|
||||||
|
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current text content of the status line.
|
||||||
|
* @param {import('@playwright/test').Page} page
|
||||||
|
*/
|
||||||
|
async function statusText(page) {
|
||||||
|
return page.locator('#status-text').textContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadApp, statusText };
|
||||||
110
tests/e2e/input-pipeline.spec.js
Normal file
110
tests/e2e/input-pipeline.spec.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Input pipeline tests — joystick → MLP inputs → outputs → heatmap.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loadApp } = require('./helpers');
|
||||||
|
|
||||||
|
test.describe('Joystick → output pipeline', () => {
|
||||||
|
test('different input positions produce different outputs', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__nisps.setInputs(0.1, 0.1));
|
||||||
|
const out1 = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__nisps.setInputs(0.9, 0.9));
|
||||||
|
const out2 = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
|
||||||
|
const anyChanged = out1.some((v, i) => Math.abs(v - out2[i]) > 0.0001);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all outputs remain in [0, 1] across input positions', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const corners = [[0, 0], [0, 1], [1, 0], [1, 1], [0.5, 0.5]];
|
||||||
|
for (const [x, y] of corners) {
|
||||||
|
await page.evaluate(([x, y]) => window.__nisps.setInputs(x, y), [x, y]);
|
||||||
|
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
for (const v of outputs) {
|
||||||
|
expect(v).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heatmap bar widths change when inputs change', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
|
||||||
|
const widths1 = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('.heatmap-cell-bar')).map(el => el.style.width)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move to far corner
|
||||||
|
await page.evaluate(() => window.__nisps.setInputs(0.95, 0.05));
|
||||||
|
|
||||||
|
const widths2 = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('.heatmap-cell-bar')).map(el => el.style.width)
|
||||||
|
);
|
||||||
|
|
||||||
|
const anyChanged = widths1.some((w, i) => w !== widths2[i]);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mouse drag on joystick container updates MLP inputs', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
|
||||||
|
const box = await page.locator('#joystick-container').boundingBox();
|
||||||
|
const cx = box.x + box.width / 2;
|
||||||
|
const cy = box.y + box.height / 2;
|
||||||
|
|
||||||
|
// Drag from center towards bottom-right
|
||||||
|
await page.mouse.move(cx, cy);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(cx + box.width * 0.3, cy + box.height * 0.3, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// After drag, at least one input axis should have moved from 0.5
|
||||||
|
const [x, y] = await page.evaluate(() => [
|
||||||
|
window.__nisps.iml.inputState[0],
|
||||||
|
window.__nisps.iml.inputState[1],
|
||||||
|
]);
|
||||||
|
const moved = Math.abs(x - 0.5) > 0.01 || Math.abs(y - 0.5) > 0.01;
|
||||||
|
expect(moved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setInputs clamps values to [0, 1]', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(() => window.__nisps.setInputs(-5, 99));
|
||||||
|
const [x, y] = await page.evaluate(() => [
|
||||||
|
window.__nisps.iml.inputState[0],
|
||||||
|
window.__nisps.iml.inputState[1],
|
||||||
|
]);
|
||||||
|
expect(x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(x).toBeLessThanOrEqual(1);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(y).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after training, moving inputs produces smoothly varying outputs', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
|
||||||
|
// Load calm-to-chaotic preset (3 examples spanning the input space)
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await page.click('[data-preset="calm-to-chaotic"]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('status-text').textContent.includes('loss'),
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sample 5 positions and verify all outputs are bounded
|
||||||
|
const positions = [0.0, 0.25, 0.5, 0.75, 1.0];
|
||||||
|
for (const t of positions) {
|
||||||
|
await page.evaluate((t) => window.__nisps.setInputs(t, 1 - t), t);
|
||||||
|
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
expect(outputs).toHaveLength(126);
|
||||||
|
for (const v of outputs) {
|
||||||
|
expect(v).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
170
tests/e2e/ml-engine.spec.js
Normal file
170
tests/e2e/ml-engine.spec.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* ML engine sanity tests — verify the WASM IML behaves correctly:
|
||||||
|
* - outputs are always bounded [0, 1]
|
||||||
|
* - randomize produces different outputs
|
||||||
|
* - thumbs-up captures the current rawParamValues as the training label
|
||||||
|
* - training completes and produces a finite loss
|
||||||
|
* - thumbs-down moves weights and changes outputs
|
||||||
|
* - async training (triggered by thumbs-up) updates the status line
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loadApp, statusText } = require('./helpers');
|
||||||
|
|
||||||
|
// Two contrasting examples with known inputs and all-low / all-high targets.
|
||||||
|
const EXAMPLE_LOW = { input: [0.1, 0.9], output: new Array(126).fill(0.1) };
|
||||||
|
const EXAMPLE_HIGH = { input: [0.9, 0.1], output: new Array(126).fill(0.9) };
|
||||||
|
|
||||||
|
test.describe('ML engine (WASM IML)', () => {
|
||||||
|
test('probe is exposed after WASM init', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const probe = await page.evaluate(() => typeof window.__nisps);
|
||||||
|
expect(probe).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial outputs are all in [0, 1]', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
expect(outputs).toHaveLength(126);
|
||||||
|
for (const v of outputs) {
|
||||||
|
expect(v).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial state is 0 examples, untrained', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const count = await page.evaluate(() => window.__nisps.getExampleCount());
|
||||||
|
expect(count).toBe(0);
|
||||||
|
const loss = await page.evaluate(() => window.__nisps.getLoss());
|
||||||
|
expect(loss).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('randomize changes outputs', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const before = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
await page.evaluate(() => window.__nisps.randomise());
|
||||||
|
const after = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
const anyChanged = before.some((v, i) => Math.abs(v - after[i]) > 0.001);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbs-up increments example count by 1', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(() => window.__nisps.thumbsUp());
|
||||||
|
// Give async training a moment to start but we only need to check example count
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const count = await page.evaluate(() => window.__nisps.getExampleCount());
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbs-up captures current input position and all 126 output values', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
// Set a known joystick position via the probe
|
||||||
|
await page.evaluate(() => window.__nisps.setInputs(0.25, 0.75));
|
||||||
|
await page.evaluate(() => window.__nisps.thumbsUp());
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
const { features, labels } = await page.evaluate(() => ({
|
||||||
|
features: window.__nisps.iml.dataset.features,
|
||||||
|
labels: window.__nisps.iml.dataset.labels,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(features).toHaveLength(1);
|
||||||
|
expect(labels).toHaveLength(1);
|
||||||
|
|
||||||
|
// Input dimension = 2 (joystick x/y, pipeline-processed)
|
||||||
|
expect(features[0]).toHaveLength(2);
|
||||||
|
// The input pipeline may transform values; inputs must stay in [0, 1]
|
||||||
|
expect(features[0][0]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(features[0][0]).toBeLessThanOrEqual(1);
|
||||||
|
expect(features[0][1]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(features[0][1]).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Labels = all 126 output values, captured from rawParamValues at click time
|
||||||
|
expect(labels[0]).toHaveLength(126);
|
||||||
|
for (const v of labels[0]) {
|
||||||
|
expect(v).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sync train() returns a finite non-negative loss', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(([low, high]) => {
|
||||||
|
window.__nisps.iml.addExample(low.input, low.output);
|
||||||
|
window.__nisps.iml.addExample(high.input, high.output);
|
||||||
|
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
|
||||||
|
|
||||||
|
const loss = await page.evaluate(() => window.__nisps.train());
|
||||||
|
expect(typeof loss).toBe('number');
|
||||||
|
expect(isFinite(loss)).toBe(true);
|
||||||
|
expect(loss).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('training with contrasting examples produces a lower loss than initial', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
// Initial inference — loss is null (never trained), so randomise to get a baseline
|
||||||
|
await page.evaluate(() => window.__nisps.randomise());
|
||||||
|
|
||||||
|
await page.evaluate(([low, high]) => {
|
||||||
|
window.__nisps.iml.addExample(low.input, low.output);
|
||||||
|
window.__nisps.iml.addExample(high.input, high.output);
|
||||||
|
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
|
||||||
|
|
||||||
|
const loss1 = await page.evaluate(() => window.__nisps.train());
|
||||||
|
const loss2 = await page.evaluate(() => window.__nisps.train());
|
||||||
|
|
||||||
|
// Second training run on same data should converge further (loss2 <= loss1)
|
||||||
|
expect(loss2).toBeLessThanOrEqual(loss1 + 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status line reflects example count and loss after training', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(([low, high]) => {
|
||||||
|
window.__nisps.iml.addExample(low.input, low.output);
|
||||||
|
window.__nisps.iml.addExample(high.input, high.output);
|
||||||
|
window.__nisps.train();
|
||||||
|
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
|
||||||
|
|
||||||
|
// updateStatus() is called inside trainModel()
|
||||||
|
const text = await page.locator('#status-text').textContent();
|
||||||
|
expect(text).toContain('2 examples');
|
||||||
|
expect(text).toContain('loss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbs-down changes outputs (weight noise)', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const before = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
await page.evaluate(() => window.__nisps.thumbsDown());
|
||||||
|
const after = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
const anyChanged = before.some((v, i) => Math.abs(v - after[i]) > 0.0001);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('async training via thumbs-up button updates status with loss', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('#btn-thumbsup');
|
||||||
|
// Wait for the async training to complete and status to update
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('status-text').textContent.includes('loss'),
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
const text = await page.locator('#status-text').textContent();
|
||||||
|
expect(text).toContain('1 example');
|
||||||
|
expect(text).toContain('loss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear examples resets to 0 and marks untrained', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(([low]) => {
|
||||||
|
window.__nisps.iml.addExample(low.input, low.output);
|
||||||
|
window.__nisps.train();
|
||||||
|
}, [EXAMPLE_LOW]);
|
||||||
|
expect(await page.evaluate(() => window.__nisps.getExampleCount())).toBe(1);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__nisps.clearExamples());
|
||||||
|
expect(await page.evaluate(() => window.__nisps.getExampleCount())).toBe(0);
|
||||||
|
const text = await page.locator('#status-text').textContent();
|
||||||
|
expect(text).toContain('0 examples');
|
||||||
|
});
|
||||||
|
});
|
||||||
118
tests/e2e/persistence.spec.js
Normal file
118
tests/e2e/persistence.spec.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* State persistence tests — localStorage round-trip and URL param application.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loadApp, statusText } = require('./helpers');
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'nisps-a-immersive';
|
||||||
|
|
||||||
|
test.describe('State persistence', () => {
|
||||||
|
test('fresh load with no localStorage starts untrained', async ({ page }) => {
|
||||||
|
await loadApp(page); // helpers.js clears localStorage before load
|
||||||
|
const text = await statusText(page);
|
||||||
|
expect(text).toContain('0 examples');
|
||||||
|
expect(text).toContain('untrained');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL ?preset=beginner-1 applies preset on load', async ({ page }) => {
|
||||||
|
await loadApp(page, '&preset=beginner-1');
|
||||||
|
const selected = await page.locator('#synth-preset-select').inputValue();
|
||||||
|
expect(selected).toBe('beginner-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL ?preset=advanced-2 applies advanced preset', async ({ page }) => {
|
||||||
|
await loadApp(page, '&preset=advanced-2');
|
||||||
|
const selected = await page.locator('#synth-preset-select').inputValue();
|
||||||
|
expect(selected).toBe('advanced-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL ?spread=0 is accepted without crash', async ({ page }) => {
|
||||||
|
await loadApp(page, '&spread=0');
|
||||||
|
// App should be functional — probe must still exist
|
||||||
|
const probe = await page.evaluate(() => typeof window.__nisps);
|
||||||
|
expect(probe).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL ?spread=1 is accepted without crash', async ({ page }) => {
|
||||||
|
await loadApp(page, '&spread=1');
|
||||||
|
const probe = await page.evaluate(() => typeof window.__nisps);
|
||||||
|
expect(probe).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('examples + weights persist across reload via localStorage', async ({ page }) => {
|
||||||
|
// Navigate fresh — no init script so localStorage is untouched between loads.
|
||||||
|
// Suppress the help overlay by pre-setting nisps-help-seen via evaluate.
|
||||||
|
await page.goto(`/a-immersive.html?debug=1`);
|
||||||
|
await page.evaluate(() => localStorage.removeItem('nisps-a-immersive'));
|
||||||
|
await page.evaluate(() => localStorage.setItem('nisps-help-seen', '1'));
|
||||||
|
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Write 2 examples directly to localStorage in the app's save format.
|
||||||
|
await page.evaluate(([key]) => {
|
||||||
|
const state = {
|
||||||
|
features: [[0.1, 0.9], [0.9, 0.1]],
|
||||||
|
labels: [new Array(126).fill(0.2), new Array(126).fill(0.8)],
|
||||||
|
handFeatures: [],
|
||||||
|
handLabels: [],
|
||||||
|
noiseLevel: 0.05,
|
||||||
|
outputMode: 'visual',
|
||||||
|
inputMode: 'joystick',
|
||||||
|
joyX: 0.5, joyY: 0.5,
|
||||||
|
groupOverrides: null,
|
||||||
|
visualOverrides: null,
|
||||||
|
midiCCOverrides: null,
|
||||||
|
audioCanvasState: null,
|
||||||
|
synthPresetId: null,
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify(state));
|
||||||
|
}, [STORAGE_KEY]);
|
||||||
|
|
||||||
|
// Reload — no addInitScript registered, so localStorage is preserved.
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
|
||||||
|
|
||||||
|
// loadState() runs sync training; wait for status to show a loss value.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('status-text').textContent.includes('loss'),
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = await page.evaluate(() => window.__nisps.getExampleCount());
|
||||||
|
expect(count).toBe(2);
|
||||||
|
const text = await statusText(page);
|
||||||
|
expect(text).toContain('2 examples');
|
||||||
|
expect(text).toContain('loss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveState probe writes valid JSON to localStorage', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(() => window.__nisps.saveState());
|
||||||
|
|
||||||
|
const raw = await page.evaluate(([key]) => localStorage.getItem(key), [STORAGE_KEY]);
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
expect(Array.isArray(state.features)).toBe(true);
|
||||||
|
expect(Array.isArray(state.labels)).toBe(true);
|
||||||
|
expect(typeof state.outputMode).toBe('string');
|
||||||
|
expect(typeof state.noiseLevel).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('localStorage state is overwritten on next saveState call', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.evaluate(() => window.__nisps.saveState());
|
||||||
|
|
||||||
|
const before = await page.evaluate(([key]) => localStorage.getItem(key), [STORAGE_KEY]);
|
||||||
|
|
||||||
|
// Add an example and save again
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__nisps.iml.addExample([0.3, 0.7], new Array(126).fill(0.5));
|
||||||
|
window.__nisps.saveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await page.evaluate(([key]) => localStorage.getItem(key), [STORAGE_KEY]);
|
||||||
|
// State changed — the feature array should now include our example
|
||||||
|
const parsed = JSON.parse(after);
|
||||||
|
expect(parsed.features.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
176
tests/e2e/ui-interactions.spec.js
Normal file
176
tests/e2e/ui-interactions.spec.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* UI state machine tests — drawers, mode switching, preset chips, keyboard shortcuts.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loadApp, statusText } = require('./helpers');
|
||||||
|
|
||||||
|
test.describe('UI interactions', () => {
|
||||||
|
test.describe('Dock drawers', () => {
|
||||||
|
test('Train dock button opens training drawer', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await expect(page.locator('#drawer-training')).toHaveClass(/hidden/);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await expect(page.locator('#drawer-training')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Drawer close button hides the drawer', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await expect(page.locator('#drawer-training')).not.toHaveClass(/hidden/);
|
||||||
|
await page.click('#drawer-training .drawer-close');
|
||||||
|
await expect(page.locator('#drawer-training')).toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mode dock button opens mode drawer', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
await expect(page.locator('#drawer-mode')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Engine dock button opens engine drawer', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="params"]');
|
||||||
|
await expect(page.locator('#drawer-params')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple drawers can be open simultaneously', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
// The app doesn't auto-close drawers — both can be open
|
||||||
|
await expect(page.locator('#drawer-training')).not.toHaveClass(/hidden/);
|
||||||
|
await expect(page.locator('#drawer-mode')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Output mode switching', () => {
|
||||||
|
test('default mode is visual — synth quick controls hidden', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await expect(page.locator('#synth-quick-controls')).toHaveClass(/hidden/);
|
||||||
|
await expect(page.locator('#midi-cc-quick-controls')).toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching to Synth shows synth quick controls', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
await page.click('[data-mode="synth"]');
|
||||||
|
await expect(page.locator('#synth-quick-controls')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching to MIDI CC shows midi-cc quick controls', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
await page.click('[data-mode="midi-cc"]');
|
||||||
|
await expect(page.locator('#midi-cc-quick-controls')).not.toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching back to Visual hides synth controls', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
await page.click('[data-mode="synth"]');
|
||||||
|
await page.click('[data-mode="visual"]');
|
||||||
|
await expect(page.locator('#synth-quick-controls')).toHaveClass(/hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heatmap shows 20 bars in visual mode', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const count = await page.locator('.heatmap-cell').count();
|
||||||
|
expect(count).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heatmap shows 126 bars in synth mode', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="mode"]');
|
||||||
|
await page.click('[data-mode="synth"]');
|
||||||
|
const count = await page.locator('.heatmap-cell').count();
|
||||||
|
expect(count).toBe(126);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Status line', () => {
|
||||||
|
test('shows 0 examples · untrained on fresh load', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const text = await statusText(page);
|
||||||
|
expect(text).toContain('0 examples');
|
||||||
|
expect(text).toContain('untrained');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Training controls', () => {
|
||||||
|
test('Randomize button changes heatmap bar widths', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
|
||||||
|
const widthsBefore = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('.heatmap-cell-bar')).map(el => el.style.width)
|
||||||
|
);
|
||||||
|
await page.click('#btn-randomize');
|
||||||
|
const widthsAfter = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('.heatmap-cell-bar')).map(el => el.style.width)
|
||||||
|
);
|
||||||
|
|
||||||
|
const anyChanged = widthsBefore.some((w, i) => w !== widthsAfter[i]);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset chip loads examples and trains', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await page.click('[data-preset="calm-to-chaotic"]');
|
||||||
|
|
||||||
|
// calm-to-chaotic has 3 examples; training is async
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('status-text').textContent.includes('loss'),
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
const text = await statusText(page);
|
||||||
|
expect(text).toContain('3 examples');
|
||||||
|
expect(text).toContain('loss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear Ex resets example count to 0', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
await page.click('[data-drawer="training"]');
|
||||||
|
await page.click('[data-preset="calm-to-chaotic"]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.getElementById('status-text').textContent.includes('loss'),
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.click('#btn-clear-examples');
|
||||||
|
const text = await statusText(page);
|
||||||
|
expect(text).toContain('0 examples');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Keyboard shortcuts', () => {
|
||||||
|
test('key 2 triggers thumbs-up (adds example)', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const before = await page.evaluate(() => window.__nisps.getExampleCount());
|
||||||
|
await page.keyboard.press('2');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const after = await page.evaluate(() => window.__nisps.getExampleCount());
|
||||||
|
expect(after).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('key 1 triggers thumbs-down (changes outputs)', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const before = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
await page.keyboard.press('1');
|
||||||
|
const after = await page.evaluate(() => window.__nisps.getOutputs());
|
||||||
|
const anyChanged = before.some((v, i) => Math.abs(v - after[i]) > 0.0001);
|
||||||
|
expect(anyChanged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('key Z triggers undo after thumbs-down', async ({ page }) => {
|
||||||
|
await loadApp(page);
|
||||||
|
const before = await page.evaluate(() => window.__nisps.getWeights());
|
||||||
|
await page.keyboard.press('1'); // thumbs-down
|
||||||
|
await page.keyboard.press('z'); // undo
|
||||||
|
const after = await page.evaluate(() => window.__nisps.getWeights());
|
||||||
|
// Weights should be restored (approximately)
|
||||||
|
const maxDiff = before.reduce((m, v, i) => Math.max(m, Math.abs(v - after[i])), 0);
|
||||||
|
expect(maxDiff).toBeLessThan(1e-4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue