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:
w1n5t0n 2026-04-03 16:38:04 +01:00
parent b5b90a623d
commit 44fc974425
10 changed files with 706 additions and 11 deletions

6
.gitignore vendored
View file

@ -41,3 +41,9 @@ playground/osc-bridge/node_modules/
# Dolt database files (added by bd init)
.dolt/
*.db
# Node / Playwright
node_modules/
bun.lock
playwright-report/
test-results/

13
package.json Normal file
View 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"
}
}

View file

@ -869,6 +869,54 @@ async function init() {
// Auto-save every 10 seconds
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
requestAnimationFrame(animate);
}

View file

@ -2,6 +2,8 @@
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
// Training runs in a Web Worker for non-blocking operation.
import { Dataset } from './dataset.js';
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
@ -116,8 +118,8 @@ export class WasmIML {
this.totalTrainingIterations = 0;
this.logFn = null;
// Dataset (JS-side for persistence/visualization access)
this.dataset = { features: [], labels: [], maxExamples: 100 };
// Dataset (JS-side for persistence/visualization access and sample weighting)
this.dataset = new Dataset(100);
// Persistent WASM buffers for inference (avoid alloc/free per frame)
const inputDim = nInputs + BIAS;
@ -200,18 +202,11 @@ export class WasmIML {
while (inVec.length < this.nInputs) inVec.push(0);
const outVec = outputs.slice(0, this.nOutputs);
while (outVec.length < this.nOutputs) outVec.push(0);
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]);
this.dataset.add(inVec, outVec);
}
clearDataset() {
this.dataset.features = [];
this.dataset.labels = [];
this.dataset.clear();
this.log('Dataset cleared.');
}

27
playwright.config.js Normal file
View 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
View 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 };

View 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
View 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');
});
});

View 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);
});
});

View 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);
});
});
});