diff --git a/.gitignore b/.gitignore index ce0f284..462374b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b34fc21 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 5e9039f..eb1332e 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -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); } diff --git a/playground/js/nisps/nisps-wasm.js b/playground/js/nisps/nisps-wasm.js index 8cdffcc..4cab492 100644 --- a/playground/js/nisps/nisps-wasm.js +++ b/playground/js/nisps/nisps-wasm.js @@ -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.'); } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..be794e9 --- /dev/null +++ b/playwright.config.js @@ -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' }]], +}); diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js new file mode 100644 index 0000000..1a1b319 --- /dev/null +++ b/tests/e2e/helpers.js @@ -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 }; diff --git a/tests/e2e/input-pipeline.spec.js b/tests/e2e/input-pipeline.spec.js new file mode 100644 index 0000000..c099a30 --- /dev/null +++ b/tests/e2e/input-pipeline.spec.js @@ -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); + } + } + }); +}); diff --git a/tests/e2e/ml-engine.spec.js b/tests/e2e/ml-engine.spec.js new file mode 100644 index 0000000..17fa358 --- /dev/null +++ b/tests/e2e/ml-engine.spec.js @@ -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'); + }); +}); diff --git a/tests/e2e/persistence.spec.js b/tests/e2e/persistence.spec.js new file mode 100644 index 0000000..571a703 --- /dev/null +++ b/tests/e2e/persistence.spec.js @@ -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); + }); +}); diff --git a/tests/e2e/ui-interactions.spec.js b/tests/e2e/ui-interactions.spec.js new file mode 100644 index 0000000..b928139 --- /dev/null +++ b/tests/e2e/ui-interactions.spec.js @@ -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); + }); + }); +});