memlnaut-nisps/tests/e2e/ui-interactions.spec.js
w1n5t0n 44fc974425 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
2026-04-03 16:38:04 +01:00

176 lines
6.9 KiB
JavaScript

/**
* 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);
});
});
});