mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
Merge remote-tracking branch 'origin/main' into feat/feedpak-jsonc
Signed-off-by: topkoa <topkoa@gmail.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# Browser Tests
|
||||
|
||||
This directory contains Playwright browser tests for Slopsmith keyboard shortcuts.
|
||||
This directory contains Playwright browser tests for FeedBack keyboard shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Slopsmith web server**: Tests need the server reachable at `http://localhost:8000`.
|
||||
1. **FeedBack web server**: Tests need the server reachable at `http://localhost:8000`.
|
||||
Playwright auto-starts it via `webServer.command` in `playwright.config.ts`, so manual
|
||||
startup is optional. Start it manually if you want to debug the server, run tests
|
||||
outside Playwright, or skip the per-run boot delay:
|
||||
@@ -92,7 +92,7 @@ Increase timeout in `playwright.config.ts` if needed.
|
||||
### LIBRARY_PATH not set
|
||||
Make sure to set the LIBRARY_PATH environment variable:
|
||||
```bash
|
||||
LIBRARY_PATH=~/slopsmith-library docker compose up -d
|
||||
LIBRARY_PATH=~/feedBack-library docker compose up -d
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -7,7 +7,7 @@ test('legacy audio fader and analyser bridges stay visible in browser diagnostic
|
||||
const result = await page.evaluate(() => {
|
||||
const appWindow = window as any;
|
||||
let faderValue = 1;
|
||||
appWindow.slopsmith.audio.registerFader({
|
||||
appWindow.feedBack.audio.registerFader({
|
||||
id: 'browser-smoke',
|
||||
label: 'Browser Smoke',
|
||||
min: 0,
|
||||
@@ -17,14 +17,14 @@ test('legacy audio fader and analyser bridges stay visible in browser diagnostic
|
||||
getValue: () => faderValue,
|
||||
setValue: (value: number) => { faderValue = value; },
|
||||
});
|
||||
appWindow.slopsmith.audioSession.recordBridgeHit({
|
||||
appWindow.feedBack.audioSession.recordBridgeHit({
|
||||
domain: 'audio-mix',
|
||||
bridgeId: 'audio-mix.analyser',
|
||||
legacySurface: 'browser smoke analyser',
|
||||
participantId: 'highway_3d',
|
||||
});
|
||||
const snapshot = appWindow.slopsmith.audioSession.snapshot();
|
||||
const diagnostics = appWindow.slopsmith.capabilities.snapshotDiagnostics();
|
||||
const snapshot = appWindow.feedBack.audioSession.snapshot();
|
||||
const diagnostics = appWindow.feedBack.capabilities.snapshotDiagnostics();
|
||||
return {
|
||||
hasFader: snapshot.domains['audio-mix'].participants.some((entry: any) => entry.participantId === 'fader.browser-smoke'),
|
||||
hasAnalyserBridge: snapshot.domains['audio-mix'].bridges.some((entry: any) => entry.bridgeId === 'audio-mix.analyser'),
|
||||
|
||||
@@ -6,13 +6,13 @@ test('audio session runtime is available on page load', async ({ page }) => {
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const appWindow = window as any;
|
||||
if (!appWindow.slopsmith?.audioSession?.snapshot) {
|
||||
if (!appWindow.feedBack?.audioSession?.snapshot) {
|
||||
throw new Error('audioSession host not available');
|
||||
}
|
||||
return appWindow.slopsmith.audioSession.snapshot();
|
||||
return appWindow.feedBack.audioSession.snapshot();
|
||||
});
|
||||
|
||||
expect(snapshot.schema).toBe('slopsmith.audio_session.diagnostics.v1');
|
||||
expect(snapshot.schema).toBe('feedBack.audio_session.diagnostics.v1');
|
||||
expect(snapshot.domains['audio-mix']).toBeTruthy();
|
||||
expect(snapshot.domains['audio-input']).toBeTruthy();
|
||||
expect(snapshot.domains['audio-monitoring']).toBeTruthy();
|
||||
|
||||
@@ -6,7 +6,7 @@ test('app loads', async ({ page }) => {
|
||||
|
||||
// Check if the page loaded
|
||||
const title = await page.title();
|
||||
expect(title).toBe('Slopsmith');
|
||||
expect(title).toBe('FeedBack');
|
||||
});
|
||||
|
||||
test('check if window has any shortcuts', async ({ page }) => {
|
||||
|
||||
@@ -37,11 +37,11 @@ test('audio mixer opens with audio-mix command-backed controls', async ({ page }
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('#btn-mixer', { state: 'attached', timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => window.slopsmith?.audio?.openMixer?.());
|
||||
await page.evaluate(() => window.feedBack?.audio?.openMixer?.());
|
||||
await expect(page.locator('#mixer-popover')).not.toHaveClass(/hidden/);
|
||||
|
||||
const faderState = await page.evaluate(async () => {
|
||||
const api = window.slopsmith?.capabilities;
|
||||
const api = window.feedBack?.capabilities;
|
||||
if (!api?.command) return { outcome: 'no-owner' };
|
||||
const result = await api.command('audio-mix', 'list-faders', { requester: 'browser-smoke' });
|
||||
return { outcome: result.outcome, count: result.payload?.faders?.length || 0 };
|
||||
|
||||
@@ -69,14 +69,14 @@ test('player arrangement pin saves the selected arrangement name', async ({ page
|
||||
.join('');
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
window.feedBack.currentSong = {
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
};
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
|
||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||
});
|
||||
|
||||
const pin = page.locator('#arr-default-pin');
|
||||
@@ -137,14 +137,14 @@ test('player arrangement pin preserves non-built-in arrangement names', async ({
|
||||
.join('');
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
window.feedBack.currentSong = {
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
};
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
|
||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||
});
|
||||
|
||||
await page.locator('#arr-default-pin').click();
|
||||
@@ -191,14 +191,14 @@ test('failed settings save does not mark arrangement default as persisted', asyn
|
||||
.join('');
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
window.feedBack.currentSong = {
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
};
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.emit('song:loaded', window.slopsmith.currentSong);
|
||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||
});
|
||||
|
||||
const pin = page.locator('#arr-default-pin');
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Opt-in "Ask before leaving a song" confirm. Default OFF → Escape/✕ leave
|
||||
// instantly. When ON, a true-modal confirm appears and PAUSES the song; Escape
|
||||
// (like every other modal) DISMISSES it → Stay, so a second Escape returns to
|
||||
// the song rather than leaving, and Space/Enter activate the default-focused
|
||||
// "Leave". (The mock song has no backing audio, so the pause-on-open /
|
||||
// resume-on-Stay is verified manually on web + desktop; these specs lock the
|
||||
// navigation + keyboard semantics.)
|
||||
|
||||
const CONFIRM_KEY = 'confirmExitSong';
|
||||
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
|
||||
}, 0);
|
||||
}
|
||||
send() {}
|
||||
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
|
||||
}
|
||||
// @ts-ignore
|
||||
window.WebSocket = MockWebSocket;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.playSong('mock-song.sloppak'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Exit-confirm toggle', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so Escape reaches the player, not the overlay.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), CONFIRM_KEY);
|
||||
});
|
||||
|
||||
test('default OFF: Escape exits the song immediately, no confirm', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Escape opens the confirm and the song stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
// "Leave" is focused so Space/Enter leaves immediately.
|
||||
await expect(page.locator('#fb-exit-confirm button', { hasText: 'Leave' })).toBeFocused();
|
||||
});
|
||||
|
||||
test('ON: a second Escape dismisses the prompt and stays in the song', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// Escape = dismiss (Stay), matching every other modal — NOT leave.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: clicking the backdrop dismisses the prompt and stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// mousedown on the overlay backdrop (top-left, away from the centered card)
|
||||
// is Stay — never an accidental leave.
|
||||
await page.locator('#fb-exit-confirm').click({ position: { x: 5, y: 5 } });
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: "Stay" keeps you in the song; "Leave" exits', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Stay' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Leave' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Enter on the default-focused "Leave" leaves', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,7 @@ test('left-handed setting reaches the 3D Highway renderer with a mocked song str
|
||||
await expect(page.locator('#viz-picker')).toHaveValue('highway_3d');
|
||||
await page.evaluate(() => {
|
||||
(window as any).__h3dReadySeen = false;
|
||||
(window as any).slopsmith.on('viz:renderer:ready', () => {
|
||||
(window as any).feedBack.on('viz:renderer:ready', () => {
|
||||
(window as any).__h3dReadySeen = true;
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
|
||||
|
||||
test.describe('Keyboard Shortcuts', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
|
||||
// intercepts pointer/keyboard events — so the app behaves like a returning
|
||||
// user, which is the state these tests assume.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
});
|
||||
@@ -90,7 +98,6 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
const required = [
|
||||
{ key: '?', scope: 'global' },
|
||||
{ key: '/', scope: 'library' },
|
||||
{ key: 'c', scope: 'library' },
|
||||
{ key: 'f', scope: 'library' },
|
||||
{ key: 'e', scope: 'library' },
|
||||
{ key: 'Space', scope: 'player' },
|
||||
@@ -125,9 +132,7 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
// Library shortcuts should be visible on library screen
|
||||
await expect(modal).toContainText('Focus search');
|
||||
await expect(modal).toContainText('/');
|
||||
await expect(modal).toContainText('Convert library entry');
|
||||
await expect(modal).toContainText('c');
|
||||
|
||||
|
||||
// Player shortcuts should NOT be visible on library screen
|
||||
await expect(modal).not.toContainText('Play/Pause');
|
||||
});
|
||||
@@ -149,7 +154,6 @@ test.describe('Keyboard Shortcuts', () => {
|
||||
const expectedShortcuts = [
|
||||
{ key: '?', scope: 'global' },
|
||||
{ key: '/', scope: 'library' },
|
||||
{ key: 'c', scope: 'library' },
|
||||
{ key: 'f', scope: 'library' },
|
||||
{ key: 'e', scope: 'library' },
|
||||
{ key: 'Space', scope: 'player' },
|
||||
@@ -651,6 +655,314 @@ test('should support condition callbacks', async ({ page }) => {
|
||||
expect(result.calledByKey).toBe(true);
|
||||
});
|
||||
|
||||
test('Space toggles play/pause when a player rail button is focused (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// Inject a focusable <button> into the player (the bug: BUTTON elements
|
||||
// are "interactive controls" so Space was blocked before reaching the
|
||||
// shortcut dispatcher) and spy on the player-scope Space shortcut so the
|
||||
// assertion does not depend on the real audio path. The dispatcher calls
|
||||
// preventDefault() before the handler, so the focused button must NOT
|
||||
// also activate.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.__railBtnClicked = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Mixer';
|
||||
// @ts-ignore
|
||||
btn.addEventListener('click', () => { window.__railBtnClicked++; });
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
// @ts-ignore
|
||||
clicked: window.__railBtnClicked,
|
||||
}));
|
||||
// Play/pause fired despite the button holding focus…
|
||||
expect(result.played).toBe(1);
|
||||
// …and the focused button did not also activate (dispatcher preventDefault()).
|
||||
expect(result.clicked).toBe(0);
|
||||
});
|
||||
|
||||
test('Space in a player-screen text input still types a space, not play/pause (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the player
|
||||
// Space carve-out, so typing space in an input must never toggle playback.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
value: (document.getElementById('__test-player-input') as HTMLInputElement).value,
|
||||
}));
|
||||
expect(result.played).toBe(0);
|
||||
expect(result.value).toBe(' ');
|
||||
});
|
||||
|
||||
test('Space inside a modal dialog over the player reaches the modal, not play/pause (#593)', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal dialog (role="dialog" aria-modal="true" / .feedBack-modal)
|
||||
// layered over the player must trap interaction: Space activates the
|
||||
// modal's focused control (native), it does NOT toggle playback behind it.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__spacePlayCount = 0;
|
||||
// @ts-ignore
|
||||
window.__modalBtnClicked = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Space',
|
||||
description: 'Play/Pause (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__spacePlayCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
// @ts-ignore
|
||||
btn.addEventListener('click', () => { window.__modalBtnClicked++; });
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const result = await page.evaluate(() => ({
|
||||
// @ts-ignore
|
||||
played: window.__spacePlayCount,
|
||||
// @ts-ignore
|
||||
clicked: window.__modalBtnClicked,
|
||||
}));
|
||||
// Playback is NOT toggled behind the modal…
|
||||
expect(result.played).toBe(0);
|
||||
// …and Space activated the modal's focused button natively.
|
||||
expect(result.clicked).toBe(1);
|
||||
});
|
||||
|
||||
// ── Escape = universal "Back" carve-out ──────────────────────────────────
|
||||
// Escape must escape a focused non-modal control exactly like Space does,
|
||||
// so a focused transport/rail button can't swallow it ("Escape in song not
|
||||
// consistent"). These mirror the #593 Space tests above. Each registers an
|
||||
// Escape spy in the relevant scope (which replaces the built-in handler for
|
||||
// that composite key) so the assertion doesn't depend on showScreen teardown.
|
||||
|
||||
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The bug: a focused <button> is an "interactive control", so Escape was
|
||||
// blocked before reaching the dispatcher and the song wouldn't exit until
|
||||
// the user clicked empty canvas to blur the control.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Restart';
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Back-to-library fired despite the control button holding focus.
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the Escape
|
||||
// carve-out, so Escape in a field is the field's own concern (clear/blur),
|
||||
// never a song exit.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
|
||||
// over the player is a focus trap: Escape there must NOT eject past it to
|
||||
// exit the song — the modal owns Escape. The carve-out's modal-overlay
|
||||
// guard keeps the player-back shortcut from firing.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Playback is NOT exited behind the modal.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The Section Practice popover claims Escape earlier in
|
||||
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
|
||||
// open popover suppresses the player-scope back-to-library Escape — the
|
||||
// popover's own handler owns closing it. This locks that ordering guard.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
let bar = document.getElementById('section-practice-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'section-practice-bar';
|
||||
document.getElementById('player')!.appendChild(bar);
|
||||
}
|
||||
bar.classList.add('section-practice-bar--open');
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Player-back did NOT fire while the popover was open.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
|
||||
// The same focus bug existed on the settings screen (the carve-out was
|
||||
// player-only). The fix covers settings too: Escape returns to the
|
||||
// previous screen even when a settings control holds focus.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escSettingsBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Go back from settings (test spy)',
|
||||
scope: 'settings',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escSettingsBackCount++; },
|
||||
});
|
||||
// @ts-ignore
|
||||
window.showScreen('settings');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-settings-btn';
|
||||
btn.textContent = 'Some setting';
|
||||
document.getElementById('settings')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.waitForSelector('#settings.active', { timeout: 5000 });
|
||||
await page.locator('#__test-settings-btn').focus();
|
||||
await expect(page.locator('#__test-settings-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should warn on invalid scope', async ({ page }) => {
|
||||
const messages: string[] = [];
|
||||
page.on('console', msg => {
|
||||
|
||||
@@ -39,17 +39,17 @@ test('progression capability domain is owned by core', async ({ page }) => {
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const appWindow = window as any;
|
||||
const inspectCmd = await appWindow.slopsmith.capabilities.command('progression', 'inspect', {
|
||||
const inspectCmd = await appWindow.feedBack.capabilities.command('progression', 'inspect', {
|
||||
requester: 'browser-smoke',
|
||||
});
|
||||
const pipeline = appWindow.slopsmith.capabilities.inspect('progression');
|
||||
const pipeline = appWindow.feedBack.capabilities.inspect('progression');
|
||||
const owner = (pipeline.participants || []).find((p: any) => p.pluginId === 'core.progression');
|
||||
return {
|
||||
outcome: inspectCmd.outcome,
|
||||
masteryRank: inspectCmd.payload ? inspectCmd.payload.mastery_rank : null,
|
||||
ownerRoles: owner ? owner.roles : [],
|
||||
// buy-item without user-action authorization must be denied.
|
||||
deniedBuy: (await appWindow.slopsmith.capabilities.command('progression', 'buy-item', {
|
||||
deniedBuy: (await appWindow.feedBack.capabilities.command('progression', 'buy-item', {
|
||||
requester: 'browser-smoke',
|
||||
payload: { item_id: 'theme.sunset-strat' },
|
||||
})).outcome,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Resume-last-session: leaving the player snapshots {song, arrangement,
|
||||
// position, speed} so an exit is recoverable via a non-blocking "Resume" pill.
|
||||
// These exercise the deterministic plumbing (snapshot guards, staleness, the
|
||||
// pill, and resume consumption) without depending on real audio timing.
|
||||
|
||||
const RESUME_KEY = 'feedBack.resumeSession';
|
||||
|
||||
// Make playSong()'s WebSocket a no-network mock that emits a song_info + ready.
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) if (this.onmessage) this.onmessage({ data: JSON.stringify(m) });
|
||||
}, 0);
|
||||
}
|
||||
send() {}
|
||||
close() { this.readyState = MockWebSocket.CLOSED; if (this.onclose) this.onclose(new CloseEvent('close')); }
|
||||
}
|
||||
// @ts-ignore
|
||||
window.WebSocket = MockWebSocket;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
await window.playSong('mock-song.sloppak');
|
||||
});
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Resume last session', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so the player isn't covered.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), RESUME_KEY);
|
||||
});
|
||||
|
||||
test('snapshots song + arrangement + position once you are mid-song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const snap = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(30);
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
});
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap.f).toBe('mock-song.sloppak');
|
||||
expect(snap.a).toBe(0);
|
||||
expect(Math.round(snap.t)).toBe(30);
|
||||
expect(snap.title).toBe('Mock Song');
|
||||
});
|
||||
|
||||
test('does NOT snapshot a barely-started or basically-finished song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const result = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(1); // < 3s min → ignored
|
||||
// @ts-ignore
|
||||
const tooEarly = window._readResumeSession();
|
||||
// duration is 90; end-guard is 5s, so 88 > 85 → ignored
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(88);
|
||||
// @ts-ignore
|
||||
const tooLate = window._readResumeSession();
|
||||
return { tooEarly, tooLate };
|
||||
});
|
||||
expect(result.tooEarly).toBeNull();
|
||||
expect(result.tooLate).toBeNull();
|
||||
});
|
||||
|
||||
test('a stale (>24h) snapshot is ignored', async ({ page }) => {
|
||||
const got = await page.evaluate((k) => {
|
||||
const old = { f: 'old.sloppak', a: 0, t: 42, sp: 1, title: 'Old', ts: Date.now() - 25 * 60 * 60 * 1000 };
|
||||
localStorage.setItem(k, JSON.stringify(old));
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
}, RESUME_KEY);
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
test('the Resume pill appears off-player and hides on the player', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', artist: 'Mock Artist', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await expect(page.locator('#fb-resume-pill')).toContainText('Mock Song');
|
||||
|
||||
// Entering the player hides it (screen:changed → _hideResumePill()).
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.showScreen('player'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing the pill removes it and does not re-show it this session', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await page.locator('#fb-resume-pill button[aria-label="Dismiss"]').click();
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
|
||||
// A re-offer attempt within the same session is suppressed.
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.feedBack._maybeShowResumePill(); });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('resumeLastSession() re-enters the song and consumes the snapshot', async ({ page }) => {
|
||||
await installMockSong(page);
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
}, RESUME_KEY);
|
||||
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.resumeLastSession(); });
|
||||
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
// The snapshot is consumed (cleared) so it isn't offered again.
|
||||
const remaining = await page.evaluate((k) => localStorage.getItem(k), RESUME_KEY);
|
||||
expect(remaining).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Verifies the v3 tabbed settings page (feat/v3-settings-tabbed): the tab bar
|
||||
// renders, tabs switch panels, the active tab persists, existing controls
|
||||
// still hydrate from /api/settings, the new countdown toggle persists, and the
|
||||
// per-category reset hits /api/settings/reset.
|
||||
|
||||
interface SettingsPayload {
|
||||
dlc_dir: string;
|
||||
default_arrangement: string;
|
||||
demucs_server_url: string;
|
||||
master_difficulty: number;
|
||||
av_offset_ms: number;
|
||||
countdown_before_song: boolean;
|
||||
miss_penalty: string;
|
||||
fail_behavior: string;
|
||||
}
|
||||
|
||||
const basePayload: SettingsPayload = {
|
||||
dlc_dir: '',
|
||||
default_arrangement: 'Rhythm',
|
||||
demucs_server_url: '',
|
||||
master_difficulty: 70,
|
||||
av_offset_ms: 0,
|
||||
countdown_before_song: false,
|
||||
miss_penalty: 'none',
|
||||
fail_behavior: 'continue',
|
||||
};
|
||||
|
||||
// A fresh profile shows the blocking onboarding overlay; onboard via the API
|
||||
// so the tab clicks below aren't intercepted (idempotent once onboarded).
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/api/profile', { data: { display_name: 'Settings Tester' } });
|
||||
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
|
||||
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
|
||||
});
|
||||
|
||||
// Open the v3 settings screen with the first-run onboarding overlay neutralised
|
||||
// (the API skip in beforeEach handles the common path; this also hides the
|
||||
// overlay element so a slow async profile render can't intercept tab clicks).
|
||||
async function openSettings(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
|
||||
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
|
||||
await page.evaluate(() => (window as any).showScreen('settings'));
|
||||
}
|
||||
|
||||
async function mockSettings(page, posts: any[], resets: any[]) {
|
||||
await page.route('**/api/settings', async route => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: basePayload });
|
||||
return;
|
||||
}
|
||||
posts.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: { message: 'Settings saved' } });
|
||||
});
|
||||
await page.route('**/api/settings/reset', async route => {
|
||||
resets.push(route.request().postDataJSON());
|
||||
await route.fulfill({ json: { message: 'Settings reset', reset: [] } });
|
||||
});
|
||||
}
|
||||
|
||||
test('tab bar renders the settings tabs and Gameplay is default', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
const tabs = await page.locator('#settings-tabbar .fb-tab').allTextContents();
|
||||
expect(tabs).toEqual(['Gameplay', 'Audio', 'Graphics', 'Keybinds', 'Progression', 'Mic', 'Plugins', 'System']);
|
||||
|
||||
// Gameplay panel is active by default and its controls are present.
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).toHaveClass(/active/);
|
||||
await expect(page.locator('#setting-lefty')).toBeAttached();
|
||||
await expect(page.locator('#setting-countdown-before-song')).toBeAttached();
|
||||
});
|
||||
|
||||
test('clicking a tab switches the visible panel', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="audio"]').click();
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="audio"]')).toHaveClass(/active/);
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="gameplay"]')).not.toHaveClass(/active/);
|
||||
await expect(page.locator('#setting-live-guitar-tone-source')).toBeVisible();
|
||||
});
|
||||
|
||||
test('active tab persists across reload', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="system"]').click();
|
||||
await expect(page.locator('.fb-tabpanel[data-tab="system"]')).toHaveClass(/active/);
|
||||
|
||||
await page.reload();
|
||||
await page.waitForSelector('#settings-tabbar', { state: 'attached' });
|
||||
// Restored from localStorage even before navigating back to settings.
|
||||
await expect(page.locator('#settings-tabbar .fb-tab[data-tab="system"]')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('existing controls hydrate from /api/settings', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.locator('#default-arrangement')).toHaveValue('Rhythm');
|
||||
// Note highway speed shares master_difficulty (70 in the mock).
|
||||
await expect(page.locator('#setting-highway-speed')).toHaveValue('70');
|
||||
await expect(page.locator('#setting-highway-speed-val')).toHaveText('70'); // span holds number; '%' is literal in markup
|
||||
});
|
||||
|
||||
test('countdown toggle persists countdown_before_song', async ({ page }) => {
|
||||
const posts: any[] = [];
|
||||
await mockSettings(page, posts, []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('label.fb-switch:has(#setting-countdown-before-song) .fb-switch-track').click();
|
||||
await expect.poll(() => posts.some(p => p && p.countdown_before_song === true)).toBe(true);
|
||||
});
|
||||
|
||||
test('reset gameplay posts to /api/settings/reset', async ({ page }) => {
|
||||
const resets: any[] = [];
|
||||
await mockSettings(page, [], resets);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('[data-reset="gameplay"]').click();
|
||||
// _confirmDialog modal — confirm it.
|
||||
await page.locator('.slopsmith-modal [data-confirm]').click();
|
||||
|
||||
await expect.poll(() => resets.length).toBeGreaterThan(0);
|
||||
expect(resets[0].keys).toContain('countdown_before_song');
|
||||
expect(resets[0].keys).toContain('master_difficulty');
|
||||
});
|
||||
|
||||
test('keybinds tab renders the shortcut reference', async ({ page }) => {
|
||||
await mockSettings(page, [], []);
|
||||
await openSettings(page);
|
||||
|
||||
await page.locator('#settings-tabbar .fb-tab[data-tab="keybinds"]').click();
|
||||
// Either real shortcuts (kbd chips) or the empty-state note — never blank.
|
||||
await expect(page.locator('#settings-keybinds')).not.toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
|
||||
// stage 2). Before virtualization the grid appended every scrolled page, so for
|
||||
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
|
||||
// Now only the visible window (± overscan) is ever in the DOM while a sizer
|
||||
// element gives the scrollbar the full-library geometry.
|
||||
//
|
||||
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
|
||||
// deterministic in CI without a seeded 2000-row library: /api/library serves a
|
||||
// synthetic page from the page/after param with total 2001, and the keyset
|
||||
// cursor is mocked as the next absolute offset.
|
||||
|
||||
const TOTAL = 2001;
|
||||
const PAGE_SIZE = 24;
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z
|
||||
// rail has real buckets and a jump has somewhere to land.
|
||||
function songAt(i: number) {
|
||||
const letter = COLS[i % 26];
|
||||
return {
|
||||
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
|
||||
title: `Song ${String(i).padStart(4, '0')}`,
|
||||
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
|
||||
album: `${letter} Album`,
|
||||
format: 'sloppak',
|
||||
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
|
||||
};
|
||||
}
|
||||
|
||||
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
|
||||
function sortLetters() {
|
||||
const m: Record<string, number> = {};
|
||||
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
|
||||
return m;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/library?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const after = url.searchParams.get('after');
|
||||
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
|
||||
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
|
||||
const songs = [];
|
||||
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
|
||||
const nextOffset = offset + size;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
songs, total: TOTAL, page: Math.floor(offset / size), size,
|
||||
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/library/stats**', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
|
||||
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
|
||||
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
|
||||
});
|
||||
|
||||
async function openSongs(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — neutralize playback so a stray click can't navigate away.
|
||||
window.playSong = () => Promise.resolve();
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
|
||||
}
|
||||
|
||||
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
|
||||
// The count reflects the FULL library even though only a window is rendered.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
|
||||
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
|
||||
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
|
||||
expect(scrollHeight).toBeGreaterThan(20000);
|
||||
|
||||
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
|
||||
const CAP = 150;
|
||||
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
for (let s = 0; s < 50; s++) {
|
||||
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
|
||||
await page.waitForTimeout(60);
|
||||
const n = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
maxNodes = Math.max(maxNodes, n);
|
||||
expect(n).toBeLessThanOrEqual(CAP);
|
||||
}
|
||||
// Sanity: we actually rendered a window (not zero), and stayed well under the
|
||||
// unbounded 2001 the old append-everything grid would have produced.
|
||||
expect(maxNodes).toBeGreaterThan(0);
|
||||
expect(maxNodes).toBeLessThanOrEqual(CAP);
|
||||
|
||||
// The count is still correct after scrolling to the end.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
});
|
||||
|
||||
test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
|
||||
|
||||
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
|
||||
await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.v3-azrail-letter')]
|
||||
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
|
||||
if (!b) throw new Error('no M rail letter'); b.click();
|
||||
});
|
||||
|
||||
// After the jump+window render, an 'M' card is present near the top of the
|
||||
// viewport (the jump is O(1) via sort_letters, not a full page-through).
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const main = document.getElementById('v3-main')!;
|
||||
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
|
||||
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
|
||||
});
|
||||
}), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Regression coverage for the v3 Section Map "leftmost section unclickable" bug.
|
||||
//
|
||||
// The Section Map plugin pins a ~20px clickable bar (#section-map, z-index:5)
|
||||
// to the very top of #player. The v3 chrome has a full-height invisible rail
|
||||
// "catcher" (.v3-railzone::before, z-index:30, width:96px, pinned left/top:0)
|
||||
// that reveals the hover rail. Because the catcher sat at top:0 and outranks
|
||||
// the bar, its top-left corner swallowed every click on the section map's first
|
||||
// section. Fix (static/v3/v3.css): `#section-map ~ #v3-railzone::before { top: 20px }`
|
||||
// drops the catcher below the bar when the section map is present.
|
||||
//
|
||||
// We reproduce the plugin's bar exactly (first child of #player, the rendered
|
||||
// position:relative / z-index:5 / 20px-tall state) and hit-test the top-left
|
||||
// corner with elementFromPoint — that is precisely what a real click resolves
|
||||
// against. A negative control re-raises the catcher to prove the test catches
|
||||
// the bug.
|
||||
|
||||
// A fresh profile shows the blocking onboarding overlay; onboard via the API so
|
||||
// it isn't (re)created over the player. Idempotent once onboarded.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/api/profile', { data: { display_name: 'Section Map Tester' } });
|
||||
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
|
||||
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
|
||||
});
|
||||
|
||||
async function openPlayerWithSectionMap(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
// The bug affects an already-onboarded user mid-song. The API skip above
|
||||
// handles the common path; this persistent hide also covers a slow async
|
||||
// profile render that could otherwise re-create the full-screen overlay and
|
||||
// intercept the top-left hit-test (mirrors settings-tabbed.spec.ts).
|
||||
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — show the player screen (static #v3-railzone markup lives here).
|
||||
window.showScreen('player');
|
||||
const player = document.getElementById('player');
|
||||
if (!player) throw new Error('#player missing');
|
||||
|
||||
// Reproduce the section_map plugin's rendered bar: first child of #player,
|
||||
// 20px tall, full width, z-index:5, position:relative (its post-_smRender
|
||||
// state), with a left-edge "first section" block at left:0.
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'section-map';
|
||||
bar.style.cssText =
|
||||
'position:relative;top:0;left:0;right:0;z-index:5;height:20px;background:rgba(8,8,16,0.7);cursor:pointer;';
|
||||
const block = document.createElement('div');
|
||||
block.id = 'sm-first-block';
|
||||
block.style.cssText =
|
||||
'position:absolute;left:0;width:30%;top:0;bottom:0;background:#3b82f6;';
|
||||
bar.appendChild(block);
|
||||
player.insertBefore(bar, player.firstChild);
|
||||
});
|
||||
await page.waitForSelector('#section-map', { state: 'attached', timeout: 5000 });
|
||||
await page.waitForSelector('#v3-railzone', { state: 'attached', timeout: 5000 });
|
||||
}
|
||||
|
||||
// What element does a click at the top-left strip land on? (x within the 96px
|
||||
// catcher, y within the 20px bar.)
|
||||
function hitTopLeft(page, x = 10, y = 8) {
|
||||
return page.evaluate(({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
return el ? { id: el.id, cls: el.className, tag: el.tagName } : null;
|
||||
}, { x, y });
|
||||
}
|
||||
|
||||
test('top-left of the section map receives clicks, not the rail catcher (fix present)', async ({ page }) => {
|
||||
await openPlayerWithSectionMap(page);
|
||||
|
||||
const hit = await hitTopLeft(page);
|
||||
// Click must resolve to the section map (the bar or its first-section block),
|
||||
// never the rail hover-zone.
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.id).not.toBe('v3-railzone');
|
||||
expect(['section-map', 'sm-first-block']).toContain(hit!.id);
|
||||
});
|
||||
|
||||
test('negative control: re-raising the catcher to top:0 reproduces the bug', async ({ page }) => {
|
||||
await openPlayerWithSectionMap(page);
|
||||
|
||||
// Undo the fix at runtime (highest-specificity inline-ish override) so the
|
||||
// catcher again covers the bar's top-left — this is the pre-fix layout.
|
||||
await page.evaluate(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '#section-map ~ #v3-railzone::before { top: 0 !important; }';
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
|
||||
const hit = await hitTopLeft(page);
|
||||
// Without the fix, the rail catcher swallows the click.
|
||||
expect(hit!.id).toBe('v3-railzone');
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Regression coverage for the list/tree view select-mode fix (PR #585, which
|
||||
// re-lands a change that was reverted). The core bug: entering select mode
|
||||
// re-renders the tree (setSelectMode -> reload -> loadTree), and the rebuild
|
||||
// wiped every expanded <details>, collapsing the tree and making selection
|
||||
// unusable. The fix captures the open artist groups before the wipe and
|
||||
// restores them. We also cover: clicking a row in select mode selects instead
|
||||
// of playing.
|
||||
//
|
||||
// Navigation uses programmatic element.click() rather than Playwright's
|
||||
// actionability-gated click: this screen briefly re-renders its toolbar and
|
||||
// the harness can show transient overlays, but element.click() still
|
||||
// dispatches a real bubbling event through the capture-phase select handler.
|
||||
|
||||
const ARTISTS = {
|
||||
artists: [
|
||||
{
|
||||
name: 'Alpha Band',
|
||||
song_count: 2,
|
||||
albums: [{ name: 'First Album', songs: [
|
||||
{ filename: 'alpha/one.sloppak', title: 'Alpha One', artist: 'Alpha Band', album: 'First Album' },
|
||||
{ filename: 'alpha/two.sloppak', title: 'Alpha Two', artist: 'Alpha Band', album: 'First Album' },
|
||||
] }],
|
||||
},
|
||||
{
|
||||
name: 'Beta Crew',
|
||||
song_count: 1,
|
||||
albums: [{ name: 'Beta LP', songs: [
|
||||
{ filename: 'beta/solo.sloppak', title: 'Beta Solo', artist: 'Beta Crew', album: 'Beta LP' },
|
||||
] }],
|
||||
},
|
||||
],
|
||||
total_artists: 2,
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Paged artists endpoint (used by both the tree and the artist catalog):
|
||||
// page 0 returns data, later pages return empty so the paging loop ends.
|
||||
await page.route('**/api/library/artists**', async route => {
|
||||
const pageNum = Number(new URL(route.request().url()).searchParams.get('page') || '0');
|
||||
await route.fulfill({ json: pageNum === 0 ? ARTISTS : { artists: [], total_artists: 2 } });
|
||||
});
|
||||
await page.route('**/api/library/providers', route => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', route => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', route => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/library?**', route => route.fulfill({ json: { songs: [], total: 0, page: 0, size: 60 } }));
|
||||
});
|
||||
|
||||
// Programmatic click — fires a real bubbling click through capture-phase
|
||||
// handlers without Playwright's actionability gate.
|
||||
async function clickSel(page, selector: string) {
|
||||
await page.evaluate((s) => {
|
||||
const el = document.querySelector(s) as HTMLElement | null;
|
||||
if (!el) throw new Error('not found: ' + s);
|
||||
el.click();
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function openTree(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — record playback so an accidental row-click is detectable.
|
||||
window.__played = 0;
|
||||
// @ts-ignore
|
||||
window.playSong = () => { window.__played++; return Promise.resolve(); };
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-tree-btn', { state: 'attached', timeout: 8000 });
|
||||
await clickSel(page, '#v3-songs-tree-btn');
|
||||
await page.waitForSelector('#v3-songs-tree details', { state: 'attached', timeout: 8000 });
|
||||
}
|
||||
|
||||
// Returns the <details> whose <summary> names the given artist.
|
||||
function group(page, artist: string) {
|
||||
return page.locator('#v3-songs-tree details', { has: page.locator('summary', { hasText: artist }) });
|
||||
}
|
||||
|
||||
test('select mode keeps expanded artist groups open across the tree re-render (#585)', async ({ page }) => {
|
||||
await openTree(page);
|
||||
|
||||
// Expand Alpha (the precondition the bug used to destroy on re-render).
|
||||
await page.evaluate(() => {
|
||||
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||
d.open = true;
|
||||
});
|
||||
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||
|
||||
// Enter select mode → triggers the full tree re-render.
|
||||
await clickSel(page, '#v3-songs-select');
|
||||
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||
|
||||
// The bug: Alpha collapses after the rebuild. The fix restores it.
|
||||
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||
// Beta was never opened — it must stay collapsed (no false restore).
|
||||
await expect(group(page, 'Beta Crew')).not.toHaveAttribute('open', '');
|
||||
});
|
||||
|
||||
test('clicking a tree row in select mode selects it instead of playing (#585)', async ({ page }) => {
|
||||
await openTree(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||
d.open = true;
|
||||
});
|
||||
|
||||
await clickSel(page, '#v3-songs-select');
|
||||
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||
|
||||
await clickSel(page, '#v3-songs-tree [data-fn="alpha/one.sloppak"]');
|
||||
|
||||
await expect(page.locator('#v3-songs-tree [data-fn="alpha/one.sloppak"] input[data-select]')).toBeChecked();
|
||||
expect(await page.evaluate(() => (window as any).__played)).toBe(0);
|
||||
});
|
||||
@@ -51,7 +51,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(installWakeLockSpy);
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
|
||||
await page.waitForFunction(() => typeof (window as any).feedBack?.emit === 'function');
|
||||
});
|
||||
|
||||
test('acquires a single screen wake lock on play and releases on pause', async ({ page }) => {
|
||||
@@ -59,26 +59,26 @@ test('acquires a single screen wake lock on play and releases on pause', async (
|
||||
// only one 'screen' lock should be requested (the in-flight guard must hold
|
||||
// before the first request resolves).
|
||||
await page.evaluate(() => {
|
||||
(window as any).slopsmith.emit('song:play');
|
||||
(window as any).slopsmith.emit('song:resume');
|
||||
(window as any).feedBack.emit('song:play');
|
||||
(window as any).feedBack.emit('song:resume');
|
||||
});
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
|
||||
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(1);
|
||||
expect(await page.evaluate(() => (window as any).__wakeLockSpy.lastType)).toBe('screen');
|
||||
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:pause'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
|
||||
});
|
||||
|
||||
test('song:ended and song:stop release the wake lock', async ({ page }) => {
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:play'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:ended'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:ended'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
|
||||
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:play'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:stop'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:stop'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
|
||||
});
|
||||
|
||||
@@ -87,8 +87,8 @@ test('fast play→pause before the request resolves leaves no stale lock', async
|
||||
// Pause arrives while navigator.wakeLock.request is still in flight — the
|
||||
// resolved sentinel must release itself instead of being held stale.
|
||||
await page.evaluate(() => {
|
||||
(window as any).slopsmith.emit('song:play');
|
||||
(window as any).slopsmith.emit('song:pause');
|
||||
(window as any).feedBack.emit('song:play');
|
||||
(window as any).feedBack.emit('song:pause');
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
expect(await page.evaluate(() => (window as any).__wakeLockSpy.held)).toBe(false);
|
||||
@@ -97,53 +97,53 @@ test('fast play→pause before the request resolves leaves no stale lock', async
|
||||
});
|
||||
|
||||
test('re-acquires the wake lock when the UA releases it while still playing', async ({ page }) => {
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:play'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === true);
|
||||
const before = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
|
||||
|
||||
// Simulate the UA releasing the lock (power policy / page hide) while
|
||||
// playback continues; the release handler re-acquires when still visible.
|
||||
await page.evaluate(() => {
|
||||
(window as any).slopsmith.isPlaying = true;
|
||||
(window as any).feedBack.isPlaying = true;
|
||||
(window as any).__wakeLockSpy.lastSentinel.release();
|
||||
});
|
||||
await page.waitForFunction((n) => (window as any).__wakeLockSpy.requestCount === n + 1 && (window as any).__wakeLockSpy.held === true, before);
|
||||
|
||||
// After a real pause there must be no further re-acquire churn.
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:pause'));
|
||||
await page.waitForFunction(() => (window as any).__wakeLockSpy.held === false);
|
||||
const settled = await page.evaluate(() => (window as any).__wakeLockSpy.requestCount);
|
||||
await page.waitForTimeout(150);
|
||||
expect(await page.evaluate(() => (window as any).__wakeLockSpy.requestCount)).toBe(settled);
|
||||
});
|
||||
|
||||
test('drives the slopsmith-desktop native power bridge, deduped and visibility-gated', async ({ page }) => {
|
||||
test('drives the feedBack-desktop native power bridge, deduped and visibility-gated', async ({ page }) => {
|
||||
// In the packaged Electron app navigator.wakeLock is unreliable, so the
|
||||
// helper also drives window.slopsmithDesktop.power.setScreenAwake — to
|
||||
// helper also drives window.feedBackDesktop.power.setScreenAwake — to
|
||||
// exactly (wanted && visible), emitting only on change. Inject a spy bridge
|
||||
// before app.js runs and assert it tracks playback without duplicate starts.
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__bridgeCalls = [];
|
||||
(window as any).slopsmithDesktop = {
|
||||
(window as any).feedBackDesktop = {
|
||||
power: { setScreenAwake: (keep: boolean) => (window as any).__bridgeCalls.push(keep) },
|
||||
};
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.waitForFunction(() => typeof (window as any).slopsmith?.emit === 'function');
|
||||
await page.waitForFunction(() => typeof (window as any).feedBack?.emit === 'function');
|
||||
|
||||
// song:play + song:resume fire together but must produce a single `true`.
|
||||
await page.evaluate(() => {
|
||||
(window as any).slopsmith.emit('song:play');
|
||||
(window as any).slopsmith.emit('song:resume');
|
||||
(window as any).feedBack.emit('song:play');
|
||||
(window as any).feedBack.emit('song:resume');
|
||||
});
|
||||
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === true).length === 1);
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:pause'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:pause'));
|
||||
await page.waitForFunction(() => (window as any).__bridgeCalls.filter((x: boolean) => x === false).length === 1);
|
||||
|
||||
// Hidden while playing → bridge OFF (a minimized window mustn't keep the
|
||||
// whole display awake); restoring visibility while playing turns it back ON.
|
||||
await page.evaluate(() => (window as any).slopsmith.emit('song:play'));
|
||||
await page.evaluate(() => (window as any).feedBack.emit('song:play'));
|
||||
await page.waitForFunction(() => (window as any).__bridgeCalls[(window as any).__bridgeCalls.length - 1] === true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
"""Shared pytest fixtures for the slopsmith test suite."""
|
||||
"""Shared pytest fixtures for the feedBack test suite."""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -6,12 +6,12 @@ import pytest
|
||||
import structlog
|
||||
|
||||
|
||||
_LOGGING_NAMES = ("slopsmith", "uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
_LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolate_logging():
|
||||
"""Restore slopsmith / uvicorn logger state after each test.
|
||||
"""Restore feedBack / uvicorn logger state after each test.
|
||||
|
||||
Saves handlers, level, and propagate flag before the test runs and
|
||||
restores all three on teardown. Import into any test module that calls
|
||||
|
||||
@@ -18,7 +18,7 @@ function registerProvider(api, overrides = {}) {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'plan-1',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: overrides.providerId || 'rig-builder',
|
||||
@@ -42,18 +42,18 @@ function registerProvider(api, overrides = {}) {
|
||||
|
||||
test('audio-effects host registers active domain and contributes diagnostics', () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const pipeline = api.inspect('audio-effects');
|
||||
const diagnostics = window.slopsmith.diagnostics.snapshotContributions();
|
||||
const diagnostics = window.feedBack.diagnostics.snapshotContributions();
|
||||
|
||||
assert.equal(pipeline.review.lifecycle, 'active');
|
||||
assert.equal(pipeline.participants.some(p => p.pluginId === 'core.audio.effects' && p.roles.includes('owner')), true);
|
||||
assert.equal(diagnostics['audio-effects'].schema, 'slopsmith.audio_effects.diagnostics.v1');
|
||||
assert.equal(diagnostics['audio-effects'].schema, 'feedBack.audio_effects.diagnostics.v1');
|
||||
});
|
||||
|
||||
test('audio-effects runtime providers and executors are capability participants', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
@@ -91,7 +91,7 @@ test('audio-effects runtime providers and executors are capability participants'
|
||||
|
||||
test('unregistering a provider drops its role/operations and clearing all removes the participant', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
@@ -144,7 +144,7 @@ test('unregistering a provider drops its role/operations and clearing all remove
|
||||
|
||||
test('host runtime overlay preserves a plugin-declared audio-effects manifest entry', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// The plugin declares its own audio-effects participancy in its manifest before any runtime
|
||||
// registration goes through the host.
|
||||
@@ -181,7 +181,7 @@ test('host runtime overlay preserves a plugin-declared audio-effects manifest en
|
||||
|
||||
test('select-chain requires user action and records selected provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
|
||||
const denied = await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main' } });
|
||||
@@ -197,7 +197,7 @@ test('select-chain requires user action and records selected provider', async ()
|
||||
|
||||
test('resolve-plan calls selected provider and returns constrained plan without storing raw payload in diagnostics', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
|
||||
|
||||
@@ -206,7 +206,7 @@ test('resolve-plan calls selected provider and returns constrained plan without
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(resolved.outcome, 'handled');
|
||||
assert.equal(resolved.payload.plan.schema, 'slopsmith.audio_effects.chain_plan.v1');
|
||||
assert.equal(resolved.payload.plan.schema, 'feedBack.audio_effects.chain_plan.v1');
|
||||
assert.equal(resolved.payload.plan.stages.length, 3);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(resolved.payload.plan.segments[0].stageBypass)), { 'pre-1': true, 'amp-1': false });
|
||||
assert.equal(snapshot.routes[0].state, 'resolved');
|
||||
@@ -218,13 +218,13 @@ test('resolve-plan calls selected provider and returns constrained plan without
|
||||
|
||||
test('resolve-plan rejects raw file paths and records fallback state', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api, {
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'bad-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -247,9 +247,9 @@ test('resolve-plan rejects raw file paths and records fallback state', async ()
|
||||
|
||||
test('load-plan calls trusted executor with provider-private assets without diagnostic leakage', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let executorRequest = null;
|
||||
window.slopsmithDesktop = {
|
||||
window.feedBackDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan(request) {
|
||||
executorRequest = request;
|
||||
@@ -262,7 +262,7 @@ test('load-plan calls trusted executor with provider-private assets without diag
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'private-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -303,9 +303,9 @@ test('load-plan calls trusted executor with provider-private assets without diag
|
||||
|
||||
test('route gain and release delegate to the selected executor', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
window.slopsmithDesktop = {
|
||||
window.feedBackDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan() { calls.push(['load']); return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
|
||||
setRouteGain(request) { calls.push(['gain', request.gains]); return { outcome: 'handled', payload: { gains: request.gains } }; },
|
||||
@@ -314,9 +314,9 @@ test('route gain and release delegate to the selected executor', async () => {
|
||||
};
|
||||
await registerProvider(api);
|
||||
const loaded = await api.dispatch({ capability: 'audio-effects', command: 'load-plan', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'playback-session' } });
|
||||
const gained = await window.slopsmith.audioEffects.setRouteGain({ routeKey: 'desktop-main', authorization: 'playback-session', gains: { input: 3, chain: 2 } });
|
||||
const released = await window.slopsmith.audioEffects.releaseRoute({ routeKey: 'desktop-main', authorization: 'playback-session' });
|
||||
const inspected = await window.slopsmith.audioEffects.inspectRoute({ routeKey: 'desktop-main' });
|
||||
const gained = await window.feedBack.audioEffects.setRouteGain({ routeKey: 'desktop-main', authorization: 'playback-session', gains: { input: 3, chain: 2 } });
|
||||
const released = await window.feedBack.audioEffects.releaseRoute({ routeKey: 'desktop-main', authorization: 'playback-session' });
|
||||
const inspected = await window.feedBack.audioEffects.inspectRoute({ routeKey: 'desktop-main' });
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(gained.outcome, 'handled');
|
||||
@@ -327,8 +327,8 @@ test('route gain and release delegate to the selected executor', async () => {
|
||||
|
||||
test('provider unregister clears route plan and executor state', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithDesktop = {
|
||||
const api = window.feedBack.capabilities;
|
||||
window.feedBackDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
|
||||
},
|
||||
@@ -360,7 +360,7 @@ test('provider unregister clears route plan and executor state', async () => {
|
||||
|
||||
test('registry caps still allow provider and executor refreshes', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = await registerProvider(api, { providerId: `provider-${i}`, pluginId: `plugin-${i}` });
|
||||
assert.equal(result.outcome, 'handled');
|
||||
@@ -400,8 +400,8 @@ test('registry caps still allow provider and executor refreshes', async () => {
|
||||
|
||||
test('route restore preserves loaded state when an executor remains active', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithDesktop = {
|
||||
const api = window.feedBack.capabilities;
|
||||
window.feedBackDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
|
||||
},
|
||||
@@ -419,7 +419,7 @@ test('route restore preserves loaded state when an executor remains active', asy
|
||||
|
||||
test('load-plan uses a registered compatible executor when Desktop is unavailable', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let executorRequest = null;
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
@@ -428,7 +428,7 @@ test('load-plan uses a registered compatible executor when Desktop is unavailabl
|
||||
'chain.resolve': request => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'browser-plan',
|
||||
routeKey: request.routeKey,
|
||||
providerId: 'nam-tone',
|
||||
@@ -482,7 +482,7 @@ test('load-plan uses a registered compatible executor when Desktop is unavailabl
|
||||
|
||||
test('load-plan redacts circular executor payloads without overflowing', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const circular = { loaded: true };
|
||||
circular.self = circular;
|
||||
await registerProvider(api);
|
||||
@@ -516,7 +516,7 @@ test('load-plan redacts circular executor payloads without overflowing', async (
|
||||
|
||||
test('load-plan does not use an executor registered for a different provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
@@ -524,7 +524,7 @@ test('load-plan does not use an executor registered for a different provider', a
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -562,7 +562,7 @@ test('load-plan does not use an executor registered for a different provider', a
|
||||
|
||||
test('load-plan rejects executors that cannot support the resolved stage kinds', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let called = false;
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
@@ -571,7 +571,7 @@ test('load-plan rejects executors that cannot support the resolved stage kinds',
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-vst-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -611,7 +611,7 @@ test('load-plan rejects executors that cannot support the resolved stage kinds',
|
||||
|
||||
test('load-plan can fall back to a compatible provider executor when the selected provider has none', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let loadedProviderId = '';
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
@@ -621,7 +621,7 @@ test('load-plan can fall back to a compatible provider executor when the selecte
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -638,7 +638,7 @@ test('load-plan can fall back to a compatible provider executor when the selecte
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'nam-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'nam-tone',
|
||||
@@ -678,7 +678,7 @@ test('load-plan can fall back to a compatible provider executor when the selecte
|
||||
|
||||
test('load-plan can fall back from a selected provider when its executor cannot load the plan', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let loadedProviderId = '';
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
@@ -688,7 +688,7 @@ test('load-plan can fall back from a selected provider when its executor cannot
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-vst-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -705,7 +705,7 @@ test('load-plan can fall back from a selected provider when its executor cannot
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'nam-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'nam-tone',
|
||||
@@ -759,14 +759,14 @@ test('load-plan can fall back from a selected provider when its executor cannot
|
||||
|
||||
test('provider operations route stage and segment changes through selected provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
await registerProvider(api, {
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
schema: 'feedBack.audio_effects.chain_plan.v1',
|
||||
planId: 'switch-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
@@ -793,7 +793,7 @@ test('provider operations route stage and segment changes through selected provi
|
||||
|
||||
test('mapping helpers call core mapping API with provider-tagged payloads', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
window.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
@@ -815,7 +815,7 @@ test('mapping helpers call core mapping API with provider-tagged payloads', asyn
|
||||
};
|
||||
};
|
||||
|
||||
const saved = await window.slopsmith.audioEffects.upsertMapping({
|
||||
const saved = await window.feedBack.audioEffects.upsertMapping({
|
||||
song_key: 'settings-v1-song',
|
||||
filename: 'Artist - Song_p.archive',
|
||||
tone_key: 'Dist',
|
||||
@@ -829,8 +829,8 @@ test('mapping helpers call core mapping API with provider-tagged payloads', asyn
|
||||
source: 'rig_builder',
|
||||
payload: { song_key: 'settings-v1-song', provider_id: 'rig-builder' },
|
||||
});
|
||||
const activated = await window.slopsmith.audioEffects.activateMapping({ mappingId: 7, providerId: 'rig-builder' });
|
||||
const cleared = await window.slopsmith.audioEffects.clearActiveMapping({ songKey: 'settings-v1-song', toneKey: 'Dist' });
|
||||
const activated = await window.feedBack.audioEffects.activateMapping({ mappingId: 7, providerId: 'rig-builder' });
|
||||
const cleared = await window.feedBack.audioEffects.clearActiveMapping({ songKey: 'settings-v1-song', toneKey: 'Dist' });
|
||||
|
||||
assert.equal(saved.outcome, 'handled');
|
||||
assert.equal(saved.payload.mapping.provider_ref, 'chain:99');
|
||||
@@ -856,21 +856,21 @@ test('mapping helpers forward present falsey fields to the server instead of swa
|
||||
|
||||
// A falsey non-string provider_id must reach the server (which rejects it) rather than being
|
||||
// coerced to '' client-side, which would become a silent unscoped activate.
|
||||
await window.slopsmith.audioEffects.activateMapping({ mappingId: 7, providerId: false });
|
||||
await window.feedBack.audioEffects.activateMapping({ mappingId: 7, providerId: false });
|
||||
assert.equal(JSON.parse(calls[0].options.body).provider_id, false);
|
||||
|
||||
// A present falsey query filter is forwarded (stringified) rather than dropped.
|
||||
await window.slopsmith.audioEffects.listMappings({ provider_id: 0 });
|
||||
await window.feedBack.audioEffects.listMappings({ provider_id: 0 });
|
||||
assert.match(calls[1].url, /provider_id=0/);
|
||||
|
||||
// An omitted filter stays omitted (no spurious empty filter).
|
||||
await window.slopsmith.audioEffects.listMappings({});
|
||||
await window.feedBack.audioEffects.listMappings({});
|
||||
assert.equal(calls[2].url, '/api/audio-effects/mappings');
|
||||
});
|
||||
|
||||
test('bridge hits are safe and diagnosable', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const result = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
@@ -902,7 +902,7 @@ test('bridge hits are safe and diagnosable', async () => {
|
||||
routeKey: 'desktop-main',
|
||||
bridgeId: 'audio-effects.legacy-native-load',
|
||||
pluginId: 'nam_tone',
|
||||
legacySurface: 'window.slopsmithDesktop.audio.loadPreset /Users/example/model.nam',
|
||||
legacySurface: 'window.feedBackDesktop.audio.loadPreset /Users/example/model.nam',
|
||||
},
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
@@ -910,7 +910,7 @@ test('bridge hits are safe and diagnosable', async () => {
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(dbResult.outcome, 'handled');
|
||||
assert.equal(nativeResult.outcome, 'handled');
|
||||
const sharedShim = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === 'audio-effects.legacy-nam-routing');
|
||||
const sharedShim = window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === 'audio-effects.legacy-nam-routing');
|
||||
assert.equal(sharedShim.status, 'used');
|
||||
assert.equal(sharedShim.hitCount >= 1, true);
|
||||
assert.equal(encoded.includes('audio-effects.legacy-nam-routing'), true);
|
||||
|
||||
@@ -16,7 +16,7 @@ function loadAudioEffects(options = {}) {
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window) {
|
||||
return window.slopsmith.audioEffects.snapshot();
|
||||
return window.feedBack.audioEffects.snapshot();
|
||||
}
|
||||
|
||||
module.exports = { loadAudioEffects, diagnosticsSnapshot, ROOT };
|
||||
|
||||
@@ -26,7 +26,7 @@ test('legacy fader API remains compatible while bridge hits are attributed', asy
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
|
||||
let volume = 0.5;
|
||||
window.slopsmith.audio.registerFader({
|
||||
window.feedBack.audio.registerFader({
|
||||
id: 'plugin.delay',
|
||||
label: 'Delay',
|
||||
min: 0,
|
||||
@@ -37,8 +37,8 @@ test('legacy fader API remains compatible while bridge hits are attributed', asy
|
||||
setValue: value => { volume = value; },
|
||||
});
|
||||
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
assert.equal(window.slopsmith.audio.getFaders().some(fader => fader.id === 'plugin.delay'), true);
|
||||
const snapshot = window.feedBack.audioSession.snapshot();
|
||||
assert.equal(window.feedBack.audio.getFaders().some(fader => fader.id === 'plugin.delay'), true);
|
||||
assert.equal(snapshot.domains['audio-mix'].participants.some(participant => participant.participantId === 'fader.plugin.delay'), true);
|
||||
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.bridgeId === 'audio-mix.fader-registry'), true);
|
||||
});
|
||||
@@ -48,9 +48,9 @@ test('legacy analyser fallback records bridge status without losing analyser out
|
||||
installAnalyserDom(window);
|
||||
runBrowserScript(window, 'plugins/highway_3d/screen.js');
|
||||
|
||||
const analyser = window.slopsmithViz_highway_3d.__test.getAnalyserForBridgeTest();
|
||||
const bands = window.slopsmithViz_highway_3d.__test.readBandsForBridgeTest();
|
||||
const bridge = window.slopsmith.audioSession.snapshot().domains['audio-mix'].bridges.find(entry => entry.bridgeId === 'audio-mix.analyser');
|
||||
const analyser = window.feedBackViz_highway_3d.__test.getAnalyserForBridgeTest();
|
||||
const bands = window.feedBackViz_highway_3d.__test.readBandsForBridgeTest();
|
||||
const bridge = window.feedBack.audioSession.snapshot().domains['audio-mix'].bridges.find(entry => entry.bridgeId === 'audio-mix.analyser');
|
||||
|
||||
assert.equal(analyser.source, 'core');
|
||||
assert.equal(bands.bass > 0, true);
|
||||
@@ -59,9 +59,9 @@ test('legacy analyser fallback records bridge status without losing analyser out
|
||||
|
||||
test('barrier and input compatibility surfaces are visible in diagnostics', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.slopsmithAudioBarrier', participantId: 'note_detect', outcome: 'degraded', reason: 'timeout' });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.feedBackAudioBarrier', participantId: 'note_detect', outcome: 'degraded', reason: 'timeout' });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect', outcome: 'denied', reason: 'permission denied' });
|
||||
|
||||
const snapshot = audioSession.snapshot();
|
||||
@@ -71,7 +71,7 @@ test('barrier and input compatibility surfaces are visible in diagnostics', () =
|
||||
|
||||
test('a legacy bridge hit with unsafe fields never leaks a path/token into diagnostics', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.recordBridgeHit({
|
||||
domain: 'audio-input',
|
||||
@@ -91,7 +91,7 @@ test('a legacy bridge hit with unsafe fields never leaks a path/token into diagn
|
||||
|
||||
test('audio-input explicit enumeration registers provider sources without list prompting', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'desktop_audio',
|
||||
sourceId: 'bootstrap-source',
|
||||
@@ -106,7 +106,7 @@ test('audio-input explicit enumeration registers provider sources without list p
|
||||
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'desktop:instrument:secondary'), false);
|
||||
assert.deepEqual(provider.calls, []);
|
||||
|
||||
const enumerated = await window.slopsmith.audioSession.enumerateInputSources({ providerId: 'desktop_audio', explicit: true, requesterId: 'settings' });
|
||||
const enumerated = await window.feedBack.audioSession.enumerateInputSources({ providerId: 'desktop_audio', explicit: true, requesterId: 'settings' });
|
||||
const after = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
|
||||
assert.equal(enumerated.outcome, 'handled');
|
||||
@@ -117,7 +117,7 @@ test('audio-input explicit enumeration registers provider sources without list p
|
||||
|
||||
test('audio-input native source wins over compatibility-backed duplicate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
@@ -150,7 +150,7 @@ test('audio-input native source wins over compatibility-backed duplicate', async
|
||||
});
|
||||
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-input'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-input'];
|
||||
|
||||
assert.equal(listed.payload.sources.length, 1);
|
||||
assert.equal(listed.payload.sources[0].providerId, 'native_input');
|
||||
@@ -160,7 +160,7 @@ test('audio-input native source wins over compatibility-backed duplicate', async
|
||||
|
||||
test('audio-monitoring native provider wins over legacy compatibility provider and overshadows its compatibility bridge', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const legacy = makeMonitoringProvider({
|
||||
providerId: 'legacy_monitor',
|
||||
logicalMonitoringKey: 'shared:monitor:primary',
|
||||
@@ -177,7 +177,7 @@ test('audio-monitoring native provider wins over legacy compatibility provider a
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'native_monitor', payload: native.provider });
|
||||
|
||||
const listed = await api.dispatch({ capability: 'audio-monitoring', command: 'list-providers', source: 'test' });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
// The compatibility bridge below is the one the registration/supersession path actually produces;
|
||||
// asserting only on it (not on manually pre-seeded bridge hits) keeps this test honest if the
|
||||
@@ -192,10 +192,10 @@ test('legacy registerFader callbacks are usable through audio-mix get and set op
|
||||
const window = loadAudioSession();
|
||||
installMixerDom(window);
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
window.feedBack.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
|
||||
let gain = 0.35;
|
||||
window.slopsmith.audio.registerFader({
|
||||
window.feedBack.audio.registerFader({
|
||||
id: 'plugin.gain',
|
||||
label: 'Plugin Gain',
|
||||
min: 0,
|
||||
@@ -206,7 +206,7 @@ test('legacy registerFader callbacks are usable through audio-mix get and set op
|
||||
setValue: value => { gain = Math.min(0.9, value); return gain; },
|
||||
});
|
||||
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const listed = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
const read = await api.dispatch({ capability: 'audio-mix', command: 'get-fader-value', source: 'test', payload: { participantId: 'fader.plugin.gain', faderId: 'plugin.gain' } });
|
||||
const written = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'fader.plugin.gain', faderId: 'plugin.gain', value: 1 } });
|
||||
@@ -216,7 +216,7 @@ test('legacy registerFader callbacks are usable through audio-mix get and set op
|
||||
assert.equal(written.payload.committedValue, 0.9);
|
||||
assert.equal(gain, 0.9);
|
||||
|
||||
window.slopsmith.audio.unregisterFader('plugin.gain');
|
||||
assert.equal(window.slopsmith.audio.getFaders().some(fader => fader.id === 'plugin.gain'), false);
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].participants.some(participant => participant.participantId === 'fader.plugin.gain'), false);
|
||||
window.feedBack.audio.unregisterFader('plugin.gain');
|
||||
assert.equal(window.feedBack.audio.getFaders().some(fader => fader.id === 'plugin.gain'), false);
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-mix'].participants.some(participant => participant.participantId === 'fader.plugin.gain'), false);
|
||||
});
|
||||
@@ -4,8 +4,8 @@ const { loadAudioSession, diagnosticsSnapshot, makeInputProvider, makeMonitoring
|
||||
|
||||
test('audio session host registers active core domains and contributes diagnostics', () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const diagnostics = window.slopsmith.diagnostics.snapshotContributions();
|
||||
const api = window.feedBack.capabilities;
|
||||
const diagnostics = window.feedBack.diagnostics.snapshotContributions();
|
||||
|
||||
for (const domain of ['audio-mix', 'audio-input', 'audio-monitoring']) {
|
||||
const pipeline = api.inspect(domain);
|
||||
@@ -15,12 +15,12 @@ test('audio session host registers active core domains and contributes diagnosti
|
||||
const stemsPipeline = api.inspect('stems');
|
||||
assert.equal(stemsPipeline.review.lifecycle, 'active');
|
||||
assert.equal(stemsPipeline.participants.some(p => p.pluginId === 'core.audio.session' && p.roles.includes('coordinator') && !p.roles.includes('owner')), true);
|
||||
assert.equal(diagnostics['audio-session'].schema, 'slopsmith.audio_session.diagnostics.v1');
|
||||
assert.equal(diagnostics['audio-session'].schema, 'feedBack.audio_session.diagnostics.v1');
|
||||
});
|
||||
|
||||
test('audio session lifecycle and snapshots redact source identity with per-snapshot pseudonyms', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive', songFormat: 'archive' });
|
||||
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
|
||||
@@ -36,7 +36,7 @@ test('audio session lifecycle and snapshots redact source identity with per-snap
|
||||
|
||||
test('audio-input diagnostics redact source ids labels handles and bounded reasons', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
@@ -72,7 +72,7 @@ test('audio-input diagnostics redact source ids labels handles and bounded reaso
|
||||
|
||||
test('audio-input shares compatible open sessions and closes provider after last release', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
|
||||
await api.dispatch({
|
||||
@@ -110,7 +110,7 @@ test('audio-input shares compatible open sessions and closes provider after last
|
||||
|
||||
test('audio diagnostics record bounded runtime outcomes and domain statuses', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
audioSession.recordOutcome({ domain: 'audio-input', operation: 'select-source', participantId: 'test', outcome: 'degraded', status: 'unavailable', reason: `missing-${i}` });
|
||||
@@ -124,8 +124,8 @@ test('audio diagnostics record bounded runtime outcomes and domain statuses', ()
|
||||
|
||||
test('disabled missing incompatible unsupported and timeout paths are diagnosable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.registerMixParticipant({ participantId: 'disabled-fader', availability: 'disabled' });
|
||||
assert.equal(audioSession.snapshot().domains['audio-mix'].participants[0].availability, 'disabled');
|
||||
@@ -154,8 +154,8 @@ test('disabled missing incompatible unsupported and timeout paths are diagnosabl
|
||||
|
||||
test('audio-mix diagnostics include faders routes analysers bridge hits and redacted outcomes', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive' });
|
||||
audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' });
|
||||
audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] });
|
||||
@@ -197,7 +197,7 @@ test('audio-mix diagnostics include faders routes analysers bridge hits and reda
|
||||
|
||||
test('audio-monitoring diagnostics redact provider source session handles and private payloads', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const input = makeInputProvider({
|
||||
providerId: 'secret_input',
|
||||
sourceId: 'USB Interface Hardware ABC1234',
|
||||
@@ -228,7 +228,7 @@ test('audio-monitoring diagnostics redact provider source session handles and pr
|
||||
delete providerPayload.safeLabel;
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: monitoring.provider.providerId, payload: providerPayload });
|
||||
const started = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
window.slopsmith.audioSession.recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: 'secret_monitor', providerId: 'secret_monitor', monitoringId: started.payload.monitoringId, sourceId: 'USB Interface Hardware ABC1234', openSessionId: 'open raw id 1234', requesterId: 'user', outcome: 'failed', status: 'timeout', reason: 'failed at /Users/barlind/private secret=abc123' });
|
||||
window.feedBack.audioSession.recordOutcome({ domain: 'audio-monitoring', operation: 'start', participantId: 'secret_monitor', providerId: 'secret_monitor', monitoringId: started.payload.monitoringId, sourceId: 'USB Interface Hardware ABC1234', openSessionId: 'open raw id 1234', requesterId: 'user', outcome: 'failed', status: 'timeout', reason: 'failed at /Users/barlind/private secret=abc123' });
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
@@ -26,7 +26,7 @@ async function registerSource(api, payload = {}) {
|
||||
|
||||
test('audio-input requires sourceId providerId and logicalSourceKey', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const missingSource = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { providerId: 'test', logicalSourceKey: 'test:key' } });
|
||||
const missingProvider = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', logicalSourceKey: 'test:key' } });
|
||||
@@ -41,7 +41,7 @@ test('audio-input requires sourceId providerId and logicalSourceKey', async () =
|
||||
|
||||
test('audio-input registration list inspect select and snapshots pseudonymize source identity', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const registeredEvents = captureEvents(window, 'audio-input:source-registered');
|
||||
const selectedEvents = captureEvents(window, 'audio-input:source-selected');
|
||||
|
||||
@@ -62,7 +62,7 @@ test('audio-input registration list inspect select and snapshots pseudonymize so
|
||||
assert.equal(registeredEvents.length, 1);
|
||||
assert.equal(selectedEvents.length, 1);
|
||||
|
||||
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot().domains['audio-input']);
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot().domains['audio-input']);
|
||||
assert.equal(encoded.includes('source-raw-id-12345'), false);
|
||||
assert.equal(encoded.includes('Scarlett'), false);
|
||||
assert.equal(encoded.includes('987654'), false);
|
||||
@@ -70,7 +70,7 @@ test('audio-input registration list inspect select and snapshots pseudonymize so
|
||||
|
||||
test('audio-input pseudonyms are per-bundle: distinct within a snapshot, never leak raw identity', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.registerInputSource({ sourceId: 'mic-A', providerId: 'note_detect', logicalSourceKey: 'note_detect:mic-a', label: '/Users/me/My Songs/mic A' });
|
||||
audioSession.registerInputSource({ sourceId: 'mic-B', providerId: 'note_detect', logicalSourceKey: 'note_detect:mic-b', label: 'device B' });
|
||||
@@ -90,7 +90,7 @@ test('audio-input pseudonyms are per-bundle: distinct within a snapshot, never l
|
||||
|
||||
test('audio-input degraded select and unknown unregister never leak the raw source id', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
const degraded = audioSession.selectInputSource('/Users/me/secret-device', 'note_detect');
|
||||
const removed = audioSession.unregisterInputSource('/Users/me/secret-device');
|
||||
@@ -107,7 +107,7 @@ test('audio-input degraded select and unknown unregister never leak the raw sour
|
||||
|
||||
test('unknown unregister echoes the requested logicalSourceKey/providerId without pseudonymizing them', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
const removed = audioSession.unregisterInputSource({ logicalSourceKey: 'note_detect:instrument:primary', providerId: 'note_detect' });
|
||||
|
||||
@@ -121,7 +121,7 @@ test('unknown unregister echoes the requested logicalSourceKey/providerId withou
|
||||
|
||||
test('unregister-source uses providerId to target the right source among logical-key duplicates', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.registerInputSource({ sourceId: 'native-x', logicalSourceKey: 'dup:disambig', providerId: 'native_p', kind: 'instrument', safeLabel: 'N' });
|
||||
audioSession.registerInputSource({ sourceId: 'compat-x', logicalSourceKey: 'dup:disambig', providerId: 'compat_p', compatibilitySource: 'legacy', kind: 'instrument', safeLabel: 'C' });
|
||||
@@ -136,7 +136,7 @@ test('unregister-source uses providerId to target the right source among logical
|
||||
|
||||
test('register-source rejects a sourceId already owned by another provider', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const first = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_a', payload: { sourceId: 'shared-id', logicalSourceKey: 'a:key', providerId: 'prov_a' } });
|
||||
const collision = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_b', payload: { sourceId: 'shared-id', logicalSourceKey: 'b:key', providerId: 'prov_b' } });
|
||||
@@ -153,20 +153,20 @@ test('register-source rejects a sourceId already owned by another provider', asy
|
||||
|
||||
test('register-source rejects a logicalSourceKey that is not redaction-safe', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const result = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'p', payload: { sourceId: 's1', providerId: 'p', logicalSourceKey: '/Users/me/secret token=abc123' } });
|
||||
|
||||
assert.equal(result.outcome, 'failed');
|
||||
// The unsafe key must not be stored or leak into diagnostics.
|
||||
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('token=abc123'), false);
|
||||
});
|
||||
|
||||
test('enumerate denied with an unsafe providerId never leaks it into diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
// Missing explicit/userInitiated -> denied; the caller-supplied providerId is unsafe and is
|
||||
// recorded as the outcome participantId/providerId, so it must be bounded in the snapshot.
|
||||
@@ -182,20 +182,20 @@ test('enumerate denied with an unsafe providerId never leaks it into diagnostics
|
||||
|
||||
test('a malicious dispatch source is redacted before becoming a requesterId in diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'req-raw', logicalSourceKey: 'test:req' });
|
||||
// The capability dispatch `source` (the requester identity) is attacker-controlled here.
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: '/Users/me/plugin token=abc123', payload: { logicalSourceKey: 'test:req' } });
|
||||
|
||||
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('abc123'), false);
|
||||
});
|
||||
|
||||
test('caller-provided logical keys are bounded so a path/token cannot leak into diagnostics on a miss', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
// Untrusted callers may pass an unsafe value as a logical key; select/unregister misses must not
|
||||
// echo or record it raw into the redaction-safe diagnostics snapshot.
|
||||
@@ -211,7 +211,7 @@ test('caller-provided logical keys are bounded so a path/token cannot leak into
|
||||
|
||||
test('audio-monitoring distinguishes a failed state from transient unavailability', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api);
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:instrument:primary' } });
|
||||
@@ -231,7 +231,7 @@ test('audio-monitoring distinguishes a failed state from transient unavailabilit
|
||||
|
||||
test('inspect list and select do not call provider enumeration or open handlers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const provider = makeInputProvider({ providerId: 'note_detect', logicalSourceKey: 'note_detect:instrument:primary' });
|
||||
|
||||
await registerSource(api, provider.source);
|
||||
@@ -244,7 +244,7 @@ test('inspect list and select do not call provider enumeration or open handlers'
|
||||
|
||||
test('open-source and close-source record outcomes events and no live handles', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const openedEvents = captureEvents(window, 'audio-input:source-opened');
|
||||
const closedEvents = captureEvents(window, 'audio-input:source-closed');
|
||||
const degradedEvents = captureEvents(window, 'audio-input:source-open-degraded');
|
||||
@@ -275,7 +275,7 @@ test('open-source and close-source record outcomes events and no live handles',
|
||||
assert.equal(incompatible.payload.state, 'incompatible');
|
||||
assert.equal(degradedEvents.length >= 1, true);
|
||||
|
||||
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
|
||||
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('mediaStream'), false);
|
||||
assert.equal(encoded.includes('nativeHandle'), false);
|
||||
assert.equal(encoded.includes('token=abc'), false);
|
||||
@@ -283,7 +283,7 @@ test('open-source and close-source record outcomes events and no live handles',
|
||||
|
||||
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const noOwner = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(noOwner.outcome, 'no-owner');
|
||||
@@ -308,7 +308,7 @@ test('open-source reports no-owner no-handler unsupported failed and malformed p
|
||||
const malformed = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(malformed.outcome, 'handled');
|
||||
|
||||
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(outcome => outcome.domain === 'audio-input');
|
||||
const outcomes = window.feedBack.audioSession.snapshot().recentOutcomes.filter(outcome => outcome.domain === 'audio-input');
|
||||
assert.equal(outcomes.some(outcome => outcome.status === 'no-owner' || outcome.outcome === 'no-owner'), true);
|
||||
assert.equal(outcomes.some(outcome => outcome.outcome === 'unsupported-command'), true);
|
||||
assert.equal(outcomes.some(outcome => outcome.outcome === 'no-handler'), true);
|
||||
@@ -317,7 +317,7 @@ test('open-source reports no-owner no-handler unsupported failed and malformed p
|
||||
|
||||
test('open-source never switches to a non-selected source addressed by raw sourceId or logical key', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'primary-raw', logicalSourceKey: 'test:primary' });
|
||||
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
|
||||
@@ -335,7 +335,7 @@ test('open-source never switches to a non-selected source addressed by raw sourc
|
||||
|
||||
test('open-source emits an open-session-shaped payload (with requester attribution) for a pre-denied source', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const deniedEvents = captureEvents(window, 'audio-input:permission-denied');
|
||||
|
||||
await registerSource(api, { sourceId: 'predenied-raw', logicalSourceKey: 'test:predenied', availability: 'denied', reason: 'permission denied' });
|
||||
@@ -353,7 +353,7 @@ test('open-source emits an open-session-shaped payload (with requester attributi
|
||||
|
||||
test('open-source source-open-degraded uses an open-session-shaped payload when nothing is selected', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const degraded = captureEvents(window, 'audio-input:source-open-degraded');
|
||||
|
||||
// No source selected -> degraded; the event must share the OpenInputSessionSummary schema.
|
||||
@@ -369,7 +369,7 @@ test('open-source source-open-degraded uses an open-session-shaped payload when
|
||||
|
||||
test('open-source reports the selected source as unavailable when no matching source is registered', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'gone-raw', logicalSourceKey: 'test:gone' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:gone' } });
|
||||
@@ -385,7 +385,7 @@ test('open-source reports the selected source as unavailable when no matching so
|
||||
|
||||
test('open-source and close-source ignore a payload requesterId so callers cannot spoof session ownership', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'owned-raw', logicalSourceKey: 'test:owned' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:owned' } });
|
||||
@@ -405,7 +405,7 @@ test('open-source and close-source ignore a payload requesterId so callers canno
|
||||
|
||||
test('open-source and close-source propagate a provider non-handled outcome exactly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// source.open returns an explicit non-denied/failed outcome -> must propagate, not collapse to degraded.
|
||||
await registerSource(api, { sourceId: 'po-raw', logicalSourceKey: 'test:po', operationHandlers: {
|
||||
@@ -430,7 +430,7 @@ test('open-source and close-source propagate a provider non-handled outcome exac
|
||||
|
||||
test('close-source accepts logicalKey/sourceKey aliases like the other audio-input paths', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'alias-raw', logicalSourceKey: 'test:alias' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:alias' } });
|
||||
@@ -445,7 +445,7 @@ test('close-source accepts logicalKey/sourceKey aliases like the other audio-inp
|
||||
|
||||
test('close-source resolves by logicalSourceKey when requiredChannelShape is omitted', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// Default source is stereo; open without a channel-shape hint so the session is keyed by the
|
||||
// source's resolved shape, then close by logical key alone (requiredChannelShape is optional).
|
||||
@@ -461,7 +461,7 @@ test('close-source resolves by logicalSourceKey when requiredChannelShape is omi
|
||||
|
||||
test('close-source with an explicit wrong requiredChannelShape does not close a differently-shaped session', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// Default source supports mono+stereo; open as mono so the session is keyed by 'mono'.
|
||||
await registerSource(api, { sourceId: 'shaped-raw', logicalSourceKey: 'test:shaped' });
|
||||
@@ -472,7 +472,7 @@ test('close-source with an explicit wrong requiredChannelShape does not close a
|
||||
// An explicit, wrong shape must NOT fall back and close the mono session.
|
||||
const wrongShape = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:shaped', requiredChannelShape: 'stereo' } });
|
||||
assert.equal(wrongShape.outcome, 'no-handler');
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-input'].totalOpenSessions, 1);
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-input'].totalOpenSessions, 1);
|
||||
|
||||
// The original session still closes via openSessionId.
|
||||
const right = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
|
||||
@@ -481,7 +481,7 @@ test('close-source with an explicit wrong requiredChannelShape does not close a
|
||||
|
||||
test('open-source rejects a non-selected duplicate sharing the logical key, addressed by sourceId', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// Native + compatibility-backed duplicate share one logical key; the native source wins.
|
||||
await registerSource(api, { sourceId: 'native-raw', logicalSourceKey: 'dup:key', providerId: 'native_provider' });
|
||||
@@ -500,7 +500,7 @@ test('open-source rejects a non-selected duplicate sharing the logical key, addr
|
||||
|
||||
test('inspect resolves a raw sourceId to its source via the stable logical key', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'inspect-raw-id', logicalSourceKey: 'test:inspectable' });
|
||||
|
||||
@@ -516,7 +516,7 @@ test('inspect resolves a raw sourceId to its source via the stable logical key',
|
||||
|
||||
test('inspect by a shared logical key returns the native winner, not a suppressed duplicate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// Register the compatibility duplicate FIRST so a naive logicalSourceKey-only match would pick it.
|
||||
await registerSource(api, { sourceId: 'compat-first', logicalSourceKey: 'dup:inspect', providerId: 'compat_p', compatibilitySource: 'legacy' });
|
||||
@@ -531,7 +531,7 @@ test('inspect by a shared logical key returns the native winner, not a suppresse
|
||||
|
||||
test('enumerate preserves a provider-supplied safeLabel through redaction', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'labelled',
|
||||
sourceId: 'labelled-bootstrap',
|
||||
@@ -551,7 +551,7 @@ test('enumerate preserves a provider-supplied safeLabel through redaction', asyn
|
||||
|
||||
test('enumerate returns no-handler when providers exist but none support source.enumerate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.registerInputSource({ sourceId: 'nohandler-raw', logicalSourceKey: 'nh:key', providerId: 'nh_provider', operations: ['source.open'], operationHandlers: { 'source.open': () => ({ outcome: 'handled', status: 'open' }) } });
|
||||
|
||||
@@ -561,7 +561,7 @@ test('enumerate returns no-handler when providers exist but none support source.
|
||||
|
||||
test('open-source hint mismatch echoes a pseudonymized sourceId hint without leaking the raw id', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'sel-raw', logicalSourceKey: 'test:sel' });
|
||||
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
|
||||
@@ -571,13 +571,13 @@ test('open-source hint mismatch echoes a pseudonymized sourceId hint without lea
|
||||
|
||||
assert.equal(open.outcome, 'degraded');
|
||||
assert.match(open.payload.sourceId, /^source-\d+$/);
|
||||
const encoded = JSON.stringify({ open, snapshot: window.slopsmith.audioSession.snapshot() });
|
||||
const encoded = JSON.stringify({ open, snapshot: window.feedBack.audioSession.snapshot() });
|
||||
assert.equal(encoded.includes('other-raw'), false);
|
||||
});
|
||||
|
||||
test('enumerate propagates a provider source.enumerate denial instead of empty success', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'denier',
|
||||
sourceId: 'denier-bootstrap',
|
||||
@@ -608,7 +608,7 @@ test('enumerate propagates a provider source.enumerate denial instead of empty s
|
||||
|
||||
test('enumerateInputSources returns distinct sourceId pseudonyms across sources', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'multi_provider',
|
||||
sourceId: 'mp-bootstrap',
|
||||
@@ -631,7 +631,7 @@ test('enumerateInputSources returns distinct sourceId pseudonyms across sources'
|
||||
|
||||
test('open-session ids correlate between openSessions and recentOutcomes in a snapshot', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'corr-raw', logicalSourceKey: 'test:corr' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:corr' } });
|
||||
@@ -639,7 +639,7 @@ test('open-session ids correlate between openSessions and recentOutcomes in a sn
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
const openId = open.payload.openSessionId;
|
||||
const snap = window.slopsmith.audioSession.snapshot();
|
||||
const snap = window.feedBack.audioSession.snapshot();
|
||||
assert.equal(snap.domains['audio-input'].openSessions[0].openSessionId, openId);
|
||||
const openOutcome = snap.recentOutcomes.find(outcome => outcome.operation === 'open-source' && outcome.status === 'open' && outcome.openSessionId);
|
||||
assert.ok(openOutcome);
|
||||
@@ -649,8 +649,8 @@ test('open-session ids correlate between openSessions and recentOutcomes in a sn
|
||||
|
||||
test('startSession closes open input sessions from the previous session before replacing it', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
let providerClosed = 0;
|
||||
|
||||
await registerSource(api, {
|
||||
@@ -677,8 +677,8 @@ test('a persisted selected-source key that is not redaction-safe is ignored on r
|
||||
const window = loadAudioSession();
|
||||
|
||||
// Simulate a tampered/mutated localStorage entry.
|
||||
window.localStorage.setItem('slopsmith.audioInput.selectedLogicalSourceKey', '/Users/me/evil token=zzz999');
|
||||
const snap = window.slopsmith.audioSession.startSession({ sessionId: 'main:restore-test' });
|
||||
window.localStorage.setItem('feedBack.audioInput.selectedLogicalSourceKey', '/Users/me/evil token=zzz999');
|
||||
const snap = window.feedBack.audioSession.startSession({ sessionId: 'main:restore-test' });
|
||||
|
||||
assert.equal(snap.domains['audio-input'].selected, null);
|
||||
const encoded = JSON.stringify(snap);
|
||||
@@ -688,8 +688,8 @@ test('a persisted selected-source key that is not redaction-safe is ignored on r
|
||||
|
||||
test('stopSession closes open input sessions and notifies providers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
const closedEvents = captureEvents(window, 'audio-input:source-closed');
|
||||
let providerClosed = 0;
|
||||
|
||||
@@ -716,17 +716,17 @@ test('stopSession closes open input sessions and notifies providers', async () =
|
||||
|
||||
test('startSession keeps the in-memory selection when persistence has failed, ignoring stale storage', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// Seed storage with a stale key, then make subsequent writes fail.
|
||||
window.localStorage.setItem('slopsmith.audioInput.selectedLogicalSourceKey', 'stale:old-input');
|
||||
window.localStorage.setItem('feedBack.audioInput.selectedLogicalSourceKey', 'stale:old-input');
|
||||
window.localStorage.setItem = () => { throw new Error('quota'); };
|
||||
|
||||
await registerSource(api, { sourceId: 'current-raw', logicalSourceKey: 'current:input' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'current:input' } });
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-input'].storageStatus, 'failed');
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-input'].storageStatus, 'failed');
|
||||
|
||||
const snap = window.slopsmith.audioSession.startSession({ sessionId: 'main:after-fail' });
|
||||
const snap = window.feedBack.audioSession.startSession({ sessionId: 'main:after-fail' });
|
||||
|
||||
// Must keep the in-memory 'current:input', not revert to the stale storage key.
|
||||
assert.equal(snap.domains['audio-input'].selected.logicalSourceKey, 'current:input');
|
||||
@@ -735,20 +735,20 @@ test('startSession keeps the in-memory selection when persistence has failed, ig
|
||||
|
||||
test('selected source persistence restore and storage-unavailable fallback are stable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'persisted-source', logicalSourceKey: 'persisted:input' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'persisted:input' } });
|
||||
const afterStart = window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
const afterStart = window.feedBack.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
assert.equal(afterStart.domains['audio-input'].selected.logicalSourceKey, 'persisted:input');
|
||||
assert.equal(afterStart.domains['audio-input'].selected.restoreStatus, 'restored');
|
||||
|
||||
const noStorageWindow = loadAudioSession();
|
||||
noStorageWindow.localStorage.setItem = () => { throw new Error('blocked'); };
|
||||
const noStorageApi = noStorageWindow.slopsmith.capabilities;
|
||||
const noStorageApi = noStorageWindow.feedBack.capabilities;
|
||||
await registerSource(noStorageApi, { sourceId: 'session-source', logicalSourceKey: 'session:input' });
|
||||
await noStorageApi.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'session:input' } });
|
||||
const noStorageSnapshot = noStorageWindow.slopsmith.audioSession.startSession({ sessionId: 'main:no-storage-song' });
|
||||
const noStorageSnapshot = noStorageWindow.feedBack.audioSession.startSession({ sessionId: 'main:no-storage-song' });
|
||||
assert.equal(noStorageSnapshot.domains['audio-input'].storageStatus, 'failed');
|
||||
assert.equal(noStorageSnapshot.domains['audio-input'].selected.logicalSourceKey, 'session:input');
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ const { loadAudioSession } = require('./audio_session_test_harness');
|
||||
|
||||
test('audio-mix commands inspect register and unregister participants', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = [];
|
||||
window.slopsmith.on('audio-mix:participant-registered', event => events.push(event.detail.payload.participantId));
|
||||
window.feedBack.on('audio-mix:participant-registered', event => events.push(event.detail.payload.participantId));
|
||||
|
||||
const registered = await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
@@ -29,12 +29,12 @@ test('audio-mix commands inspect register and unregister participants', async ()
|
||||
assert.equal(inspected.payload.participants.some(p => p.participantId === 'plugin.delay'), true);
|
||||
assert.equal(events.includes('plugin.delay'), true);
|
||||
assert.equal(removed.status, 'applied');
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].participants.some(p => p.participantId === 'plugin.delay'), false);
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-mix'].participants.some(p => p.participantId === 'plugin.delay'), false);
|
||||
});
|
||||
|
||||
test('audio-mix registration reports incompatible participants explicitly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const result = await window.slopsmith.capabilities.dispatch({
|
||||
const result = await window.feedBack.capabilities.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
@@ -43,13 +43,13 @@ test('audio-mix registration reports incompatible participants explicitly', asyn
|
||||
|
||||
assert.equal(result.status, 'incompatible-version');
|
||||
assert.equal(result.outcome, 'incompatible-version');
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().recentOutcomes.at(-1).outcome, 'incompatible-version');
|
||||
assert.equal(window.feedBack.audioSession.snapshot().recentOutcomes.at(-1).outcome, 'incompatible-version');
|
||||
});
|
||||
|
||||
test('audio-mix lists required participant kinds and commits provider fader values', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
|
||||
|
||||
let pluginValue = 0.25;
|
||||
@@ -99,10 +99,10 @@ test('audio-mix lists required participant kinds and commits provider fader valu
|
||||
|
||||
test('audio-mix reports invalid unavailable and timed-out fader operations', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = [];
|
||||
window.slopsmith.on('audio-mix:fader-unavailable', event => events.push(event.detail.payload.participantId));
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
window.feedBack.on('audio-mix:fader-unavailable', event => events.push(event.detail.payload.participantId));
|
||||
window.feedBack.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
@@ -150,7 +150,7 @@ test('audio-mix reports invalid unavailable and timed-out fader operations', asy
|
||||
|
||||
test('audio-mix keeps pre-session participants pending then attaches them on session start', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
@@ -166,7 +166,7 @@ test('audio-mix keeps pre-session participants pending then attaches them on ses
|
||||
},
|
||||
});
|
||||
const pending = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
window.feedBack.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
const active = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
|
||||
assert.equal(pending.payload.faders.find(fader => fader.participantId === 'plugin.presession').availability, 'pending');
|
||||
@@ -175,7 +175,7 @@ test('audio-mix keeps pre-session participants pending then attaches them on ses
|
||||
|
||||
test('audio-mix registration is idempotent and song switching keeps known participants without stale route', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song' });
|
||||
audioSession.setRoute({ routeKind: 'stems', availability: 'available' });
|
||||
|
||||
@@ -189,11 +189,11 @@ test('audio-mix registration is idempotent and song switching keeps known partic
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
});
|
||||
}
|
||||
const beforeStop = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
const beforeStop = await window.feedBack.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
audioSession.stopSession('song switch');
|
||||
const stopped = audioSession.snapshot();
|
||||
audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song' });
|
||||
const afterStart = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
const afterStart = await window.feedBack.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
|
||||
assert.equal(beforeStop.payload.faders.filter(fader => fader.participantId === 'plugin.rehydrated').length, 1);
|
||||
assert.equal(stopped.domains['audio-mix'].route.availability, 'unavailable');
|
||||
@@ -207,7 +207,7 @@ test('re-registering a mix participant without handlers preserves the existing s
|
||||
// wiped the fader.set-value handler installed at init — the mixer slider
|
||||
// then moved visually but never applied the volume (archive and sloppak).
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
|
||||
|
||||
const applied = [];
|
||||
|
||||
@@ -23,7 +23,7 @@ async function installMonitoring(api, overrides = {}) {
|
||||
|
||||
test('audio-monitoring starts and stops through selected provider and source', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
|
||||
const stoppedEvents = captureEvents(window, 'audio-monitoring:monitoring-stopped');
|
||||
const input = await installInput(api);
|
||||
@@ -31,7 +31,7 @@ test('audio-monitoring starts and stops through selected provider and source', a
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { requesterId: 'user', monitoringId: active.payload.monitoringId } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(active.payload.state, 'active');
|
||||
@@ -46,7 +46,7 @@ test('audio-monitoring starts and stops through selected provider and source', a
|
||||
|
||||
test('audio-monitoring redacts circular provider payloads without overflowing', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const circular = { directMonitor: { state: 'muted' }, latencySummary: { bucket: 'low' } };
|
||||
circular.self = circular;
|
||||
await installInput(api);
|
||||
@@ -57,7 +57,7 @@ test('audio-monitoring redacts circular provider payloads without overflowing',
|
||||
});
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(snapshot.sessions[0].state, 'active');
|
||||
@@ -65,12 +65,12 @@ test('audio-monitoring redacts circular provider payloads without overflowing',
|
||||
|
||||
test('audio-monitoring snapshots redact session identifiers and source refs', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const session = snapshot.sessions[0];
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
@@ -83,7 +83,7 @@ test('audio-monitoring snapshots redact session identifiers and source refs', as
|
||||
|
||||
test('audio-monitoring reports no provider unavailable degraded denied failed user action and reload boundaries', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const degradedEvents = captureEvents(window, 'audio-monitoring:monitoring-degraded');
|
||||
const deniedEvents = captureEvents(window, 'audio-monitoring:monitoring-denied');
|
||||
const unavailableEvents = captureEvents(window, 'audio-monitoring:monitoring-unavailable');
|
||||
@@ -125,25 +125,25 @@ test('audio-monitoring reports no provider unavailable degraded denied failed us
|
||||
assert.equal(failedEvents.length >= 1, true);
|
||||
assert.equal(activeBeforeSwitch.outcome, 'handled');
|
||||
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
const afterSongSwitch = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
window.feedBack.audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
const afterSongSwitch = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
assert.equal(afterSongSwitch.sessions.some(session => session.state === 'active'), true);
|
||||
|
||||
const restoredWindow = loadAudioSession({ storage: storageEntries(window) });
|
||||
const restored = restoredWindow.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const restored = restoredWindow.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
assert.equal(restored.sessions.length, 0);
|
||||
});
|
||||
|
||||
test('audio-monitoring provider registration is idempotent and selected provider is deterministic', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const legacy = await installMonitoring(api, { providerId: 'legacy_monitor', logicalMonitoringKey: 'shared:monitor', sourceMode: 'compatibility', compatibilitySource: 'legacy.monitor' });
|
||||
const native = await installMonitoring(api, { providerId: 'native_monitor', logicalMonitoringKey: 'shared:monitor', sourceMode: 'native', safeLabel: 'Native Monitor' });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'native_monitor', payload: { ...native.provider, availability: 'pending' } });
|
||||
|
||||
const listed = await api.dispatch({ capability: 'audio-monitoring', command: 'list-providers', source: 'test' });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
assert.equal(listed.payload.providers.length, 1);
|
||||
assert.equal(listed.payload.providers[0].providerId, 'native_monitor');
|
||||
assert.equal(snapshot.providers.some(provider => provider.providerId === 'legacy_monitor' && provider.supersededBy), true);
|
||||
@@ -160,7 +160,7 @@ test('audio-monitoring provider registration is idempotent and selected provider
|
||||
|
||||
test('audio-monitoring shares compatible sessions and stops provider after final requester', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
@@ -179,14 +179,14 @@ test('audio-monitoring shares compatible sessions and stops provider after final
|
||||
|
||||
const activeAgain = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'system', payload: { providerId: monitoring.provider.providerId } });
|
||||
const afterDisappear = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const afterDisappear = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
assert.equal(activeAgain.outcome, 'handled');
|
||||
assert.equal(afterDisappear.sessions.some(session => session.state === 'orphaned'), true);
|
||||
});
|
||||
|
||||
test('audio-monitoring owner can retry a stop after a transient provider stop failure', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
let stopCalls = 0;
|
||||
await installMonitoring(api, {
|
||||
@@ -213,7 +213,7 @@ test('audio-monitoring owner can retry a stop after a transient provider stop fa
|
||||
|
||||
test('audio-monitoring surfaces a provider denial reason even when a direct-monitor conflict is present', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { startResult: { outcome: 'handled', status: 'denied', reason: 'microphone permission blocked' } });
|
||||
|
||||
@@ -229,11 +229,11 @@ test('audio-monitoring surfaces a provider denial reason even when a direct-moni
|
||||
|
||||
test('audio-monitoring normalizes an unsafe provider id before storing it', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'evil', payload: { providerId: '/Users/secret token=abcdef0123456789 monitor', logicalMonitoringKey: 'evil:main', operations: ['monitoring.start'], operationHandlers: { 'monitoring.start': () => ({ outcome: 'handled', status: 'active' }) } } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const provider = snapshot.providers[0];
|
||||
|
||||
// The surfaced providerId must be redacted + charset-restricted, never the raw path/token.
|
||||
@@ -244,7 +244,7 @@ test('audio-monitoring normalizes an unsafe provider id before storing it', asyn
|
||||
|
||||
test('audio-monitoring bounds caller-supplied identifiers reflected back in error reasons', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const evil = '/Users/secret/private token=supersecretvalue123 ' + 'x'.repeat(400);
|
||||
|
||||
@@ -266,7 +266,7 @@ test('audio-monitoring bounds caller-supplied identifiers reflected back in erro
|
||||
|
||||
test('audio-monitoring attaching to a shared session honors a conflicting direct-monitor requirement', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
@@ -284,7 +284,7 @@ test('audio-monitoring attaching to a shared session honors a conflicting direct
|
||||
|
||||
test('audio-monitoring start never lets a caller inject raw sourceRef fields', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
@@ -302,7 +302,7 @@ test('audio-monitoring start never lets a caller inject raw sourceRef fields', a
|
||||
|
||||
test('audio-monitoring start treats a void provider result as failed', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { providerId: 'void_monitor', logicalMonitoringKey: 'void:main', operationHandlers: { 'monitoring.start': () => undefined } });
|
||||
|
||||
@@ -314,14 +314,14 @@ test('audio-monitoring start treats a void provider result as failed', async ()
|
||||
|
||||
test('audio-monitoring stopAll requires explicit user action', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
// A background requester must not be able to tear down everyone's monitoring.
|
||||
const background = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'note_detect', payload: { stopAll: true } });
|
||||
const mid = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const mid = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
// An explicit user action can.
|
||||
const user = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { stopAll: true, authorization: 'user-action' } });
|
||||
|
||||
@@ -333,7 +333,7 @@ test('audio-monitoring stopAll requires explicit user action', async () => {
|
||||
|
||||
test('audio-monitoring stop does not report stopped when the provider reports a terminal status', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { stopResult: { outcome: 'handled', status: 'failed', reason: 'device fell off the bus' } });
|
||||
|
||||
@@ -348,7 +348,7 @@ test('audio-monitoring stop does not report stopped when the provider reports a
|
||||
|
||||
test('audio-monitoring stop reports no-owner when the provider has disappeared', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
@@ -364,7 +364,7 @@ test('audio-monitoring stop reports no-owner when the provider has disappeared',
|
||||
|
||||
test('audio-monitoring stop reports unsupported-command when the provider has no stop operation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { providerId: 'nostop_monitor', logicalMonitoringKey: 'nostop:main', operations: ['monitoring.start'] });
|
||||
|
||||
@@ -378,7 +378,7 @@ test('audio-monitoring stop reports unsupported-command when the provider has no
|
||||
|
||||
test('audio-monitoring events emit redaction-safe sessions', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
@@ -397,7 +397,7 @@ test('audio-monitoring events emit redaction-safe sessions', async () => {
|
||||
|
||||
test('audio-monitoring rejects a providerId collision from a different owner', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const first = makeMonitoringProvider({ providerId: 'shared_id', ownerPluginId: 'plugin_a', logicalMonitoringKey: 'a:main' });
|
||||
const second = makeMonitoringProvider({ providerId: 'shared_id', ownerPluginId: 'plugin_b', logicalMonitoringKey: 'b:main' });
|
||||
@@ -408,7 +408,7 @@ test('audio-monitoring rejects a providerId collision from a different owner', a
|
||||
const sneaky = makeMonitoringProvider({ providerId: 'shared_id', logicalMonitoringKey: 'c:main' });
|
||||
delete sneaky.provider.ownerPluginId;
|
||||
const reg3 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_c', payload: sneaky.provider });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(reg1.outcome, 'handled');
|
||||
assert.equal(reg2.outcome, 'failed');
|
||||
@@ -420,12 +420,12 @@ test('audio-monitoring rejects a providerId collision from a different owner', a
|
||||
|
||||
test('audio-monitoring session keeps openInputSessionId verbatim for cross-domain correlation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const session = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'].sessions.at(-1);
|
||||
const session = window.feedBack.audioSession.snapshot().domains['audio-monitoring'].sessions.at(-1);
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.match(active.payload.openInputSessionId, /^input-open-\d+$/);
|
||||
@@ -437,12 +437,12 @@ test('audio-monitoring session keeps openInputSessionId verbatim for cross-domai
|
||||
|
||||
test('audio-monitoring status refresh tolerates a void provider result without faking active', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { availability: 'available', operationHandlers: { 'monitoring.status': () => undefined } });
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
|
||||
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring' && entry.operation === 'status');
|
||||
const outcomes = window.feedBack.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring' && entry.operation === 'status');
|
||||
|
||||
// A void status reply is tolerated but must not be recorded as an 'active' session.
|
||||
assert.equal(outcomes.length >= 1, true);
|
||||
@@ -451,7 +451,7 @@ test('audio-monitoring status refresh tolerates a void provider result without f
|
||||
|
||||
test('audio-monitoring status refresh does not leak the raw device sourceId to providers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
let statusSourceRef = null;
|
||||
await installMonitoring(api, { operationHandlers: { 'monitoring.status': (request) => { statusSourceRef = request.sourceRef; return { outcome: 'handled', status: 'active' }; } } });
|
||||
@@ -466,7 +466,7 @@ test('audio-monitoring status refresh does not leak the raw device sourceId to p
|
||||
|
||||
test('audio-monitoring status refresh does not downgrade availability on a non-state provider reply', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { availability: 'available', statusResult: { outcome: 'no-handler' } });
|
||||
|
||||
@@ -480,7 +480,7 @@ test('audio-monitoring status refresh does not downgrade availability on a non-s
|
||||
|
||||
test('audio-monitoring re-keys active sessions on a preference change so requesters still attach', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
@@ -499,13 +499,13 @@ test('audio-monitoring re-keys active sessions on a preference change so request
|
||||
|
||||
test('audio-monitoring does not assume direct-monitor applied without provider confirmation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { directMonitorResult: { outcome: 'handled' } });
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
// The provider handled the request but did not confirm application, so applied must stay false.
|
||||
@@ -515,7 +515,7 @@ test('audio-monitoring does not assume direct-monitor applied without provider c
|
||||
|
||||
test('audio-monitoring direct-monitor summary preserves an unavailable control state', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, {
|
||||
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'unavailable', applied: false, reason: 'temporarily unavailable' } } },
|
||||
@@ -523,7 +523,7 @@ test('audio-monitoring direct-monitor summary preserves an unavailable control s
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
// A session that reports control 'unavailable' must not be collapsed to 'unknown' in the rollup.
|
||||
assert.equal(changed.payload.directMonitor.control, 'unavailable');
|
||||
@@ -533,7 +533,7 @@ test('audio-monitoring direct-monitor summary preserves an unavailable control s
|
||||
|
||||
test('audio-monitoring direct-monitor summary reflects a provider that handles but does not apply', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, {
|
||||
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'supported', applied: false, reason: 'hardware busy' } } },
|
||||
@@ -541,7 +541,7 @@ test('audio-monitoring direct-monitor summary reflects a provider that handles b
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
// Provider handled the request but reported it was not applied; the domain summary must not
|
||||
@@ -556,13 +556,13 @@ test('audio-monitoring direct-monitor summary reflects a provider that handles b
|
||||
|
||||
test('audio-monitoring direct monitor preference is user authoritative', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await installInput(api);
|
||||
const monitoring = await installMonitoring(api, { directMonitor: { state: 'muted', control: 'supported', preference: 'muted', applied: true } });
|
||||
|
||||
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const startConflict = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { requesterId: 'note_detect', authorization: 'user-action', directMonitorRequirement: 'muted', requiredChannelShape: 'mono' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
assert.equal(startConflict.outcome, 'degraded');
|
||||
@@ -574,14 +574,14 @@ test('audio-monitoring direct monitor preference is user authoritative', async (
|
||||
|
||||
test('audio-monitoring direct monitor unsupported control remains diagnosable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const changedEvents = captureEvents(window, 'audio-monitoring:direct-monitor-changed');
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { providerId: 'noctl_monitor', logicalMonitoringKey: 'noctl:monitor', operations: ['monitoring.start', 'monitoring.stop'] });
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'noctl_monitor', requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const changed = await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-monitoring'];
|
||||
const snapshot = window.feedBack.audioSession.snapshot().domains['audio-monitoring'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(changed.outcome, 'unsupported-command');
|
||||
@@ -592,7 +592,7 @@ test('audio-monitoring direct monitor unsupported control remains diagnosable',
|
||||
|
||||
test('audio-monitoring distinguishes failure outcomes and prompt-free status inspection', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const input = await installInput(api, { channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } });
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
@@ -621,7 +621,7 @@ test('audio-monitoring distinguishes failure outcomes and prompt-free status ins
|
||||
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.status'), true);
|
||||
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.start'), false);
|
||||
|
||||
const outcomes = window.slopsmith.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring');
|
||||
const outcomes = window.feedBack.audioSession.snapshot().recentOutcomes.filter(entry => entry.domain === 'audio-monitoring');
|
||||
for (const outcome of ['handled', 'unsupported-command', 'incompatible', 'incompatible-version', 'no-handler']) {
|
||||
assert.equal(outcomes.some(entry => entry.outcome === outcome), true, `missing outcome ${outcome}`);
|
||||
}
|
||||
@@ -629,12 +629,12 @@ test('audio-monitoring distinguishes failure outcomes and prompt-free status ins
|
||||
|
||||
test('audio-session diagnostics remain bounded during frequent input monitoring updates', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'bench', payload: { sourceId: `source-${index}`, logicalSourceKey: `bench:source:${index}`, providerId: 'bench', safeLabel: `/Users/example/private-${index}`, channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] }, operations: ['source.open'], operationHandlers: { 'source.open': () => ({ outcome: 'handled' }) } } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'bench', payload: { providerId: `monitor-${index}`, logicalMonitoringKey: `bench:monitor:${index}`, operations: ['monitoring.start'], operationHandlers: { 'monitoring.start': () => ({ outcome: 'handled', status: 'active' }) } } });
|
||||
}
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
const snapshot = window.feedBack.audioSession.snapshot();
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
assert.equal(snapshot.recentOutcomes.length <= 100, true);
|
||||
assert.equal(encoded.length < 96 * 1024, true);
|
||||
|
||||
@@ -4,7 +4,7 @@ const { loadAudioSession, runBrowserScript, installMixerDom } = require('./audio
|
||||
|
||||
test('audio session records route transitions without blocking callers', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
const html5 = audioSession.setRoute({ routeKind: 'html5', availability: 'available', selectedByUser: true });
|
||||
const stems = audioSession.setRoute({ routeKind: 'stems', availability: 'available', selectedByUser: true });
|
||||
@@ -24,10 +24,10 @@ test('legacy song fader registration is bridged into audio-mix participants and
|
||||
window.localStorage.setItem('volume', '65');
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
assert.equal(typeof window.slopsmith.audio.applySongVolume, 'function');
|
||||
assert.equal(typeof window.feedBack.audio.applySongVolume, 'function');
|
||||
|
||||
await window.slopsmith.audio.applySongVolume(72);
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
await window.feedBack.audio.applySongVolume(72);
|
||||
const snapshot = window.feedBack.audioSession.snapshot();
|
||||
const songParticipant = snapshot.domains['audio-mix'].participants.find(p => p.participantId === 'core.song');
|
||||
|
||||
assert.equal(audio.volume, 0.72);
|
||||
@@ -43,27 +43,27 @@ test('song volume persists through html5 stems and desktop routes', async () =>
|
||||
const stemsCalls = [];
|
||||
const desktopCalls = [];
|
||||
window.localStorage.setItem('volume', '41');
|
||||
window.slopsmith.stems = { setMasterVolume(value) { stemsCalls.push(value); return Promise.resolve(); } };
|
||||
window.slopsmithDesktop = { audio: { setGain(name, value) { desktopCalls.push([name, value]); return Promise.resolve(); } } };
|
||||
window.feedBack.stems = { setMasterVolume(value) { stemsCalls.push(value); return Promise.resolve(); } };
|
||||
window.feedBackDesktop = { audio: { setGain(name, value) { desktopCalls.push([name, value]); return Promise.resolve(); } } };
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
assert.equal(window.slopsmith.audio.readSongVolume(), 41);
|
||||
assert.equal(window.feedBack.audio.readSongVolume(), 41);
|
||||
|
||||
await window.slopsmith.audio.applySongVolume(55);
|
||||
await window.feedBack.audio.applySongVolume(55);
|
||||
assert.equal(audio.volume, 0.55);
|
||||
assert.equal(stemsCalls.at(-1), 0.55);
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'stems');
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'stems');
|
||||
|
||||
window._juceMode = true;
|
||||
delete window.slopsmith.stems;
|
||||
await window.slopsmith.audio.applySongVolume(66);
|
||||
delete window.feedBack.stems;
|
||||
await window.feedBack.audio.applySongVolume(66);
|
||||
assert.deepEqual(desktopCalls.at(-1), ['backing', 0.66]);
|
||||
assert.equal(window.slopsmith.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'juce');
|
||||
assert.equal(window.feedBack.audioSession.snapshot().domains['audio-mix'].route.routeKind, 'juce');
|
||||
});
|
||||
|
||||
test('stems provider ownership remains separate from audio-mix stem participation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:stems-song' });
|
||||
audioSession.registerStemOwner({ ownerId: 'stems_plugin', stemIds: ['guitar', 'bass'], availability: 'available' });
|
||||
audioSession.registerMixParticipant({
|
||||
@@ -76,8 +76,8 @@ test('stems provider ownership remains separate from audio-mix stem participatio
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
});
|
||||
|
||||
const stemsInspect = await window.slopsmith.capabilities.dispatch({ capability: 'stems', command: 'inspect', source: 'test' });
|
||||
const mixInspect = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'inspect', source: 'test' });
|
||||
const stemsInspect = await window.feedBack.capabilities.dispatch({ capability: 'stems', command: 'inspect', source: 'test' });
|
||||
const mixInspect = await window.feedBack.capabilities.dispatch({ capability: 'audio-mix', command: 'inspect', source: 'test' });
|
||||
|
||||
assert.equal(stemsInspect.payload.owner.ownerId, 'stems_plugin');
|
||||
assert.equal(mixInspect.payload.faders.some(fader => fader.kind === 'stem' && fader.ownerPluginId === 'stems_plugin'), true);
|
||||
@@ -85,8 +85,8 @@ test('stems provider ownership remains separate from audio-mix stem participatio
|
||||
|
||||
test('audio-input selection and registered providers survive song session switches without live sessions', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song.sloppak', songFormat: 'sloppak' });
|
||||
await api.dispatch({
|
||||
|
||||
@@ -4,8 +4,8 @@ const { loadAudioSession } = require('./audio_session_test_harness');
|
||||
|
||||
test('stem owner claim restore orphan and manual override lifecycle is recorded', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
const noOwner = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', payload: { stemIds: ['guitar'] } });
|
||||
assert.equal(noOwner.outcome, 'no-owner');
|
||||
@@ -26,8 +26,8 @@ test('stem owner claim restore orphan and manual override lifecycle is recorded'
|
||||
|
||||
test('audio session coordinates stems without replacing the active stems owner', () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
|
||||
let stemsPipeline = api.inspect('stems');
|
||||
let coordinator = stemsPipeline.participants.find(entry => entry.pluginId === 'core.audio.session');
|
||||
@@ -46,8 +46,8 @@ test('audio session coordinates stems without replacing the active stems owner',
|
||||
|
||||
test('stem automation claims become orphaned when owner disappears', () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const api = window.feedBack.capabilities;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
const unavailableEvents = [];
|
||||
api.subscribe('stems:owner-unavailable', detail => unavailableEvents.push(detail));
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ test('Stems master-volume compatibility bridge hit is attributed through audio s
|
||||
const window = loadAudioSession();
|
||||
const calls = [];
|
||||
installMixerDom(window);
|
||||
window.slopsmith.stems = { setMasterVolume(value) { calls.push(value); } };
|
||||
window.feedBack.stems = { setMasterVolume(value) { calls.push(value); } };
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
await window.slopsmith.audio.applySongVolume(50);
|
||||
await window.feedBack.audio.applySongVolume(50);
|
||||
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
const snapshot = window.feedBack.audioSession.snapshot();
|
||||
assert.deepEqual(calls, [0.5]);
|
||||
assert.equal(snapshot.domains.stems.bridges.some(bridge => bridge.bridgeId === 'stems.master-volume'), true);
|
||||
assert.equal(window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims.some(shim => shim.shimId === 'stems.master-volume' && shim.hitCount >= 1), true);
|
||||
assert.equal(window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims.some(shim => shim.shimId === 'stems.master-volume' && shim.hitCount >= 1), true);
|
||||
});
|
||||
@@ -23,12 +23,12 @@ function runBrowserScript(window, relativePath) {
|
||||
|
||||
function captureEvents(window, eventName) {
|
||||
const events = [];
|
||||
window.slopsmith.on(eventName, event => events.push(event.detail));
|
||||
window.feedBack.on(eventName, event => events.push(event.detail));
|
||||
return events;
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window) {
|
||||
return window.slopsmith.audioSession.snapshot();
|
||||
return window.feedBack.audioSession.snapshot();
|
||||
}
|
||||
|
||||
function storageEntries(window) {
|
||||
@@ -175,7 +175,7 @@ function installMixerDom(window) {
|
||||
|
||||
function loadAudioMixer(window) {
|
||||
runBrowserScript(window, path.relative(ROOT, AUDIO_MIXER_JS));
|
||||
return window.slopsmith.audio;
|
||||
return window.feedBack.audio;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Verify the autoplay & auto-exit option's pure decision helpers in app.js:
|
||||
// - _autoplayExitEnabled() (localStorage; absence = enabled)
|
||||
// - _resolvePlayerOrigin() (one-shot override → launch screen → 'home')
|
||||
//
|
||||
// Same isolation strategy as song_close.test.js — extract the function from
|
||||
// app.js by brace-matching and run it in a vm sandbox with stubbed deps.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
function runEnabled(stored) {
|
||||
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
|
||||
const sandbox = {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
if (stored === '__throw__') throw new Error('private mode');
|
||||
return stored;
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fnSrc + '\nglobalThis.__r = _autoplayExitEnabled();', sandbox);
|
||||
return sandbox.__r;
|
||||
}
|
||||
|
||||
// Fake element honoring the bits _resultsOverlayVisible() inspects.
|
||||
function el({ id = '', hidden = false, visible = true } = {}) {
|
||||
return {
|
||||
id,
|
||||
classList: { contains: (c) => c === 'hidden' && hidden },
|
||||
getClientRects: () => (visible ? [{}] : []),
|
||||
};
|
||||
}
|
||||
|
||||
function runOverlay(nodes) {
|
||||
const fnSrc = extractFunction(SRC, 'function _resultsOverlayVisible(');
|
||||
const sandbox = { document: { querySelectorAll: () => nodes } };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fnSrc + '\nglobalThis.__r = _resultsOverlayVisible();', sandbox);
|
||||
return sandbox.__r;
|
||||
}
|
||||
|
||||
function runResolve({ override = null, screens = [], active = null } = {}) {
|
||||
const fnSrc = extractFunction(SRC, 'function _resolvePlayerOrigin(');
|
||||
const sandbox = {
|
||||
window: { feedBack: { _nextReturnScreen: override } },
|
||||
document: {
|
||||
getElementById: (id) => (screens.includes(id) ? { id } : null),
|
||||
querySelector: () => (active ? { id: active } : null),
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fnSrc + '\nglobalThis.__r = _resolvePlayerOrigin();', sandbox);
|
||||
return { result: sandbox.__r, override: sandbox.window.feedBack._nextReturnScreen };
|
||||
}
|
||||
|
||||
// holdAutoExit() + _clearAutoExit() share module state; assemble them in one
|
||||
// sandbox to exercise the generation guard on the returned release handle.
|
||||
function buildHoldSandbox() {
|
||||
const clearSrc = extractFunction(SRC, 'function _clearAutoExit(');
|
||||
const holdSrc = extractFunction(SRC, 'window.feedBack.holdAutoExit = function ()');
|
||||
const sandbox = { __closeCount: 0 };
|
||||
sandbox.window = { feedBack: {}, closeCurrentSong: () => { sandbox.__closeCount++; } };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`
|
||||
var _autoExitTimer = null;
|
||||
var _autoExitHeld = false;
|
||||
var _autoExitGen = 0;
|
||||
function clearTimeout() {}
|
||||
${clearSrc}
|
||||
${holdSrc}
|
||||
globalThis.__clearAutoExit = _clearAutoExit;
|
||||
globalThis.__hold = window.feedBack.holdAutoExit;
|
||||
`, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('hold release navigates once while the generation is current', () => {
|
||||
const sb = buildHoldSandbox();
|
||||
const release = sb.__hold();
|
||||
release();
|
||||
assert.equal(sb.__closeCount, 1);
|
||||
release(); // idempotent
|
||||
assert.equal(sb.__closeCount, 1);
|
||||
});
|
||||
|
||||
test('a stale hold release no-ops after the session moves on', () => {
|
||||
const sb = buildHoldSandbox();
|
||||
const release = sb.__hold();
|
||||
sb.__clearAutoExit(); // new playSong / song:ended bumps the generation
|
||||
release();
|
||||
assert.equal(sb.__closeCount, 0, 'stale release must not navigate');
|
||||
});
|
||||
|
||||
// ── _autoplayExitEnabled ──────────────────────────────────────────────
|
||||
test('autoplayExit defaults ON when the key is absent', () => {
|
||||
assert.equal(runEnabled(null), true);
|
||||
});
|
||||
|
||||
test('autoplayExit is OFF only for the explicit "0"', () => {
|
||||
assert.equal(runEnabled('0'), false);
|
||||
assert.equal(runEnabled('1'), true);
|
||||
assert.equal(runEnabled('anything'), true);
|
||||
});
|
||||
|
||||
test('autoplayExit falls back to ON when localStorage throws', () => {
|
||||
assert.equal(runEnabled('__throw__'), true);
|
||||
});
|
||||
|
||||
// ── _resultsOverlayVisible ────────────────────────────────────────────
|
||||
test('no overlays → not visible', () => {
|
||||
assert.equal(runOverlay([]), false);
|
||||
});
|
||||
|
||||
test('a visible modal overlay defers auto-exit', () => {
|
||||
assert.equal(runOverlay([el({ id: 'mg-summary' })]), true);
|
||||
});
|
||||
|
||||
test('a hidden (.hidden) overlay does not defer', () => {
|
||||
assert.equal(runOverlay([el({ id: 'mg-summary', hidden: true })]), false);
|
||||
});
|
||||
|
||||
test('a display:none overlay (no client rects) does not defer', () => {
|
||||
assert.equal(runOverlay([el({ id: 'mg-summary', visible: false })]), false);
|
||||
});
|
||||
|
||||
test('the player screen itself never counts as a results overlay', () => {
|
||||
assert.equal(runOverlay([el({ id: 'player' })]), false);
|
||||
});
|
||||
|
||||
test('mixed: ignores player + hidden, honors a visible results overlay', () => {
|
||||
assert.equal(runOverlay([
|
||||
el({ id: 'player' }),
|
||||
el({ id: 'stale', hidden: true }),
|
||||
el({ id: 'score-card' }),
|
||||
]), true);
|
||||
});
|
||||
|
||||
// ── _resolvePlayerOrigin ──────────────────────────────────────────────
|
||||
test('one-shot override wins and is consumed when its screen exists', () => {
|
||||
const { result, override } = runResolve({
|
||||
override: 'v3-lessons', screens: ['v3-lessons', 'plugin-tutorials'], active: 'plugin-tutorials',
|
||||
});
|
||||
assert.equal(result, 'v3-lessons');
|
||||
assert.equal(override, null); // consumed, even though it won
|
||||
});
|
||||
|
||||
test('override is ignored (and still consumed) when its screen is missing', () => {
|
||||
const { result, override } = runResolve({
|
||||
override: 'ghost-screen', screens: ['favorites'], active: 'favorites',
|
||||
});
|
||||
assert.equal(result, 'favorites');
|
||||
assert.equal(override, null);
|
||||
});
|
||||
|
||||
test('remembers the real launch screen', () => {
|
||||
assert.equal(runResolve({ screens: ['v3-lessons'], active: 'v3-lessons' }).result, 'v3-lessons');
|
||||
assert.equal(runResolve({ screens: ['favorites'], active: 'favorites' }).result, 'favorites');
|
||||
});
|
||||
|
||||
test('dashboard launches (classic home + v3-home) return to the Songs list', () => {
|
||||
assert.equal(runResolve({ screens: ['home', 'v3-songs'], active: 'home' }).result, 'v3-songs');
|
||||
assert.equal(runResolve({ screens: ['v3-home', 'v3-songs'], active: 'v3-home' }).result, 'v3-songs');
|
||||
});
|
||||
|
||||
test('v3-home falls back to itself when there is no Songs list (defensive)', () => {
|
||||
assert.equal(runResolve({ screens: ['v3-home'], active: 'v3-home' }).result, 'v3-home');
|
||||
});
|
||||
|
||||
test('classic v2 (no #v3-songs) keeps home', () => {
|
||||
assert.equal(runResolve({ screens: ['home'], active: 'home' }).result, 'home');
|
||||
});
|
||||
|
||||
test('player / unknown / no active screen fall back to home', () => {
|
||||
assert.equal(runResolve({ screens: ['player'], active: 'player' }).result, 'home');
|
||||
assert.equal(runResolve({ screens: [], active: 'plugin-x' }).result, 'home');
|
||||
assert.equal(runResolve({ screens: [], active: null }).result, 'home');
|
||||
});
|
||||
@@ -31,13 +31,13 @@ function getCaseBlock(src, label) {
|
||||
|
||||
test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
// Source-level guard: catch a future contributor removing the emit
|
||||
// (regression) or replacing window.slopsmith.emit with something
|
||||
// (regression) or replacing window.feedBack.emit with something
|
||||
// else (intentional refactor — this test then needs updating).
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const block = getCaseBlock(src, 'beats');
|
||||
assert.match(
|
||||
block,
|
||||
/window\.slopsmith\.emit\(\s*['"]beats:loaded['"]/,
|
||||
/window\.feedBack\.emit\(\s*['"]beats:loaded['"]/,
|
||||
'beats case must emit beats:loaded',
|
||||
);
|
||||
assert.match(
|
||||
@@ -47,33 +47,33 @@ test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('beats:loaded emit is guarded against missing window.slopsmith', () => {
|
||||
// The WS handler can fire before the slopsmith namespace is defined
|
||||
test('beats:loaded emit is guarded against missing window.feedBack', () => {
|
||||
// The WS handler can fire before the feedBack namespace is defined
|
||||
// (early in app boot). The emit must be guarded so a missing
|
||||
// namespace doesn't throw inside the WS message dispatcher.
|
||||
// Looser pattern accepts any guard that reads window.slopsmith
|
||||
// Looser pattern accepts any guard that reads window.feedBack
|
||||
// (including typeof checks and combined conditions) rather than
|
||||
// mandating the exact `if (window.slopsmith)` form.
|
||||
// mandating the exact `if (window.feedBack)` form.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const block = getCaseBlock(src, 'beats');
|
||||
assert.match(
|
||||
block,
|
||||
/if\s*\(\s*[^)]*window\.slopsmith\b[^)]*\)/,
|
||||
'beats:loaded emit must be guarded against a missing window.slopsmith',
|
||||
/if\s*\(\s*[^)]*window\.feedBack\b[^)]*\)/,
|
||||
'beats:loaded emit must be guarded against a missing window.feedBack',
|
||||
);
|
||||
});
|
||||
|
||||
test('beats:loaded guard verifies emit is callable (typeof check)', () => {
|
||||
// A partially-attached namespace (window.slopsmith exists but emit
|
||||
// A partially-attached namespace (window.feedBack exists but emit
|
||||
// isn't a function yet during early boot) would throw without this
|
||||
// extra check. A truthy check (`window.slopsmith.emit && ...`) lets
|
||||
// extra check. A truthy check (`window.feedBack.emit && ...`) lets
|
||||
// non-callable values pass; require an explicit typeof === 'function'
|
||||
// check so the guard catches that real edge.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const block = getCaseBlock(src, 'beats');
|
||||
assert.match(
|
||||
block,
|
||||
/typeof\s+window\.slopsmith\.emit\s*===\s*['"]function['"]/,
|
||||
/typeof\s+window\.feedBack\.emit\s*===\s*['"]function['"]/,
|
||||
'guard must use typeof === \'function\' (not just truthy) to confirm emit is callable',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ function registerStemsOwner(api, calls) {
|
||||
|
||||
test('claim dispatch release records lifecycle and removes active claim', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
registerStemsOwner(api, calls);
|
||||
|
||||
@@ -48,7 +48,7 @@ test('claim dispatch release records lifecycle and removes active claim', async
|
||||
|
||||
test('manual override is terminal for matching active claim target', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
registerStemsOwner(api, calls);
|
||||
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone', target: { kind: 'guitar' } });
|
||||
|
||||
@@ -4,7 +4,7 @@ const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('diagnostics snapshots redact paths and trim recent decisions under 64 KB', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('owner', {
|
||||
stems: {
|
||||
roles: ['owner'],
|
||||
@@ -26,7 +26,7 @@ test('diagnostics snapshots redact paths and trim recent decisions under 64 KB',
|
||||
|
||||
test('compatibility shim hit counts and attribution are exported', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'active', reason: 'legacy global bridge' });
|
||||
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'used', used: true });
|
||||
api.registerCompatibilityShim({ shimId: 'stems:legacy-window', source: 'stems', capability: 'stems', legacySurface: 'window._stemsState', status: 'used', hit: true });
|
||||
@@ -39,7 +39,7 @@ test('compatibility shim hit counts and attribution are exported', () => {
|
||||
|
||||
test('diagnostics export expected compatibility shim surfaces', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const expected = api.snapshotDiagnostics().expectedCompatibilityShims;
|
||||
|
||||
assert.ok(Array.isArray(expected));
|
||||
@@ -52,7 +52,7 @@ test('diagnostics export expected compatibility shim surfaces', () => {
|
||||
|
||||
test('diagnostics include active playback but exclude deferred and documentation-only future core domains', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('playback_probe', {
|
||||
playback: {
|
||||
roles: ['provider'],
|
||||
@@ -95,7 +95,7 @@ test('diagnostics include active playback but exclude deferred and documentation
|
||||
|
||||
test('server-reported shim hit counts are not inflated by refresh registration', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const shim = {
|
||||
shimId: 'stems:legacy-window',
|
||||
source: 'stems',
|
||||
@@ -114,7 +114,7 @@ test('server-reported shim hit counts are not inflated by refresh registration',
|
||||
|
||||
test('recordLegacyHit preserves non-library shim attribution', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const first = api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
@@ -136,7 +136,7 @@ test('recordLegacyHit preserves non-library shim attribution', () => {
|
||||
|
||||
test('recordLegacyHit counts runtime use and preserves used status across active refreshes', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const first = api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
@@ -168,9 +168,9 @@ test('recordLegacyHit counts runtime use and preserves used status across active
|
||||
|
||||
test('capability diagnostics emit a changed event after runtime updates', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let changes = 0;
|
||||
window.addEventListener('slopsmith:capabilities:changed', () => { changes += 1; });
|
||||
window.addEventListener('feedBack:capabilities:changed', () => { changes += 1; });
|
||||
|
||||
api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
|
||||
@@ -2,26 +2,26 @@ const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('capability runtime installs early slopsmith event bus', () => {
|
||||
test('capability runtime installs early feedBack event bus', () => {
|
||||
const window = loadCapabilities();
|
||||
const events = [];
|
||||
const onceEvents = [];
|
||||
|
||||
window.slopsmith.on('screen:changed', event => events.push(event.detail));
|
||||
window.slopsmith.on('song:ready', event => onceEvents.push(event.detail), { once: true });
|
||||
window.feedBack.on('screen:changed', event => events.push(event.detail));
|
||||
window.feedBack.on('song:ready', event => onceEvents.push(event.detail), { once: true });
|
||||
|
||||
window.slopsmith.emit('screen:changed', { id: 'home' });
|
||||
window.slopsmith.emit('song:ready', { title: 'First' });
|
||||
window.slopsmith.emit('song:ready', { title: 'Second' });
|
||||
window.feedBack.emit('screen:changed', { id: 'home' });
|
||||
window.feedBack.emit('song:ready', { title: 'First' });
|
||||
window.feedBack.emit('song:ready', { title: 'Second' });
|
||||
|
||||
assert.deepEqual(events, [{ id: 'home' }]);
|
||||
assert.deepEqual(onceEvents, [{ title: 'First' }]);
|
||||
assert.equal(typeof window.slopsmith.off, 'function');
|
||||
assert.equal(typeof window.feedBack.off, 'function');
|
||||
});
|
||||
|
||||
test('unregistering requester releases its claims', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
|
||||
api.registerParticipant('nam_tone', { stems: { roles: ['requester'], commands: ['mute'], runtime: true } });
|
||||
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone' });
|
||||
@@ -34,7 +34,7 @@ test('unregistering requester releases its claims', () => {
|
||||
|
||||
test('unregistering owner or handler orphans claim and prevents dispatch', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
|
||||
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone' });
|
||||
api.unregisterParticipant('stems');
|
||||
@@ -51,7 +51,7 @@ test('unregistering owner or handler orphans claim and prevents dispatch', async
|
||||
|
||||
test('runtime enable disable is lifecycle state rather than user override', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('plugin_a', { stems: { roles: ['provider'], commands: ['inspect'], runtime: true } });
|
||||
const disabled = api.setParticipantEnabled('plugin_a', 'stems', false, { requester: 'test' });
|
||||
const enabled = api.setParticipantEnabled('plugin_a', 'stems', true, { requester: 'test' });
|
||||
@@ -65,7 +65,7 @@ test('runtime enable disable is lifecycle state rather than user override', () =
|
||||
|
||||
test('failed no-op registrations do not block reload and rehydrate replacement', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('', { stems: { roles: ['owner'] } });
|
||||
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled', payload: { generation: 1 } }) }, runtime: true } });
|
||||
api.unregisterParticipant('stems');
|
||||
|
||||
@@ -12,7 +12,7 @@ function fixture(name) {
|
||||
|
||||
test('manifest participants are visible before runtime handlers register', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipants([fixture('valid_owner_provider.json'), fixture('valid_requester_observer.json')]);
|
||||
|
||||
const stems = api.inspect('stems');
|
||||
@@ -27,7 +27,7 @@ test('manifest participants are visible before runtime handlers register', () =>
|
||||
|
||||
test('runtime registration refreshes an existing manifest participant without duplicating it', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipants([fixture('valid_owner_provider.json')]);
|
||||
api.registerParticipant('stems', {
|
||||
capabilities: {
|
||||
@@ -74,7 +74,7 @@ test('native library provider capability coordinates providers', async () => {
|
||||
}
|
||||
throw new Error(`unexpected fetch ${text}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = [];
|
||||
api.subscribe('library:source-changed', event => events.push(event));
|
||||
|
||||
@@ -101,7 +101,7 @@ test('native library provider capability coordinates providers', async () => {
|
||||
assert.equal(selected.payload.current, 'remote:frodo');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].payload.to, 'remote:frodo');
|
||||
assert.equal(window.slopsmith.libraryProviders.snapshot().current, 'remote:frodo');
|
||||
assert.equal(window.feedBack.libraryProviders.snapshot().current, 'remote:frodo');
|
||||
|
||||
const syncResult = await api.command('library', 'sync-song', {
|
||||
requester: 'test',
|
||||
@@ -123,7 +123,7 @@ test('removed library providers are unregistered as library participants on refr
|
||||
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
let ids = api.inspect('library').participants.map(p => p.pluginId);
|
||||
@@ -160,7 +160,7 @@ test('a plugin with non-provider library roles is not wiped when its provider di
|
||||
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// The same plugin also declares an observer library role via its manifest —
|
||||
// legitimate participation the provider-cleanup path must not delete.
|
||||
@@ -187,7 +187,7 @@ test('a plugin with non-provider library roles is not wiped when its provider di
|
||||
|
||||
test('runtime domain library declarations appear as library participants', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
const touched = api.registerParticipants([{
|
||||
id: 'remote-library-client',
|
||||
|
||||
@@ -4,7 +4,7 @@ const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('multi-provider participants use deterministic order without duplicate-owner conflict', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = [];
|
||||
api.registerParticipant('provider_b', { 'shared-viz': { roles: ['owner', 'provider'], ownership: 'multi-provider', commands: ['register-provider'], order: { after: ['provider_a'] }, handlers: { 'register-provider': () => { calls.push('b'); return { outcome: 'passed' }; } }, runtime: true } });
|
||||
api.registerParticipant('provider_a', { 'shared-viz': { roles: ['owner', 'provider'], ownership: 'multi-provider', commands: ['register-provider'], handlers: { 'register-provider': () => { calls.push('a'); return { outcome: 'handled' }; } }, runtime: true } });
|
||||
|
||||
@@ -4,7 +4,7 @@ const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('exclusive duplicate owners report conflict and degrade dispatch', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
api.registerParticipant('owner_a', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
|
||||
api.registerParticipant('owner_b', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled' }) }, runtime: true } });
|
||||
|
||||
@@ -18,7 +18,7 @@ test('exclusive duplicate owners report conflict and degrade dispatch', async ()
|
||||
|
||||
test('no-owner no-handler and unsupported-command outcomes are explicit', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
let result = await api.dispatch({ capability: 'missing-domain', command: 'mute', source: 'test' });
|
||||
assert.equal(result.status, 'no-owner');
|
||||
assert.equal(result.outcome, 'no-owner');
|
||||
|
||||
@@ -45,7 +45,7 @@ function createWindow(options = {}) {
|
||||
document: {
|
||||
getElementById(id) { return elements.get(id) || null; },
|
||||
},
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
emit(type, detail) {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ const { loadCapabilities, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
test('unsupported capability-pipelines versions are incompatible and do not execute handlers', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities', 'unsupported_capability_version.json'), 'utf8'));
|
||||
let invoked = false;
|
||||
fixture.capabilities.stems.handlers = { mute: () => { invoked = true; return { outcome: 'handled' }; } };
|
||||
|
||||
@@ -58,7 +58,7 @@ function loadInspector(snapshot, options = {}) {
|
||||
for (const handler of (listeners.get(event.type) || []).slice()) handler(event);
|
||||
return true;
|
||||
},
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
capabilities: {
|
||||
snapshotDiagnostics: () => (typeof snapshot === 'function' ? snapshot() : snapshot),
|
||||
},
|
||||
@@ -90,7 +90,7 @@ test('capability inspector renders playback session route loop bridges and outco
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const playbackSnapshot = {
|
||||
schema: 'slopsmith.playback.diagnostics.v1',
|
||||
schema: 'feedBack.playback.diagnostics.v1',
|
||||
state: {
|
||||
sessionId: 'playback-1',
|
||||
state: 'playing',
|
||||
@@ -158,8 +158,8 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
lastHitAt: '2026-05-24T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh',
|
||||
source: 'window.slopsmith.libraryProviders.refresh',
|
||||
shimId: 'runtime:library:refresh:window.feedBack.libraryProviders.refresh',
|
||||
source: 'window.feedBack.libraryProviders.refresh',
|
||||
capability: 'library',
|
||||
legacySurface: 'refresh',
|
||||
status: 'used',
|
||||
@@ -243,8 +243,8 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
assert.doesNotMatch(content, /id="capability-domain-library-graph-frame"/);
|
||||
assert.doesNotMatch(content, /Domain summary/);
|
||||
|
||||
window.__slopsmithCapabilityInspector.expandedDomains = { library: true, playback: true, 'custom.practice': true };
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.expandedDomains = { library: true, playback: true, 'custom.practice': true };
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const expandedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(expandedContent, /aria-expanded="true"/);
|
||||
@@ -307,7 +307,7 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
assert.doesNotMatch(expandedContent, /data-copy-surface=/);
|
||||
|
||||
filterElement.value = 'playback';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const selectedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(selectedContent, /data-domain-graph="playback"/);
|
||||
@@ -400,11 +400,11 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
assert.ok(selectedContent.indexOf('data-legend-icon="operation"') < selectedContent.indexOf('data-legend-icon="command"'));
|
||||
assert.doesNotMatch(selectedContent, /Domain group/);
|
||||
|
||||
window.__slopsmithCapabilityInspector.graphCollapsedGroups = {
|
||||
window.__feedBackCapabilityInspector.graphCollapsedGroups = {
|
||||
'playback|provider|all|command': true,
|
||||
'playback|participant|2|event': true,
|
||||
};
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const collapsedGroupContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(collapsedGroupContent, /data-graph-group-collapsed="true"/);
|
||||
assert.match(collapsedGroupContent, /data-graph-capability-port="group:command"/);
|
||||
@@ -416,12 +416,12 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
assert.doesNotMatch(collapsedParticipantButton, /<svg class="h-3\.5 w-3\.5/);
|
||||
assert.doesNotMatch(collapsedParticipantButton, /<path d="m9 6 6 6-6 6"\/>/);
|
||||
|
||||
window.__slopsmithCapabilityInspector.graphCollapsedGroups = {};
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.graphCollapsedGroups = {};
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
|
||||
filterElement.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'operations';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.domainGraphFilter = 'operations';
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const operationsOnlyContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(operationsOnlyContent, /aria-pressed="true" class="rounded border px-2 py-1 transition bg-purple-500\/40 text-white border-purple-400\/40">Operations/);
|
||||
assert.match(operationsOnlyContent, /data-capability-node="operation:query-page"/);
|
||||
@@ -430,25 +430,25 @@ test('capability inspector renders shims inside their capability domain', () =>
|
||||
assert.doesNotMatch(operationsOnlyContent, /data-capability-node="event:providers-refreshed"/);
|
||||
|
||||
filterElement.value = 'playback';
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'events';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.domainGraphFilter = 'events';
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const eventsOnlyContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(eventsOnlyContent, /aria-pressed="true" class="rounded border px-2 py-1 transition bg-purple-500\/40 text-white border-purple-400\/40">Events/);
|
||||
assert.match(eventsOnlyContent, /data-capability-node="event:song:ready"/);
|
||||
assert.doesNotMatch(eventsOnlyContent, /data-capability-node="command:play"/);
|
||||
|
||||
filterElement.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'shimmed';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.domainGraphFilter = 'shimmed';
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const shimmedOnlyContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(shimmedOnlyContent, /data-link-kind="shimmed"/);
|
||||
assert.doesNotMatch(shimmedOnlyContent, /data-link-kind="observed"/);
|
||||
assert.match(shimmedOnlyContent, /title="4 participants" aria-label="4 participants" role="img"/);
|
||||
|
||||
filterElement.value = '';
|
||||
window.__slopsmithCapabilityInspector.expandedDomains = {};
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'all';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.expandedDomains = {};
|
||||
window.__feedBackCapabilityInspector.domainGraphFilter = 'all';
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const collapsedFilteredContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(collapsedFilteredContent, /data-domain-graph="playback" data-domain-graph-expanded="false"[\s\S]*?title="3 participants" aria-label="3 participants" role="img"/);
|
||||
});
|
||||
@@ -468,7 +468,7 @@ test('capability inspector drops stale future-domain filter options', () => {
|
||||
|
||||
filter.innerHTML += '<option value="ui.player-panels">ui.player-panels</option>';
|
||||
filter.value = 'ui.player-panels';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
|
||||
assert.equal(filter.value, '');
|
||||
assert.doesNotMatch(filter.innerHTML, /ui\.player-panels/);
|
||||
@@ -492,9 +492,9 @@ test('capability inspector refreshes collapsed counts after runtime capability c
|
||||
|
||||
currentSnapshot = {
|
||||
...currentSnapshot,
|
||||
participants: [{ pluginId: 'core' }, { pluginId: 'window.slopsmith.libraryProviders.refresh' }, { pluginId: 'remote_library_client' }],
|
||||
participants: [{ pluginId: 'core' }, { pluginId: 'window.feedBack.libraryProviders.refresh' }, { pluginId: 'remote_library_client' }],
|
||||
compatibilityShims: [
|
||||
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:refresh:window.feedBack.libraryProviders.refresh', source: 'window.feedBack.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
|
||||
],
|
||||
expectedCompatibilityShims: [
|
||||
@@ -502,7 +502,7 @@ test('capability inspector refreshes collapsed counts after runtime capability c
|
||||
{ capability: 'library', legacySurface: 'select', reason: 'legacy library provider selector calls are counted as library.select command use' },
|
||||
],
|
||||
};
|
||||
window.dispatchEvent(new window.CustomEvent('slopsmith:capabilities:changed'));
|
||||
window.dispatchEvent(new window.CustomEvent('feedBack:capabilities:changed'));
|
||||
|
||||
const refreshedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(refreshedContent, /data-domain-graph="library" data-domain-graph-expanded="false"[\s\S]*?title="2 participants"/);
|
||||
@@ -518,7 +518,7 @@ test('capability inspector links library legacy command surfaces to canonical en
|
||||
],
|
||||
participants: [{ pluginId: 'core' }],
|
||||
compatibilityShims: [
|
||||
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:refresh:window.feedBack.libraryProviders.refresh', source: 'window.feedBack.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:sync-song:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'sync-song', status: 'used', hitCount: 1 },
|
||||
],
|
||||
@@ -532,9 +532,9 @@ test('capability inspector links library legacy command surfaces to canonical en
|
||||
const filter = elements.get('capability-inspector-filter');
|
||||
|
||||
filter.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const libraryContent = elements.get('capability-inspector-content').innerHTML;
|
||||
assert.match(libraryContent, /data-domain-participant-card="window\.slopsmith\.libraryProviders\.refresh"/);
|
||||
assert.match(libraryContent, /data-domain-participant-card="window\.feedBack\.libraryProviders\.refresh"/);
|
||||
assert.match(libraryContent, /data-domain-participant-card="remote_library_client"/);
|
||||
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>refresh<\/span>/);
|
||||
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>select<\/span>/);
|
||||
@@ -575,7 +575,7 @@ test('selected domain participant count includes shim-only graph participants',
|
||||
],
|
||||
participants: [{ pluginId: 'core' }],
|
||||
compatibilityShims: [
|
||||
{ shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh', source: 'window.slopsmith.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:refresh:window.feedBack.libraryProviders.refresh', source: 'window.feedBack.libraryProviders.refresh', capability: 'library', legacySurface: 'refresh', status: 'used', hitCount: 1 },
|
||||
{ shimId: 'runtime:library:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
|
||||
],
|
||||
expectedCompatibilityShims: [],
|
||||
@@ -583,12 +583,12 @@ test('selected domain participant count includes shim-only graph participants',
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
const filter = elements.get('capability-inspector-filter');
|
||||
filter.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const selectedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(selectedContent, /title="2 participants"/);
|
||||
assert.doesNotMatch(selectedContent, /2 Participants/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="window\.slopsmith\.libraryProviders\.refresh"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="window\.feedBack\.libraryProviders\.refresh"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="remote_library_client"/);
|
||||
});
|
||||
|
||||
@@ -604,9 +604,9 @@ test('capability inspector renders audio-mix fader diagnostics from audio-sessio
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
window.feedBack.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.audio_session.diagnostics.v1',
|
||||
schema: 'feedBack.audio_session.diagnostics.v1',
|
||||
session: { route: { routeKind: 'desktop', availability: 'degraded' }, analyser: { source: 'plugin', availability: 'available' } },
|
||||
domains: {
|
||||
'audio-mix': {
|
||||
@@ -627,7 +627,7 @@ test('capability inspector renders audio-mix fader diagnostics from audio-sessio
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /data-audio-session-support/);
|
||||
@@ -649,9 +649,9 @@ test('capability inspector renders audio-input sources selection sessions bridge
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
window.feedBack.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.audio_session.diagnostics.v1',
|
||||
schema: 'feedBack.audio_session.diagnostics.v1',
|
||||
session: { route: { routeKind: 'html5', availability: 'available' }, analyser: { source: 'none', availability: 'unavailable' } },
|
||||
domains: {
|
||||
'audio-mix': { participants: [], faders: [], route: { routeKind: 'html5', availability: 'available' }, analyser: { source: 'none', availability: 'unavailable' }, bridges: [] },
|
||||
@@ -673,7 +673,7 @@ test('capability inspector renders audio-input sources selection sessions bridge
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /Input: Input 1:available:mono:native, Legacy Input:available:stereo:compatibility:superseded/);
|
||||
@@ -695,9 +695,9 @@ test('capability inspector renders audio-monitoring providers sessions direct mo
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
window.feedBack.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.audio_session.diagnostics.v1',
|
||||
schema: 'feedBack.audio_session.diagnostics.v1',
|
||||
session: { route: { routeKind: 'desktop', availability: 'available' }, analyser: { source: 'plugin', availability: 'available' } },
|
||||
domains: {
|
||||
'audio-mix': { participants: [], faders: [], route: { routeKind: 'desktop', availability: 'available' }, analyser: { source: 'plugin', availability: 'available' }, bridges: [] },
|
||||
@@ -726,7 +726,7 @@ test('capability inspector renders audio-monitoring providers sessions direct mo
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
window.__feedBackCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /Monitoring providers: Native Monitor:available:native, Legacy Monitor:available:compatibility:superseded/);
|
||||
|
||||
@@ -7,9 +7,9 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
function loadDiagnostics() {
|
||||
const window = createWindow();
|
||||
// diagnostics.js short-circuits if window.slopsmith.diagnostics already
|
||||
// diagnostics.js short-circuits if window.feedBack.diagnostics already
|
||||
// exists (idempotent guard); the harness stubs it, so clear it first.
|
||||
window.slopsmith.diagnostics = undefined;
|
||||
window.feedBack.diagnostics = undefined;
|
||||
window.navigator = { userAgent: 'test' };
|
||||
const context = vm.createContext(window);
|
||||
const source = fs.readFileSync(path.join(ROOT, 'static', 'diagnostics.js'), 'utf8');
|
||||
@@ -18,7 +18,7 @@ function loadDiagnostics() {
|
||||
}
|
||||
|
||||
test('summarizeRuntimeDomains counts actual UI contributions, not the {declared,legacy} wrapper keys', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().feedBack.diagnostics;
|
||||
const snapshot = {
|
||||
plugins: [
|
||||
{
|
||||
@@ -49,7 +49,7 @@ test('summarizeRuntimeDomains counts actual UI contributions, not the {declared,
|
||||
});
|
||||
|
||||
test('summarizeRuntimeDomains tolerates a flat region→contributions map', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().feedBack.diagnostics;
|
||||
const summary = summarizeRuntimeDomains({
|
||||
plugins: [{ ui_contributions: { 'ui.navigation': [{ id: 'a' }, { id: 'b' }] } }],
|
||||
});
|
||||
@@ -57,7 +57,7 @@ test('summarizeRuntimeDomains tolerates a flat region→contributions map', () =
|
||||
});
|
||||
|
||||
test('summarizeRuntimeDomains counts only array region values (malformed values are ignored)', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().feedBack.diagnostics;
|
||||
// Non-array region values (a stray object/string from a malformed payload)
|
||||
// must not inflate the count — only the two real array entries count.
|
||||
const declaredSummary = summarizeRuntimeDomains({
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Regression guards for two Edit-Metadata modal fixes (static/app.js):
|
||||
//
|
||||
// 1. Year is editable — the modal renders an `edit-year` field and
|
||||
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
|
||||
// (Backend already accepts/normalizes year; only the UI omitted it.)
|
||||
//
|
||||
// 2. A click-drag that starts inside a field and is released on the backdrop
|
||||
// must NOT dismiss the modal. _editModalShouldClose() gates backdrop
|
||||
// dismissal on the mousedown having started on the backdrop too.
|
||||
//
|
||||
// Functions are extracted from the real shipped source and run in a vm — no
|
||||
// mirror copies.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const readApp = () => fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
function loadFn(signature, sandbox, exportAs) {
|
||||
const fnSrc = extractFunction(readApp(), signature);
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(`${fnSrc}\nglobalThis.${exportAs} = ${exportAs};`, ctx);
|
||||
return sandbox[exportAs];
|
||||
}
|
||||
|
||||
// ── Issue: Edit Metadata does not allow changing Year ────────────────────────
|
||||
|
||||
test('openEditModal renders a Year field bound to songData.y', () => {
|
||||
const src = extractFunction(readApp(), 'function openEditModal');
|
||||
assert.match(src, /id="edit-year"/, 'modal must render an #edit-year input');
|
||||
assert.match(src, /_escAttr\(songData\.y\)/, 'year input must be populated from songData.y');
|
||||
});
|
||||
|
||||
test('Save button wires via data-edit-save, not an inline onclick that embeds the filename', () => {
|
||||
// encodeURIComponent does NOT escape `'`, so embedding the filename in a
|
||||
// single-quoted inline `saveEditModal('…')` handler breaks the save for a
|
||||
// song whose filename contains an apostrophe (e.g. `Bob's Song.sloppak`).
|
||||
// The Save button must use the data-attr + JS-listener pattern instead.
|
||||
const src = extractFunction(readApp(), 'function openEditModal');
|
||||
assert.doesNotMatch(src, /onclick="saveEditModal\('/, 'Save must not embed the filename in an inline onclick');
|
||||
assert.match(src, /data-edit-save/, 'Save button must carry the data-edit-save hook');
|
||||
assert.match(src, /querySelector\('\[data-edit-save\]'\)/, 'Save must be wired via addEventListener');
|
||||
});
|
||||
|
||||
test('saveEditModal includes year in the metadata POST body', async () => {
|
||||
const calls = [];
|
||||
const values = {
|
||||
'edit-title': 'My Title', 'edit-artist': 'My Artist',
|
||||
'edit-album': 'My Album', 'edit-year': '1998',
|
||||
'edit-art-file': null, // signals the file branch via .files below
|
||||
'edit-modal': null,
|
||||
};
|
||||
const sandbox = {
|
||||
decodeURIComponent, encodeURIComponent, JSON, Promise,
|
||||
_lastLibSelected: null,
|
||||
loadLibrary: () => {}, loadFavorites: () => {},
|
||||
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
|
||||
document: {
|
||||
getElementById: (id) => {
|
||||
if (id === 'edit-art-file') return { files: null };
|
||||
if (id === 'edit-modal') return null;
|
||||
return id in values ? { value: values[id] } : null;
|
||||
},
|
||||
querySelector: () => null, // no active screen
|
||||
body: { contains: () => false },
|
||||
},
|
||||
};
|
||||
const saveEditModal = loadFn('async function saveEditModal', sandbox, 'saveEditModal');
|
||||
|
||||
await saveEditModal(encodeURIComponent('Song With Spaces.sloppak'));
|
||||
|
||||
const metaCall = calls.find((c) => /\/api\/song\/.+\/meta$/.test(c.url));
|
||||
assert.ok(metaCall, 'expected a POST to /api/song/<filename>/meta');
|
||||
const body = JSON.parse(metaCall.opts.body);
|
||||
assert.equal(body.year, '1998', 'meta POST body must carry the edited year');
|
||||
assert.deepEqual(
|
||||
body,
|
||||
{ title: 'My Title', artist: 'My Artist', album: 'My Album', year: '1998' },
|
||||
'meta POST body shape',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Issue: Renaming Metadata Closes Modal (click-drag release on backdrop) ────
|
||||
|
||||
test('_editModalShouldClose: backdrop needs mousedown to have started there', () => {
|
||||
const fn = loadFn('function _editModalShouldClose', {}, '_editModalShouldClose');
|
||||
|
||||
const modalEl = { closest: () => null }; // the backdrop element
|
||||
const innerEl = { closest: () => null }; // a field inside the modal
|
||||
const cancelBtn = { closest: (s) => (s === '[data-edit-close]' ? { tag: 'button' } : null) };
|
||||
|
||||
// Cancel / ✕ always closes, regardless of where the mousedown began.
|
||||
assert.equal(fn(cancelBtn, modalEl, false), true, 'Cancel/✕ closes');
|
||||
assert.equal(fn(cancelBtn, modalEl, true), true, 'Cancel/✕ closes (down-on-backdrop irrelevant)');
|
||||
|
||||
// Genuine backdrop click: down AND up on the backdrop.
|
||||
assert.equal(fn(modalEl, modalEl, true), true, 'backdrop down+up closes');
|
||||
|
||||
// The reported bug: drag began inside a field (down NOT on backdrop), click
|
||||
// resolves to the backdrop on release — must NOT close.
|
||||
assert.equal(fn(modalEl, modalEl, false), false, 'drag-from-field release on backdrop does NOT close');
|
||||
|
||||
// A click that lands on inner content never closes via the backdrop path.
|
||||
assert.equal(fn(innerEl, modalEl, true), false, 'click on inner content does not close');
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pins the arpeggio chord-gem deferral gating in plugins/highway_3d/screen.js
|
||||
// (slopsmith#262). Without these guards, an over-eager `deferChordGems` makes
|
||||
// (feedBack#262). Without these guards, an over-eager `deferChordGems` makes
|
||||
// arpeggio frames empty when standalone notes don't actually cover the shape,
|
||||
// and an under-eager one duplicates gems on top of the standalone passage.
|
||||
//
|
||||
|
||||
@@ -130,6 +130,59 @@ test('measure-start cache is invalidated on song change', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fret-row fit guard ──────────────────────────────────────────────────────
|
||||
// Keeps the heat-coloured fret-number row from clipping off the bottom edge
|
||||
// when a tight, centred zoom (worst mid-neck) drops it below the lower-third
|
||||
// framing. camUpdate dollies the camera back via a capped, hysteretic boost.
|
||||
|
||||
test('fret-row fit guard constants are defined', () => {
|
||||
for (const name of [
|
||||
'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX',
|
||||
]) {
|
||||
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
|
||||
`${name} must be declared as a fit-guard constant`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the curDist lerp target applies the fit-guard dolly boost', () => {
|
||||
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
|
||||
assert.match(
|
||||
src,
|
||||
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
|
||||
'curDist must lerp toward tgtDist * _fretRowFitBoost',
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
|
||||
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
|
||||
'the guard must probe the same row band the fret-number row is drawn at',
|
||||
);
|
||||
// Prompt pull-back when below the min, capped at BOOST_MAX.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
|
||||
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
|
||||
);
|
||||
// Lazy relax only once past the deadband, floored at 1.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
|
||||
'past the deadband the boost relaxes back toward 1',
|
||||
);
|
||||
});
|
||||
|
||||
test('the fit guard yields to the free-cam (Camera Director)', () => {
|
||||
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
|
||||
'with the free-cam enabled the guard must drop any auto dolly back to 1',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Debug hook stayed removed ───────────────────────────────────────────────
|
||||
|
||||
test('temporary camera debug hook is not present', () => {
|
||||
|
||||
@@ -42,3 +42,33 @@ test('h3dSetFretSpacing validates the mode against the two supported values', ()
|
||||
'h3dSetFretSpacing must coerce mode to a supported value before persisting',
|
||||
);
|
||||
});
|
||||
|
||||
test('h3dSetFretSpacing applies the change live, not via a page reload', () => {
|
||||
// A reload reboots the SPA to the home screen, ejecting the user from
|
||||
// Settings. The setter must instead rebind the spacing flag and broadcast
|
||||
// a 'fretSpacing' change so mounted panels rebuild in place — same path as
|
||||
// every other 3D-highway setting. Reintroducing location.reload() here is
|
||||
// the regression this guards against.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const setter = src.match(/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?\n \};/);
|
||||
assert.ok(setter, 'h3dSetFretSpacing assignment must be present');
|
||||
assert.doesNotMatch(
|
||||
setter[0],
|
||||
/location\.reload/,
|
||||
'h3dSetFretSpacing must not reload the page (it ejects the user from Settings)',
|
||||
);
|
||||
assert.match(
|
||||
setter[0],
|
||||
/_bgEmitChange\(\s*'fretSpacing'\s*\)/,
|
||||
'h3dSetFretSpacing must broadcast a live fretSpacing change',
|
||||
);
|
||||
});
|
||||
|
||||
test('the fretSpacing change rebuilds a mounted board live', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/changedKey\s*===\s*'fretSpacing'[\s\S]*?if\s*\(fretG\)\s*buildBoard\(\)/,
|
||||
'the panel bg listener must rebuild the board when fretSpacing changes',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Pins 3D Highway left-handed fret ordering (slopsmith#321).
|
||||
// Pins 3D Highway left-handed fret ordering (feedBack#321).
|
||||
// Source-level only, matching the other tests/js/ regression guards: the
|
||||
// runtime path is browser/WebGL-heavy, so these tests preserve the exact
|
||||
// contracts that keep the lefty geometry coherent.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Source-level guard for the 3D Highway overlay fully covering #highway.
|
||||
//
|
||||
// The `.h3d-wrap` overlay is anchored to top:0/left:0/right:0 of its offset
|
||||
// parent, which only lines up with #highway when the canvas sits at the
|
||||
// parent's origin. The v3 player can place chrome above the canvas, shifting
|
||||
// the wrap up so its lower edge falls short of #highway and exposes a strip
|
||||
// of the canvas (the reported gap). applySize() must pin the wrap to the
|
||||
// canvas's actual offset box so it stays flush. createHighway's WebGL
|
||||
// lifecycle is too heavy for a vm sandbox, so this locks in the wiring.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const screenJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
assert.ok(start !== -1, `signature '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
test('applySize pins the .h3d-wrap overlay to the highway canvas rect box', () => {
|
||||
const src = fs.readFileSync(screenJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function applySize(w, h)');
|
||||
// Guarded on a laid-out canvas so we never pin to a zero box.
|
||||
assert.match(
|
||||
fn,
|
||||
/highwayCanvas\s*&&\s*highwayCanvas\.offsetWidth\s*>\s*0\s*&&\s*highwayCanvas\.offsetHeight\s*>\s*0/,
|
||||
'must guard the pin on a laid-out canvas (offsetWidth/Height > 0)',
|
||||
);
|
||||
// Size/position must come from getBoundingClientRect (fractional, matches
|
||||
// ren.setSize), NOT integer offset* props which round and reopen the strip.
|
||||
assert.match(fn, /highwayCanvas\.getBoundingClientRect\(\)/, 'must measure the canvas via getBoundingClientRect');
|
||||
assert.doesNotMatch(fn, /wrap\.style\.width\s*=\s*highwayCanvas\.offsetWidth/, 'must NOT size to integer offsetWidth');
|
||||
assert.doesNotMatch(fn, /wrap\.style\.height\s*=\s*highwayCanvas\.offsetHeight/, 'must NOT size to integer offsetHeight');
|
||||
// Width/height set from the rect; position is parent-relative (padding edge).
|
||||
assert.match(fn, /wrap\.style\.width\s*=\s*_cr\.width/, 'must size width to the canvas rect width');
|
||||
assert.match(fn, /wrap\.style\.height\s*=\s*_cr\.height/, 'must size height to the canvas rect height');
|
||||
assert.match(fn, /wrap\.style\.top\s*=\s*\(\s*_cr\.top\s*-\s*_pr\.top\s*-\s*_pbTop\s*\)/, 'top must be canvas rect relative to the containing block padding edge');
|
||||
assert.match(fn, /wrap\.style\.left\s*=\s*\(\s*_cr\.left\s*-\s*_pr\.left\s*-\s*_pbLeft\s*\)/, 'left must be canvas rect relative to the containing block padding edge');
|
||||
assert.match(fn, /clientTop/, 'must strip the parent border via clientTop');
|
||||
// right:0 must be released so the explicit width takes effect.
|
||||
assert.match(fn, /wrap\.style\.right\s*=\s*['"]auto['"]/, "must release right:0 (set 'auto') when pinning width");
|
||||
});
|
||||
|
||||
test('applySize fallback resets the static anchor and the computed height', () => {
|
||||
const src = fs.readFileSync(screenJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function applySize(w, h)');
|
||||
// The not-laid-out fallback must clear any stale pin styles (a prior pin
|
||||
// leaves top/left/right:auto/width set) back to the original
|
||||
// top:0;left:0;right:0;width:auto anchor, or the wrap reappears at a
|
||||
// stale horizontal position after a panel hide/show.
|
||||
const fallback = fn.slice(fn.indexOf('} else {'));
|
||||
assert.match(fallback, /wrap\.style\.top\s*=\s*['"]0['"]/, 'fallback must reset top:0');
|
||||
assert.match(fallback, /wrap\.style\.left\s*=\s*['"]0['"]/, 'fallback must reset left:0');
|
||||
assert.match(fallback, /wrap\.style\.right\s*=\s*['"]0['"]/, 'fallback must reset right:0');
|
||||
assert.match(fallback, /wrap\.style\.width\s*=\s*['"]auto['"]/, 'fallback must reset width:auto');
|
||||
assert.match(fallback, /wrap\.style\.height\s*=\s*h\s*\+\s*['"]px['"]/, 'fallback must keep the computed height');
|
||||
});
|
||||
|
||||
test('applySize records whether the overlay pin was applied (_wrapPinned)', () => {
|
||||
const src = fs.readFileSync(screenJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function applySize(w, h)');
|
||||
// Pin path sets the flag true; the not-laid-out fallback sets it false
|
||||
// so the rAF loop knows the pin is still pending.
|
||||
assert.match(fn, /_wrapPinned\s*=\s*true/, 'pin path must set _wrapPinned = true');
|
||||
assert.match(fn, /_wrapPinned\s*=\s*false/, 'fallback path must set _wrapPinned = false');
|
||||
});
|
||||
|
||||
test('the rAF loop re-pins the overlay once the canvas lays out (Codex P1)', () => {
|
||||
// When init() pins via the parent-panel fallback (offset box still 0) and
|
||||
// the canvas later lays out to the SAME logical size, neither size-drift
|
||||
// branch fires. A dedicated branch must re-run applySize so the overlay
|
||||
// gets pinned to the now-real canvas box instead of leaving the exposed
|
||||
// strip the fix was meant to close.
|
||||
const src = fs.readFileSync(screenJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/else if\s*\(\s*!_wrapPinned\s*&&\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*&&\s*highwayCanvas\.offsetWidth\s*>\s*0\s*&&\s*highwayCanvas\.offsetHeight\s*>\s*0\s*\)\s*\{\s*[\s\S]*?applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
|
||||
'must re-pin via applySize when !_wrapPinned and the canvas has laid out',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Contract test for 3D Highway per-panel control metadata (slopsmith#247).
|
||||
// Contract test for 3D Highway per-panel control metadata (feedBack#247).
|
||||
// The plugin script is evaluated in a vm sandbox so factory statics are
|
||||
// tested without constructing a renderer instance or calling init().
|
||||
|
||||
@@ -22,7 +22,7 @@ function loadHighway3dStatics() {
|
||||
// semantic anchor inside the IIFE — so harmless footer edits (a trailing
|
||||
// sourceMappingURL comment, extra whitespace, a different IIFE close
|
||||
// style) do not break this contract test.
|
||||
const ANCHOR = 'window.slopsmithViz_highway_3d = createFactory;';
|
||||
const ANCHOR = 'window.feedBackViz_highway_3d = createFactory;';
|
||||
assert.equal(
|
||||
src.split(ANCHOR).length - 1,
|
||||
1,
|
||||
@@ -46,7 +46,7 @@ function loadHighway3dStatics() {
|
||||
},
|
||||
performance: { now: () => 0 },
|
||||
window: {
|
||||
slopsmithTour: {
|
||||
feedBackTour: {
|
||||
register() {},
|
||||
},
|
||||
},
|
||||
@@ -79,7 +79,7 @@ function assertOptionObject(option, controlKey) {
|
||||
|
||||
test('3D Highway exposes static panelControls descriptors for per-panel hosts', () => {
|
||||
const window = loadHighway3dStatics();
|
||||
const factory = window.slopsmithViz_highway_3d;
|
||||
const factory = window.feedBackViz_highway_3d;
|
||||
assert.equal(typeof factory, 'function', 'screen.js must register the 3D Highway factory');
|
||||
|
||||
assert.ok(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Source-level guards for the pool().warm() helper added in
|
||||
// slopsmith#226 — locks in:
|
||||
// feedBack#226 — locks in:
|
||||
// 1. The factory exposes .warm() (so future refactors don't quietly
|
||||
// remove the boardInit pre-allocation strategy).
|
||||
// 2. warm() coerces its argument via `cap | 0` + `Math.max(0, …)` so a
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// pause the 3D highway crept forward against a frozen audio clock and then
|
||||
// snapped back to raw — a visible twitch on every pause.
|
||||
//
|
||||
// The fix wires a host pause signal (slopsmith core's bundle.isPlaying) into
|
||||
// The fix wires a host pause signal (feedBack core's bundle.isPlaying) into
|
||||
// smoothNow: when the chart clock is not advancing, return raw immediately
|
||||
// and re-anchor. These tests lock in both halves of the contract by
|
||||
// inspecting source (the createHighway / renderer closures own WebGL + audio
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
|
||||
// half-height → ~32:9) used to render the neck as a thin central sliver because
|
||||
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
|
||||
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
|
||||
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
|
||||
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
|
||||
// byte-for-byte the prior behaviour) for live A/B comparison.
|
||||
//
|
||||
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
|
||||
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
|
||||
// guarantee would silently regress the feature (or worse, change normal-pane
|
||||
// framing). These are source-level pins — same strategy as the other
|
||||
// tests/js/ files (no DOM / WebGL in CI).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+BASE_VFOV\s*=\s*70\s*;/,
|
||||
'BASE_VFOV must be declared as a constant',
|
||||
);
|
||||
});
|
||||
|
||||
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
|
||||
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
|
||||
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
|
||||
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
|
||||
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
|
||||
'HORPLUS_MIN_VFOV floor must be declared');
|
||||
});
|
||||
|
||||
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
|
||||
|
||||
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
|
||||
// The disabled / malformed-input guard returns `base` before any Hor+ math,
|
||||
// so normal panes are unaffected when __h3dAspectTune is missing or off.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
|
||||
'effectiveVfov must short-circuit to the base fov when disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('effectiveVfov is a no-op at/under the start aspect', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
|
||||
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── shipped defaults: off + coherent ─────────────────────────────────────────
|
||||
// The "default off → byte-for-byte prior behaviour" contract only holds if the
|
||||
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
|
||||
// camera's constructed fov. A previous revision shipped enabled:true with
|
||||
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
|
||||
// silently re-framed normal single-player panes. These pin against that.
|
||||
|
||||
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
|
||||
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
|
||||
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
|
||||
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
|
||||
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default blend engages the hold and the floor sits below the base', () => {
|
||||
// blend:1 means turning the feature on actually holds the horizontal cone
|
||||
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
|
||||
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
|
||||
// not one that clamps the base upward).
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
|
||||
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
|
||||
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
|
||||
|
||||
test('applySize caches the pane aspect for camUpdate', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_paneAspect\s*=\s*cam\.aspect\s*;/,
|
||||
'applySize must cache cam.aspect into _paneAspect',
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
|
||||
'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
|
||||
);
|
||||
});
|
||||
|
||||
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
|
||||
'_aspectTune() must seed the bridge from localStorage',
|
||||
);
|
||||
});
|
||||
|
||||
test('a floating tuner panel is built and can be shown/hidden', () => {
|
||||
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
|
||||
'_ensureAspectPanel() must exist to build the live panel');
|
||||
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
|
||||
'_setAspectPanelVisible() must show/hide the panel');
|
||||
});
|
||||
|
||||
// ── Per-pane targeting ────────────────────────────────────────────────────────
|
||||
|
||||
test('the tune resolves per pane with a sparse override map', () => {
|
||||
// _resolveTuneFor overlays a pane's __panels[key] overrides onto the base so
|
||||
// one split pane can be framed independently of the others.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_resolveTuneFor\s*\(\s*paneKey\s*\)[\s\S]*?base\.__panels\s*&&\s*base\.__panels\[\s*paneKey\s*\]/,
|
||||
'_resolveTuneFor must overlay per-pane overrides from base.__panels',
|
||||
);
|
||||
});
|
||||
|
||||
test('panel writes route to the selected target (base or a pane override)', () => {
|
||||
// _aspectWriteVal writes to the base when target is empty, else into the
|
||||
// pane override sub-object; camUpdate consumes it via _resolveTuneFor.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectWriteVal\s*\([\s\S]*?if\s*\(\s*!_aspectEditTarget\s*\)[\s\S]*?base\.__panels\b[\s\S]*?\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectWriteVal must target base for "all" and __panels[target] for a pane',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Target select and pane registry drive the per-pane picker', () => {
|
||||
assert.match(src, /_aspectTargetSel\s*=\s*document\.createElement\(\s*'select'\s*\)/,
|
||||
'the panel must build a Target <select>');
|
||||
assert.match(src, /function\s+_aspectRegisterPane\s*\(/,
|
||||
'_aspectRegisterPane must record live panes for the picker');
|
||||
assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/,
|
||||
'camUpdate must register its pane only while the tuner panel is open');
|
||||
});
|
||||
|
||||
test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => {
|
||||
// 'arr:<name>' keys are distinct between split panes AND stable across
|
||||
// songs, without depending on the external splitscreen panel index (which
|
||||
// isn't always available). A per-instance id is the no-arrangement fallback.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/,
|
||||
'_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/,
|
||||
'camUpdate must key the pane by arrangement (with the uid fallback)',
|
||||
);
|
||||
});
|
||||
|
||||
test('arrangement-keyed overrides persist; instance-id keys stay session-only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPersist\s*\(\)[\s\S]*?k\.slice\(0,\s*4\)\s*===\s*'arr:'[\s\S]*?out\.__panels\s*=\s*p/,
|
||||
'_aspectPersist must persist only arr:* overrides so they carry across songs',
|
||||
);
|
||||
});
|
||||
|
||||
test('the target dropdown prunes dead panes and does not rebuild while focused', () => {
|
||||
assert.match(src, /function\s+_aspectPrunePanes\s*\(\)[\s\S]*?delete\s+reg\[k\]/,
|
||||
'_aspectPrunePanes must drop panes not seen recently');
|
||||
assert.match(src, /_aspectPrunePanes\(\)\s*;[\s\S]*?if\s*\(\s*_aspectPanesDirty\s*\)\s*_aspectBuildTargets\(\)/,
|
||||
'the readout tick must prune then rebuild only when dirty');
|
||||
assert.match(src, /function\s+_aspectBuildTargets\s*\(\)[\s\S]*?document\.activeElement\s*===\s*_aspectTargetSel[\s\S]*?return/,
|
||||
'_aspectBuildTargets must skip rebuilding while the select is focused');
|
||||
});
|
||||
|
||||
test('programmatic sync does not write back into the tune', () => {
|
||||
// _syncAspectPanel dispatches synthetic input events to refresh labels; the
|
||||
// slider handler must skip the write while syncing, else opening/switching a
|
||||
// target would populate a full override for every field.
|
||||
assert.match(src, /_aspectSyncing\s*=\s*true[\s\S]*?finally[\s\S]*?_aspectSyncing\s*=\s*false/,
|
||||
'_syncAspectPanel must set/reset the _aspectSyncing guard');
|
||||
assert.match(src, /if\s*\(\s*!_aspectSyncing\s*\)\s*_aspectWriteVal\(\s*f\.k\s*,/,
|
||||
'the slider input handler must skip the write while syncing');
|
||||
});
|
||||
|
||||
test('unchecking hfov override clears a pane override key (re-inherits base)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectClearVal\s*\(\s*k\s*\)[\s\S]*?delete\s+ov\[k\][\s\S]*?delete\s+m\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectClearVal must delete the pane override key (and empty object)',
|
||||
);
|
||||
assert.match(src, /else\s+_aspectClearVal\(\s*'hfovDeg'\s*\)/,
|
||||
'unchecking the hfov override must call _aspectClearVal');
|
||||
});
|
||||
|
||||
test('pruning drops the matching readout slot and a dangling __last', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/delete\s+reg\[k\]\s*;[\s\S]*?delete\s+ro\[k\]\s*;\s*if\s*\(\s*ro\.__last\s*===\s*k\s*\)\s*delete\s+ro\.__last/,
|
||||
'_aspectPrunePanes must prune the readout cache alongside the registry',
|
||||
);
|
||||
});
|
||||
|
||||
test('single-pane forces the edit target back to All (no hidden pane edits)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*keys\.length\s*<=\s*1\s*\|\|\s*\(\s*_aspectEditTarget\s*&&\s*!reg\[_aspectEditTarget\]\s*\)\s*\)\s*\{\s*_aspectEditTarget\s*=\s*''/,
|
||||
'_aspectBuildTargets must reset the edit target to "" when the Target row is hidden',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolved per-pane tune is memoized and invalidated by a revision', () => {
|
||||
assert.match(src, /_aspectRev\s*\+\+/, 'a mutation revision must be bumped on persist');
|
||||
assert.match(
|
||||
src,
|
||||
/_aspectResolveCache\.get\(\s*paneKey\s*\)[\s\S]*?c\.rev\s*===\s*_aspectRev[\s\S]*?return\s+c\.obj/,
|
||||
'_resolveTuneFor must return a cached object when the revision is unchanged',
|
||||
);
|
||||
});
|
||||
|
||||
test('the pane clock falls back to Date.now so pruning keeps working', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectNowMs\s*\(\)[\s\S]*?performance\.now\(\)[\s\S]*?return\s+Date\.now\(\)/,
|
||||
'_aspectNowMs must fall back to Date.now() when the Performance API is absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('opening the panel prunes before the first dropdown build', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*on\s*\)\s*\{\s*_aspectPrunePanes\(\)\s*;\s*_aspectBuildTargets\(\)/,
|
||||
'_setAspectPanelVisible must prune stale panes before building the dropdown',
|
||||
);
|
||||
});
|
||||
|
||||
test('Reset on All restores defaults exactly (no forced enabled)', () => {
|
||||
// Panel visibility is independent of the enabled flag now, so Reset must not
|
||||
// force enabled true — it should restore _ASPECT_DEFAULTS verbatim.
|
||||
assert.doesNotMatch(src, /Object\.keys\(_ASPECT_DEFAULTS\)[\s\S]*?base\.enabled\s*=\s*true/,
|
||||
'Reset must not override the default enabled state');
|
||||
});
|
||||
|
||||
test('the panel has a dismiss (close) control', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/close\.textContent\s*=\s*'×'[\s\S]*?_setAspectPanelVisible\(\s*false\s*\)/,
|
||||
'the panel header must have a × button that hides the panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate only writes cam.fov when it actually changes', () => {
|
||||
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
|
||||
// pane and keeps the disabled path free.
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
|
||||
'camUpdate must guard the cam.fov write behind a change check',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Shortcut (open/close) + lifecycle reset ───────────────────────────────────
|
||||
|
||||
test('the shortcut opens/closes the tuner panel', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/registerShortcut\(\{[\s\S]*?_toggleAspectPanel\(\)/,
|
||||
'a registerShortcut handler must toggle the tuner panel',
|
||||
);
|
||||
assert.match(src, /function\s+_toggleAspectPanel\s*\(\)/,
|
||||
'_toggleAspectPanel() must exist to reveal/dismiss the panel');
|
||||
});
|
||||
|
||||
test('destroy() resets the pane aspect and restores the base fov', () => {
|
||||
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
|
||||
'destroy() must reset _paneAspect to 0');
|
||||
assert.match(
|
||||
src,
|
||||
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
|
||||
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Source-level guards for the load-adaptive render scale (slopsmith#654).
|
||||
// Source-level guards for the load-adaptive render scale (feedBack#654).
|
||||
// The createHighway closure owns the rAF loop + WebGL sizing that's too
|
||||
// heavy for a vm sandbox, so — like highway_visibility.test.js — these
|
||||
// lock in the wiring rather than execute it.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pins the native-audio barrier ordering in static/highway.js
|
||||
// (slopsmith-desktop#117). The highway must await window.slopsmithAudioBarrier
|
||||
// (feedBack-desktop#117). The highway must await window.feedBackAudioBarrier
|
||||
// before touching the JUCE backing engine, otherwise a NAM tone graph build
|
||||
// that restarts the native audio device races the backing-track load.
|
||||
//
|
||||
@@ -12,13 +12,13 @@ const path = require('node:path');
|
||||
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
test('highway awaits slopsmithAudioBarrier before the JUCE backing path', () => {
|
||||
test('highway awaits feedBackAudioBarrier before the JUCE backing path', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const barrierIdx = src.indexOf('window.slopsmithAudioBarrier');
|
||||
const barrierIdx = src.indexOf('window.feedBackAudioBarrier');
|
||||
const isRunningIdx = src.indexOf('juceApi.isAudioRunning()');
|
||||
const loadIdx = src.indexOf('juceApi.loadBackingTrack');
|
||||
|
||||
assert.ok(barrierIdx !== -1, 'highway must reference window.slopsmithAudioBarrier');
|
||||
assert.ok(barrierIdx !== -1, 'highway must reference window.feedBackAudioBarrier');
|
||||
assert.ok(isRunningIdx !== -1, 'highway must still call juceApi.isAudioRunning()');
|
||||
assert.ok(loadIdx !== -1, 'highway must still call juceApi.loadBackingTrack');
|
||||
assert.ok(barrierIdx < isRunningIdx,
|
||||
@@ -29,8 +29,8 @@ test('highway awaits slopsmithAudioBarrier before the JUCE backing path', () =>
|
||||
|
||||
test('the barrier await is timeout-guarded so a stuck plugin barrier cannot wedge song entry', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const start = src.indexOf('window.slopsmithAudioBarrier');
|
||||
assert.ok(start !== -1, 'highway must reference window.slopsmithAudioBarrier');
|
||||
const start = src.indexOf('window.feedBackAudioBarrier');
|
||||
assert.ok(start !== -1, 'highway must reference window.feedBackAudioBarrier');
|
||||
const region = src.slice(start, start + 600);
|
||||
// Catching rejections alone does not cover a never-settling promise — the
|
||||
// await must be raced against a local timeout.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers:
|
||||
// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt`
|
||||
// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract
|
||||
// the function source by brace-matching and eval it in isolation.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const start = src.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
function loadFn(file, name) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
|
||||
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
|
||||
|
||||
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
|
||||
|
||||
test('bnvNormalizedPoints normalizes t to 0..1 across the curve span (no sus)', () => {
|
||||
const pts = bnvNormalizedPoints([
|
||||
{ t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]);
|
||||
assert.deepEqual(pts, [
|
||||
{ x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]);
|
||||
});
|
||||
|
||||
test('bnvNormalizedPoints maps t over the note sus span when given', () => {
|
||||
// A bend that completes at t=0.4 of a 0.5s note draws to x=0.8, not x=1 —
|
||||
// i.e. it stops short of the glyph's right edge (correct timing shape).
|
||||
assert.deepEqual(
|
||||
bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 0.25, v: 1 }, { t: 0.4, v: 0 }], 0.5),
|
||||
[{ x: 0, v: 0 }, { x: 0.5, v: 1 }, { x: 0.8, v: 0 }]);
|
||||
// Points beyond sus clamp to 1; sus<=0 falls back to curve-span mapping.
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0.5),
|
||||
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0),
|
||||
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
|
||||
});
|
||||
|
||||
test('bnvNormalizedPoints handles degenerate/empty input', () => {
|
||||
assert.deepEqual(bnvNormalizedPoints([]), []);
|
||||
assert.deepEqual(bnvNormalizedPoints(null), []);
|
||||
// All-same-t span collapses x to 0 (no divide-by-zero).
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]),
|
||||
[{ x: 0, v: 1 }, { x: 0, v: 2 }]);
|
||||
});
|
||||
|
||||
// ── bnvSampleAt (3D) ─────────────────────────────────────────────────────────
|
||||
|
||||
test('bnvSampleAt linearly interpolates between points', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint
|
||||
assert.equal(bnvSampleAt(bnv, 0.25), 0.5);
|
||||
});
|
||||
|
||||
test('bnvSampleAt clamps to the endpoints', () => {
|
||||
const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0), 1); // before first
|
||||
assert.equal(bnvSampleAt(bnv, 5), 3); // after last
|
||||
});
|
||||
|
||||
test('bnvSampleAt traces a round-trip curve up then back down', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak
|
||||
assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling
|
||||
});
|
||||
|
||||
test('bnvSampleAt returns 0 for an empty/invalid curve', () => {
|
||||
assert.equal(bnvSampleAt([], 0.5), 0);
|
||||
assert.equal(bnvSampleAt(null, 0.5), 0);
|
||||
});
|
||||
|
||||
test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Behavioural tests for the chord harmony-annotation render helper
|
||||
// chordHarmonyLabels (§6.3.1 / §6.6), shared by the 2D and 3D highways.
|
||||
// Pure, so we extract the function source by brace-matching and eval it in
|
||||
// isolation — same pattern as highway_teaching_marks.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const start = src.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
function loadFn(file, name) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const labels2D = loadFn('static/highway.js', 'chordHarmonyLabels');
|
||||
const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
|
||||
|
||||
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
|
||||
test(`chordHarmonyLabels (${name}) surfaces rn + voicing + caged + guideTones`, () => {
|
||||
assert.deepEqual(fn({ rn: 'ii7', q: 'm7', deg: 2 }, 'open', 'E', [4, 10]),
|
||||
{ rn: 'ii7', voicing: 'open', caged: 'CAGED: E', guideTones: 'gt 4,10' });
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) trims whitespace`, () => {
|
||||
assert.deepEqual(fn({ rn: ' V7 ' }, ' drop2 ', ' G ', []),
|
||||
{ rn: 'V7', voicing: 'drop2', caged: 'CAGED: G', guideTones: '' });
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) empties absent / malformed inputs`, () => {
|
||||
assert.deepEqual(fn(null, undefined),
|
||||
{ rn: '', voicing: '', caged: '', guideTones: '' });
|
||||
assert.deepEqual(fn({}, ''),
|
||||
{ rn: '', voicing: '', caged: '', guideTones: '' });
|
||||
assert.deepEqual(fn({ rn: 7 }, 7), // non-string
|
||||
{ rn: '', voicing: '', caged: '', guideTones: '' });
|
||||
assert.deepEqual(fn(undefined, 'shell'),
|
||||
{ rn: '', voicing: 'shell', caged: '', guideTones: '' });
|
||||
assert.deepEqual(fn({ rn: 'vi' }, null),
|
||||
{ rn: 'vi', voicing: '', caged: '', guideTones: '' });
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) rejects invalid caged enum`, () => {
|
||||
assert.equal(fn(null, null, 'X').caged, ''); // not a CAGED letter
|
||||
assert.equal(fn(null, null, 'e').caged, ''); // lower-case rejected
|
||||
assert.equal(fn(null, null, 7).caged, ''); // non-string
|
||||
assert.equal(fn(null, null, ['E']).caged, ''); // non-string
|
||||
assert.equal(fn(null, null, 'C').caged, 'CAGED: C');
|
||||
});
|
||||
|
||||
test(`chordHarmonyLabels (${name}) filters out-of-range / non-int guide tones`, () => {
|
||||
assert.equal(fn(null, null, '', [12, -1, 3, 'x', 10]).guideTones, 'gt 3,10');
|
||||
assert.equal(fn(null, null, '', [0, 11]).guideTones, 'gt 0,11'); // boundaries kept
|
||||
assert.equal(fn(null, null, '', []).guideTones, '');
|
||||
assert.equal(fn(null, null, '', '4,10').guideTones, ''); // non-array
|
||||
assert.equal(fn(null, null, '', [12, -1]).guideTones, ''); // all dropped
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// regression that drops one of the three keys, or forgets to reset the
|
||||
// derived state, will fail in CI.
|
||||
//
|
||||
// Background: see slopsmith#412 and the Copilot review thread that
|
||||
// Background: see feedBack#412 and the Copilot review thread that
|
||||
// surfaced the `chordTemplates` ordering edge case (templates can land
|
||||
// after the final `chords` chunk; `isOpen()`-derived `nonZeroNotes`
|
||||
// would otherwise stay stale until the next chord transition).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The public plugin API: window.slopsmith.highwayColors. The facade is a thin,
|
||||
// The public plugin API: window.feedBack.highwayColors. The facade is a thin,
|
||||
// stable wrapper over the (private) string-color manager in app.js. These tests
|
||||
// extract _hwcInstallFacade and run it against a fake window/bus with stubbed
|
||||
// manager functions, so the documented surface + wiring are locked in.
|
||||
@@ -49,7 +49,7 @@ function buildFacade() {
|
||||
emit(e, d) { (listeners[e] || []).slice().forEach((f) => f({ detail: d })); },
|
||||
_count: (e) => (listeners[e] || []).length,
|
||||
};
|
||||
const win = { slopsmith: bus, highway: { getStringColors: () => ['#aaaaaa'] } };
|
||||
const win = { feedBack: bus, highway: { getStringColors: () => ['#aaaaaa'] } };
|
||||
const HWC_SLOTS = [
|
||||
{ key: 'highE', label: 'High E', sub: '1st' }, { key: 'B', label: 'B', sub: '2nd' },
|
||||
{ key: 'G', label: 'G', sub: '3rd' }, { key: 'D', label: 'D', sub: '4th' },
|
||||
@@ -74,7 +74,7 @@ function buildFacade() {
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.slopsmith.highwayColors, win, bus, calls, installer, stubs };
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
}
|
||||
|
||||
test('initHighwayColors installs the facade', () => {
|
||||
@@ -159,5 +159,5 @@ test('repeated onChange with the same handler unsubscribes independently (no lea
|
||||
test('install is idempotent (does not replace an existing facade)', () => {
|
||||
const { api, win, installer } = buildFacade();
|
||||
installer();
|
||||
assert.equal(win.slopsmith.highwayColors, api, 'second install must be a no-op');
|
||||
assert.equal(win.feedBack.highwayColors, api, 'second install must be a no-op');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Source-level guards for the per-note judgment hook (slopsmith#254):
|
||||
// Source-level guards for the per-note judgment hook (feedBack#254):
|
||||
// highway.setNoteStateProvider / getNoteStateProvider / getNoteState,
|
||||
// bundle.getNoteState, isDefaultRenderer, and the _noteState
|
||||
// normalization rules. The createHighway closure owns canvas + WebGL
|
||||
@@ -54,7 +54,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)',
|
||||
assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (slopsmith#254)', () => {
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Same allocation discipline as getNoteState: highway_3d uses this
|
||||
@@ -106,7 +106,7 @@ test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with
|
||||
assert.match(src, /const\s+_showHit\s*=\s*\(\s*_ndState\s*===\s*['"]miss['"]\s*\)\s*\?\s*false\s*:\s*\(\s*_ndState\s*\?\s*_ndGood\s*:\s*\(\s*hit\s*\|\|\s*\(\s*n\.f\s*>\s*0\s*&&\s*inGhostWin\s*\)\s*\)\s*\)/, '_showHit must honor a provider "miss" and fall back to the hit/ghost heuristic only with no verdict');
|
||||
});
|
||||
|
||||
test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (slopsmith#254)', () => {
|
||||
test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (feedBack#254)', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
// Detect-mode behavior — verdict-window cull extension, chord-frame
|
||||
// hold floor, and the smart drawNote cull — must be gated on a real
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Source-level guards for the playback-aware paused-render throttle
|
||||
// (slopsmith#654). The createHighway closure owns the rAF loop + WebGL
|
||||
// (feedBack#654). The createHighway closure owns the rAF loop + WebGL
|
||||
// context lifecycle that's too heavy to reproduce in a vm sandbox, so —
|
||||
// like highway_visibility.test.js — these checks lock in the wiring.
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Source-level guard for the renderer-swap canvas reset.
|
||||
//
|
||||
// Both 3D visualizations are webgl2 renderers, but they paint to
|
||||
// different surfaces: the 3D *drum* highway renders directly onto the
|
||||
// shared #highway canvas, while the 3D *guitar* highway renders into its
|
||||
// own `.h3d-wrap` sibling overlay and never touches #highway. Switching
|
||||
// drum -> guitar is webgl2 -> webgl2, so the context-type check alone
|
||||
// never replaced the canvas — the last drum frame stayed painted on
|
||||
// #highway and bled through the gap the guitar overlay does not cover.
|
||||
//
|
||||
// The fix replaces the underlying <canvas> on ANY swap to a different
|
||||
// renderer instance (not just on a context-type change) so the incoming
|
||||
// renderer always starts over a blank surface. These checks lock in the
|
||||
// wiring; the createHighway closure owns a WebGL lifecycle too heavy to
|
||||
// reproduce in a vm sandbox.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
assert.ok(start !== -1, `signature '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
test('_setRenderer captures the outgoing renderer before overwriting it', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _setRenderer(r)');
|
||||
// prev must be captured BEFORE _destroyCurrentIfInited and the
|
||||
// `_renderer = next` assignment, otherwise the swap detection below
|
||||
// would always compare next against itself.
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
|
||||
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
|
||||
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
|
||||
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
|
||||
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
|
||||
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
|
||||
assert.ok(prevIdx < destroyIdx, 'prev must be captured before _destroyCurrentIfInited');
|
||||
assert.ok(prevIdx < assignIdx, 'prev must be captured before `_renderer = next`');
|
||||
});
|
||||
|
||||
test('_setRenderer replaces the canvas on a context-type change OR a viz change', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _setRenderer(r)');
|
||||
// The replace guard must fire on EITHER a context-type change OR a
|
||||
// swap to a different visualization. A regression that drops the
|
||||
// viz-change clause would let a stale frame bleed through.
|
||||
assert.match(
|
||||
fn,
|
||||
/const\s+_vizChanged\s*=\s*prev\s*&&\s*_rendererVizKey\(next\)\s*!==\s*_rendererVizKey\(prev\)/,
|
||||
'_vizChanged must compare _rendererVizKey(next) vs _rendererVizKey(prev), guarded by prev',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
|
||||
);
|
||||
});
|
||||
|
||||
test('_rendererVizKey keys on the viz id, not object identity (avoids churn on same-viz re-install)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _rendererVizKey(r)');
|
||||
// Default renderer keys on its singleton; custom renderers key on the
|
||||
// viz picker id (pluginId/source) stamped by app.js's _tagVizRenderer,
|
||||
// falling back to the object reference only when untagged.
|
||||
assert.match(fn, /r\s*===\s*_defaultRenderer/, 'default renderer must key on its own singleton');
|
||||
assert.match(fn, /r\.pluginId\s*\|\|\s*r\.source/, 'custom renderers must key on pluginId/source (the viz id)');
|
||||
});
|
||||
|
||||
test('_setRenderer skips the extra replace on first install (prev === null)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _setRenderer(r)');
|
||||
// The `prev &&` guard avoids a needless swap on the very first install
|
||||
// (prev === null) where there is no prior frame to clear.
|
||||
assert.match(fn, /_vizChanged\s*=\s*prev\s*&&/, 'must short-circuit _vizChanged when prev is null');
|
||||
});
|
||||
@@ -101,8 +101,8 @@ test('app.js color manager name-maps to both highways, with identity no-op + bui
|
||||
const idfn = extractBlock(src, 'function _hwcMappingIsIdentity(sc, isBass)');
|
||||
assert.match(idfn, /return isBass \? sc <= 4 : sc <= 6/, 'identity must be 4-string bass / ≤6-string guitar');
|
||||
// Re-apply on song load (string count can change the slot→index mapping).
|
||||
assert.match(src, /window\.slopsmith\.on\('viz:renderer:ready', reapplyHighwayStringColors\)/, 'must re-apply when a viz renderer becomes ready');
|
||||
assert.match(src, /window\.slopsmith\.on\('song:loaded', reapplyHighwayStringColors\)/, 'must re-apply on song load');
|
||||
assert.match(src, /window\.feedBack\.on\('viz:renderer:ready', reapplyHighwayStringColors\)/, 'must re-apply when a viz renderer becomes ready');
|
||||
assert.match(src, /window\.feedBack\.on\('song:loaded', reapplyHighwayStringColors\)/, 'must re-apply on song load');
|
||||
});
|
||||
|
||||
// ── Executable: dim/bright derivation math ────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Behavioural tests for the teaching-marks (§6.2.2) render helpers:
|
||||
// teachingFingerLabel / teachingDegreeLabel (both highways) and
|
||||
// strumGroupBuckets (2D, drives the strum bracket). All pure, so we extract
|
||||
// the function source by brace-matching and eval it in isolation — same
|
||||
// pattern as highway_bend_curve.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const start = src.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
function loadFn(file, name) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const fingerLabel2D = loadFn('static/highway.js', 'teachingFingerLabel');
|
||||
const degreeLabel2D = loadFn('static/highway.js', 'teachingDegreeLabel');
|
||||
const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
|
||||
const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
|
||||
const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets');
|
||||
|
||||
// ── teachingFingerLabel (fg) ─────────────────────────────────────────────────
|
||||
|
||||
for (const [name, fn] of [['2D', fingerLabel2D], ['3D', fingerLabel3D]]) {
|
||||
test(`teachingFingerLabel (${name}) maps 0->T, 1..4->digit, else ''`, () => {
|
||||
assert.equal(fn(0), 'T'); // thumb
|
||||
assert.equal(fn(1), '1');
|
||||
assert.equal(fn(4), '4'); // pinky
|
||||
assert.equal(fn(-1), ''); // unset
|
||||
assert.equal(fn(5), ''); // out of range
|
||||
assert.equal(fn(1.5), ''); // non-integer
|
||||
assert.equal(fn(undefined), '');
|
||||
assert.equal(fn(null), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ── teachingDegreeLabel (sd) ─────────────────────────────────────────────────
|
||||
|
||||
for (const [name, fn] of [['2D', degreeLabel2D], ['3D', degreeLabel3D]]) {
|
||||
test(`teachingDegreeLabel (${name}) shows 0..11, else ''`, () => {
|
||||
assert.equal(fn(0), '0'); // tonic
|
||||
assert.equal(fn(7), '7'); // fifth
|
||||
assert.equal(fn(11), '11');
|
||||
assert.equal(fn(-1), ''); // unset
|
||||
assert.equal(fn(12), ''); // out of range
|
||||
assert.equal(fn(3.2), ''); // non-integer
|
||||
assert.equal(fn(undefined), '');
|
||||
});
|
||||
}
|
||||
|
||||
// ── strumGroupBuckets (ch) ───────────────────────────────────────────────────
|
||||
|
||||
test('strumGroupBuckets groups notes sharing a ch >= 0, dropping lone notes', () => {
|
||||
const items = [
|
||||
{ id: 'a', ch: 5 },
|
||||
{ id: 'b', ch: -1 }, // ungrouped
|
||||
{ id: 'c', ch: 5 },
|
||||
{ id: 'd', ch: 7 }, // lone group (only one member) -> dropped
|
||||
{ id: 'e', ch: 5 },
|
||||
];
|
||||
const groups = strumGroupBuckets(items);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(groups[0].map(n => n.id), ['a', 'c', 'e']);
|
||||
});
|
||||
|
||||
test('strumGroupBuckets preserves first-seen group order and handles multiple groups', () => {
|
||||
const items = [
|
||||
{ id: 'a', ch: 2 }, { id: 'b', ch: 9 },
|
||||
{ id: 'c', ch: 2 }, { id: 'd', ch: 9 },
|
||||
];
|
||||
const groups = strumGroupBuckets(items);
|
||||
assert.deepEqual(groups.map(g => g.map(n => n.id)), [['a', 'c'], ['b', 'd']]);
|
||||
});
|
||||
|
||||
test('strumGroupBuckets ignores non-integer / negative ch and bad input', () => {
|
||||
assert.deepEqual(strumGroupBuckets([{ ch: -1 }, { ch: 1.5 }, { ch: null }, {}]), []);
|
||||
assert.deepEqual(strumGroupBuckets([]), []);
|
||||
assert.deepEqual(strumGroupBuckets(null), []);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// Source-level guards for the visibility-aware rAF skip and the
|
||||
// highway:visibility event (slopsmith#246). The createHighway closure
|
||||
// highway:visibility event (feedBack#246). The createHighway closure
|
||||
// owns the canvas + WebGL context lifecycle that's too heavy to
|
||||
// reproduce in a vm sandbox — these checks lock in the wiring instead.
|
||||
|
||||
@@ -52,7 +52,7 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(
|
||||
fn,
|
||||
/window\.slopsmith\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
'must emit highway:visibility with { visible, canvas }',
|
||||
);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
|
||||
assert.ok(readyIdx < drawIdx, 'ready gate must run before renderer.draw');
|
||||
});
|
||||
|
||||
test('draw() keeps an active custom renderer painting through an override-hide (slopsmith#819)', () => {
|
||||
test('draw() keeps an active custom renderer painting through an override-hide (feedBack#819)', () => {
|
||||
// The `_rendering` decision must distinguish a renderer-set override-hide
|
||||
// (setVisible(false) — canvas occluded by an opaque overlay, but the
|
||||
// active custom renderer still paints its own surface, e.g. Tab View's
|
||||
@@ -140,12 +140,12 @@ test('3D Highway subscribes to highway:visibility and toggles wrap on hide', ()
|
||||
// Listener registration with the documented event name (in init).
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/window\.slopsmith\.on\(\s*['"]highway:visibility['"]/,
|
||||
/window\.feedBack\.on\(\s*['"]highway:visibility['"]/,
|
||||
'initScene must subscribe to highway:visibility',
|
||||
);
|
||||
// Handler filters by canvas identity so splitscreen panels don't
|
||||
// hide each other's overlays — every instance receives every event
|
||||
// on the shared slopsmith bus, so this gate is essential.
|
||||
// on the shared feedBack bus, so this gate is essential.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/e\.detail\.canvas\s*!==\s*highwayCanvas/,
|
||||
@@ -170,7 +170,7 @@ test('3D Highway subscribes to highway:visibility and toggles wrap on hide', ()
|
||||
// context-type-driven canvas swap. Per CLAUDE.md plugin contract.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/window\.slopsmith\.on\(\s*['"]highway:canvas-replaced['"]/,
|
||||
/window\.feedBack\.on\(\s*['"]highway:canvas-replaced['"]/,
|
||||
'initScene must track canvas swaps so the visibility gate keeps matching',
|
||||
);
|
||||
assert.match(
|
||||
@@ -181,12 +181,12 @@ test('3D Highway subscribes to highway:visibility and toggles wrap on hide', ()
|
||||
// Teardown unbinds both listeners.
|
||||
assert.match(
|
||||
teardownBlock,
|
||||
/window\.slopsmith\.off\(\s*['"]highway:visibility['"]/,
|
||||
/window\.feedBack\.off\(\s*['"]highway:visibility['"]/,
|
||||
'teardown must unbind highway:visibility',
|
||||
);
|
||||
assert.match(
|
||||
teardownBlock,
|
||||
/window\.slopsmith\.off\(\s*['"]highway:canvas-replaced['"]/,
|
||||
/window\.feedBack\.off\(\s*['"]highway:canvas-replaced['"]/,
|
||||
'teardown must unbind highway:canvas-replaced',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -90,8 +90,8 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.jucePlayer = jucePlayer;
|
||||
sandbox.window.slopsmithDesktop = { audio: juceApi };
|
||||
sandbox.window.slopsmith = { audio: {} };
|
||||
sandbox.window.feedBackDesktop = { audio: juceApi };
|
||||
sandbox.window.feedBack = { audio: {} };
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const iife = extractWatcherIIFE(src);
|
||||
@@ -217,7 +217,7 @@ test('stale-abort during a swap-then-restore is NOT memoised as a JUCE reject',
|
||||
sb.window._currentSongAudio = snapA;
|
||||
|
||||
let firstLoad = true;
|
||||
sb.window.slopsmithDesktop.audio.loadBackingTrack = (p) => {
|
||||
sb.window.feedBackDesktop.audio.loadBackingTrack = (p) => {
|
||||
sb.__calls.loadBackingTrack.push(p);
|
||||
if (firstLoad) {
|
||||
firstLoad = false;
|
||||
@@ -353,7 +353,7 @@ test('song change mid-flight aborts the switch without mutating routing', async
|
||||
sb.window._currentSongAudio = original;
|
||||
// Swap the current song the moment loadBackingTrack is consulted, so the
|
||||
// post-await staleness check sees a different _currentSongAudio identity.
|
||||
sb.window.slopsmithDesktop.audio.loadBackingTrack = () => {
|
||||
sb.window.feedBackDesktop.audio.loadBackingTrack = () => {
|
||||
sb.window._currentSongAudio = { url: '/audio/song-b.ogg', juceEligible: true };
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const { loadAudioSession } = require('./audio_session_test_harness');
|
||||
|
||||
test('active audio domains expose expected legacy shim metadata', () => {
|
||||
const window = loadAudioSession();
|
||||
const shims = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims;
|
||||
const shims = window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims;
|
||||
for (const shimId of ['audio-mix.fader-registry', 'audio-mix.song-volume', 'audio-mix.analyser', 'audio-input.legacy-source', 'audio-monitoring.audio-barrier', 'stems.master-volume', 'stems.private-state']) {
|
||||
assert.equal(shims.some(shim => shim.shimId === shimId && shim.status === 'active'), true, shimId);
|
||||
}
|
||||
@@ -14,12 +14,12 @@ test('active audio domains expose expected legacy shim metadata', () => {
|
||||
|
||||
test('legacy bridge hit counts are attributed to canonical audio domains', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const audioSession = window.feedBack.audioSession;
|
||||
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.analyser', legacySurface: 'HTMLAudioElement analyser tap', participantId: 'highway_3d' });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect' });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.slopsmithAudioBarrier', participantId: 'note_detect' });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.feedBackAudioBarrier', participantId: 'note_detect' });
|
||||
|
||||
const shims = window.slopsmith.capabilities.snapshotDiagnostics().compatibilityShims;
|
||||
const shims = window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims;
|
||||
assert.equal(shims.find(shim => shim.shimId === 'audio-mix.analyser').hitCount, 1);
|
||||
assert.equal(shims.find(shim => shim.shimId === 'audio-input.legacy-source').capability, 'audio-input');
|
||||
assert.equal(shims.find(shim => shim.shimId === 'audio-monitoring.audio-barrier').hitCount, 1);
|
||||
@@ -30,9 +30,9 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
||||
const window = loadAudioSession();
|
||||
installMixerDom(window);
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
window.feedBack.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
|
||||
window.slopsmith.audio.registerFader({
|
||||
window.feedBack.audio.registerFader({
|
||||
id: 'delay.wet',
|
||||
label: 'Delay Wet Legacy',
|
||||
min: 0,
|
||||
@@ -43,7 +43,7 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
||||
getValue: () => 0.2,
|
||||
setValue: () => {},
|
||||
});
|
||||
window.slopsmith.audioSession.registerMixParticipant({
|
||||
window.feedBack.audioSession.registerMixParticipant({
|
||||
participantId: 'plugin.delay.native',
|
||||
ownerPluginId: 'delay',
|
||||
label: 'Delay Wet',
|
||||
@@ -54,8 +54,8 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
});
|
||||
|
||||
const listed = await window.slopsmith.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
const listed = await window.feedBack.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
const snapshot = window.feedBack.audioSession.snapshot();
|
||||
const legacy = snapshot.domains['audio-mix'].participants.find(participant => participant.participantId === 'fader.delay.wet');
|
||||
|
||||
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'plugin.delay.native'), true);
|
||||
@@ -86,8 +86,8 @@ function region(src, needle, length = 1200) {
|
||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||
const src = source(APP_JS);
|
||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||
assert.match(block, /window\.slopsmith\._loadingPluginId\s*=\s*plugin\.id/);
|
||||
assert.match(block, /delete\s+window\.slopsmith\._loadingPluginId/);
|
||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||
});
|
||||
|
||||
test('library providers route through native library capability', () => {
|
||||
@@ -100,7 +100,7 @@ test('library providers route through native library capability', () => {
|
||||
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
|
||||
assert.match(librarySrc, /kind:\s*['"]provider-coordinator['"]/);
|
||||
assert.match(librarySrc, /'library\.read': \['query-page', 'query-artists', 'query-stats', 'tuning-names'\]/);
|
||||
assert.match(librarySrc, /window\.slopsmith\.libraryProviders\s*=\s*providerApi/);
|
||||
assert.match(librarySrc, /window\.feedBack\.libraryProviders\s*=\s*providerApi/);
|
||||
assert.match(loader, /api\.refresh\(\{ restoreSaved \}\)/);
|
||||
assert.match(selector, /capabilityApi\.command\(['"]library['"],\s*['"]select-provider['"]/);
|
||||
assert.match(sync, /capabilityApi\.command\(['"]library['"],\s*['"]sync-song['"]/);
|
||||
|
||||
@@ -28,7 +28,7 @@ test('shouldSuppressMonitorMuteHint only for external modes', () => {
|
||||
});
|
||||
|
||||
test('labels include internal, external, and spark options', () => {
|
||||
assert.match(toneSource.LABELS.internal, /feed\[dB\]ack internal tone/i);
|
||||
assert.match(toneSource.LABELS.internal, /fee\[dB\]ack internal tone/i);
|
||||
assert.match(toneSource.LABELS.external_hardware, /External amp/i);
|
||||
assert.match(toneSource.LABELS.spark_control_x, /Spark LIVE/i);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,64 @@ test('reset counters via bindRuntime song lifecycle', () => {
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
});
|
||||
|
||||
test('backward song:seek rebuilds the tally to the new position', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
|
||||
// Notes judged at t = 1..5 (miss at t=4), each carried on the event detail.
|
||||
sm.emit('note:hit', { noteTime: 1 });
|
||||
sm.emit('note:hit', { noteTime: 2 });
|
||||
sm.emit('note:hit', { noteTime: 3 });
|
||||
sm.emit('note:miss', { noteTime: 4 });
|
||||
sm.emit('note:hit', { noteTime: 5 });
|
||||
assert.equal(runtime.getCounters().hits, 4);
|
||||
assert.equal(runtime.getCounters().misses, 1);
|
||||
|
||||
// Restart-style backward seek to t=3 → keep only t=1,2 (both hits).
|
||||
sm.emit('song:seek', { from: 5, to: 3, reason: 'song-restart' });
|
||||
assert.equal(runtime.getCounters().hits, 2);
|
||||
assert.equal(runtime.getCounters().misses, 0);
|
||||
assert.equal(runtime.getCounters().streak, 2);
|
||||
|
||||
// Restart to the very top → 0 notes.
|
||||
sm.emit('song:seek', { from: 3, to: 0, reason: 'song-restart' });
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
});
|
||||
|
||||
test('a FORWARD song:seek does not roll back the tally', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
sm.emit('note:hit', { noteTime: 1 });
|
||||
sm.emit('note:hit', { noteTime: 2 });
|
||||
sm.emit('song:seek', { from: 2, to: 30, reason: 'seek-by' });
|
||||
assert.equal(runtime.getCounters().hits, 2, 'forward seek keeps earlier hits');
|
||||
});
|
||||
|
||||
test('loop-wrap seek is ignored (drill mode keeps accumulating)', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); },
|
||||
emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); },
|
||||
};
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
sm.emit('note:hit', { noteTime: 11 });
|
||||
sm.emit('note:hit', { noteTime: 12 });
|
||||
// A-B drill loop wraps backward to loopA — must NOT reset the tally.
|
||||
sm.emit('song:seek', { from: 12, to: 10, reason: 'loop-wrap' });
|
||||
assert.equal(runtime.getCounters().hits, 2, 'loop-wrap leaves the cumulative tally intact');
|
||||
});
|
||||
|
||||
test('DOM text updates after hit and miss events', () => {
|
||||
class El {
|
||||
constructor(id) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Verify the plugin-facing loop API: setLoop / clearLoop / getLoop on
|
||||
// window.slopsmith, the input validation in setLoop, and the
|
||||
// window.feedBack, the input validation in setLoop, and the
|
||||
// loadSavedLoop refactor that funnels through setLoop.
|
||||
//
|
||||
// Same isolation strategy as loop_restart.test.js — extract relevant
|
||||
@@ -82,7 +82,7 @@ function buildSandbox() {
|
||||
// assert on the label text in these tests, so a stub is enough.
|
||||
formatTime: (s) => String(s),
|
||||
window: {
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
playback: {
|
||||
transportEvent: (...args) => transportEvents.push(args),
|
||||
},
|
||||
@@ -236,18 +236,18 @@ test('loop helpers emit transport snapshots by default and can suppress adapter
|
||||
assert.equal(sandbox.transportEvents.length, 0);
|
||||
});
|
||||
|
||||
test('window.slopsmith API surface declares setLoop/clearLoop/getLoop', () => {
|
||||
test('window.feedBack API surface declares setLoop/clearLoop/getLoop', () => {
|
||||
// Source-level assertion: the plugin-facing namespace must expose
|
||||
// these three methods. Catches a future contributor moving them or
|
||||
// renaming silently.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// Find the slopsmith Object.assign block and check method presence.
|
||||
const m = src.match(/window\.slopsmith\s*=\s*Object\.assign\(_slopsmithBus,\s*\{([\s\S]*?)\}\);\s*if \(_slopsmithExisting/);
|
||||
assert.ok(m, 'slopsmith Object.assign block not found');
|
||||
// Find the feedBack Object.assign block and check method presence.
|
||||
const m = src.match(/window\.feedBack\s*=\s*Object\.assign\(_feedBackBus,\s*\{([\s\S]*?)\}\);\s*if \(_feedBackExisting/);
|
||||
assert.ok(m, 'feedBack Object.assign block not found');
|
||||
const block = m[1];
|
||||
assert.match(block, /setLoop\s*\(/, 'setLoop method missing from slopsmith API');
|
||||
assert.match(block, /clearLoop\s*\(/, 'clearLoop method missing from slopsmith API');
|
||||
assert.match(block, /getLoop\s*\(/, 'getLoop method missing from slopsmith API');
|
||||
assert.match(block, /setLoop\s*\(/, 'setLoop method missing from feedBack API');
|
||||
assert.match(block, /clearLoop\s*\(/, 'clearLoop method missing from feedBack API');
|
||||
assert.match(block, /getLoop\s*\(/, 'getLoop method missing from feedBack API');
|
||||
});
|
||||
|
||||
test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ function buildSandbox() {
|
||||
// we don't need them to fire — the emit happens before beginCount.
|
||||
setTimeout: () => 0,
|
||||
|
||||
// Stubbed slopsmith DOM dependencies.
|
||||
// Stubbed feedBack DOM dependencies.
|
||||
audio: { pause() {} },
|
||||
jucePlayer: { pause: () => Promise.resolve(), play: () => Promise.resolve(true) },
|
||||
highway: { setTime() {}, getBPM: () => 120 },
|
||||
@@ -98,7 +98,7 @@ function buildSandbox() {
|
||||
|
||||
// Spy: records every emit call so the test can assert.
|
||||
window: {
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
emit(event, detail) { emitCalls.push({ event, detail }); },
|
||||
isPlaying: false,
|
||||
},
|
||||
@@ -121,7 +121,7 @@ test('loop:restart fires once when wrap path runs', async () => {
|
||||
// accidental revert before we even run the behavior assertion.
|
||||
assert.match(
|
||||
startCountInSrc,
|
||||
/window\.slopsmith\.emit\(\s*['"]loop:restart['"]/,
|
||||
/window\.feedBack\.emit\(\s*['"]loop:restart['"]/,
|
||||
'startCountIn is missing the loop:restart emit',
|
||||
);
|
||||
|
||||
@@ -229,7 +229,7 @@ test('loop:restart fires after highway.setTime, before beginCount', () => {
|
||||
const setTimeIdx = setTimeMatches.length
|
||||
? wrapStart + setTimeMatches[setTimeMatches.length - 1].index
|
||||
: -1;
|
||||
const emitRel = wrapSlice.search(/window\.slopsmith\.emit\(\s*['"]loop:restart['"]/);
|
||||
const emitRel = wrapSlice.search(/window\.feedBack\.emit\(\s*['"]loop:restart['"]/);
|
||||
const emitIdx = emitRel === -1 ? -1 : wrapStart + emitRel;
|
||||
const afterEmit = emitIdx === -1 ? '' : fn.slice(emitIdx);
|
||||
const beginCallMatch = afterEmit.match(/(?<!function\s)\bbeginCount\s*\(/);
|
||||
|
||||
@@ -19,7 +19,7 @@ function loadMidiInput(options = {}) {
|
||||
// A fake provider whose enumerate/open/close are observable by the test.
|
||||
function fakeProvider(window, overrides = {}) {
|
||||
const calls = { enumerate: 0, open: [], close: [] };
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
window.feedBack.midiInput.registerProvider({
|
||||
providerId: 'web-midi',
|
||||
label: 'Web MIDI',
|
||||
participantId: 'input_setup',
|
||||
@@ -33,7 +33,7 @@ function fakeProvider(window, overrides = {}) {
|
||||
|
||||
test('midi-input registers an active sensitive provider-coordinator', () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const pipeline = api.inspect('midi-input');
|
||||
assert.ok(pipeline, 'midi-input pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.midi-input');
|
||||
@@ -43,12 +43,12 @@ test('midi-input registers an active sensitive provider-coordinator', () => {
|
||||
for (const cmd of ['inspect', 'list-sources', 'discover', 'select-source', 'open-source', 'close-source']) {
|
||||
assert.ok(owner.commands.includes(cmd), `owner exposes ${cmd}`);
|
||||
}
|
||||
assert.equal(window.slopsmith.midiInput.version, 1);
|
||||
assert.equal(window.feedBack.midiInput.version, 1);
|
||||
});
|
||||
|
||||
test('list-sources and select-source are prompt-free (never enumerate)', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
const listed = await api.dispatch({ capability: 'midi-input', command: 'list-sources', source: 'tester' });
|
||||
assert.equal(listed.outcome, 'handled');
|
||||
@@ -57,12 +57,12 @@ test('list-sources and select-source are prompt-free (never enumerate)', async (
|
||||
|
||||
test('discover is the permission boundary and surfaces sources', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'handled');
|
||||
assert.equal(calls.enumerate, 1, 'discover requests MIDI access exactly once');
|
||||
const sources = window.slopsmith.midiInput.listSources();
|
||||
const sources = window.feedBack.midiInput.listSources();
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0].logicalSourceKey, 'web-midi::dev1');
|
||||
assert.equal(sources[0].kind, 'midi');
|
||||
@@ -71,31 +71,31 @@ test('discover is the permission boundary and surfaces sources', async () => {
|
||||
test('re-discovery drops sources for devices that vanished', async () => {
|
||||
const window = loadMidiInput();
|
||||
let devices = [{ sourceId: 'dev1', label: 'A' }, { sourceId: 'dev2', label: 'B' }];
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
window.feedBack.midiInput.registerProvider({
|
||||
providerId: 'web-midi', label: 'Web MIDI',
|
||||
enumerate: async () => devices,
|
||||
open: async () => ({ addListener() {}, removeListener() {} }),
|
||||
close: () => {},
|
||||
});
|
||||
await window.slopsmith.midiInput.discover();
|
||||
assert.equal(window.slopsmith.midiInput.listSources().length, 2);
|
||||
await window.feedBack.midiInput.discover();
|
||||
assert.equal(window.feedBack.midiInput.listSources().length, 2);
|
||||
devices = [{ sourceId: 'dev1', label: 'A' }]; // dev2 unplugged
|
||||
await window.slopsmith.midiInput.discover();
|
||||
const keys = window.slopsmith.midiInput.listSources().map((s) => s.logicalSourceKey);
|
||||
await window.feedBack.midiInput.discover();
|
||||
const keys = window.feedBack.midiInput.listSources().map((s) => s.logicalSourceKey);
|
||||
assert.equal(keys.length, 1, 'vanished device is dropped from the source list');
|
||||
assert.equal(keys[0], 'web-midi::dev1');
|
||||
});
|
||||
|
||||
test('discover with no provider reports unavailable', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'unavailable');
|
||||
});
|
||||
|
||||
test('discover surfaces denied when MIDI access is rejected', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
fakeProvider(window, { handlers: { enumerate: async () => { throw new Error('SecurityError: permission denied'); } } });
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'denied');
|
||||
@@ -104,21 +104,21 @@ test('discover surfaces denied when MIDI access is rejected', async () => {
|
||||
|
||||
test('select-source persists by logicalSourceKey', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
fakeProvider(window);
|
||||
await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
const sel = await api.dispatch({ capability: 'midi-input', command: 'select-source', source: 'tester', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(window.__storage.get('slopsmith.midiInput.selectedLogicalSourceKey'), 'web-midi::dev1');
|
||||
assert.ok(window.slopsmith.midiInput.listSources()[0].selected);
|
||||
assert.equal(window.__storage.get('feedBack.midiInput.selectedLogicalSourceKey'), 'web-midi::dev1');
|
||||
assert.ok(window.feedBack.midiInput.listSources()[0].selected);
|
||||
});
|
||||
|
||||
test('open/close share one session and release on the last requester', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const calls = fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
await window.feedBack.midiInput.discover();
|
||||
await window.feedBack.midiInput.select('web-midi::dev1');
|
||||
const a = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
const b = await api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
assert.equal(a.outcome, 'handled');
|
||||
@@ -133,20 +133,20 @@ test('open/close share one session and release on the last requester', async ()
|
||||
|
||||
test('concurrent opens for one source coalesce onto a single provider.open', async () => {
|
||||
const window = loadMidiInput();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
// A provider whose open() stays pending until we release it, so both
|
||||
// dispatches are genuinely in flight at the same time.
|
||||
let release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const calls = { open: 0, close: 0 };
|
||||
window.slopsmith.midiInput.registerProvider({
|
||||
window.feedBack.midiInput.registerProvider({
|
||||
providerId: 'web-midi', label: 'Web MIDI',
|
||||
enumerate: async () => [{ sourceId: 'dev1', label: 'My Keyboard' }],
|
||||
open: async () => { calls.open += 1; await gate; return { addListener() {}, removeListener() {} }; },
|
||||
close: () => { calls.close += 1; },
|
||||
});
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
await window.feedBack.midiInput.discover();
|
||||
await window.feedBack.midiInput.select('web-midi::dev1');
|
||||
const p1 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqA', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
const p2 = api.dispatch({ capability: 'midi-input', command: 'open-source', source: 'reqB', payload: { logicalSourceKey: 'web-midi::dev1' } });
|
||||
release();
|
||||
@@ -165,9 +165,9 @@ test('concurrent opens for one source coalesce onto a single provider.open', asy
|
||||
test('public open() surfaces the live handle (in-page only)', async () => {
|
||||
const window = loadMidiInput();
|
||||
fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
await window.slopsmith.midiInput.select('web-midi::dev1');
|
||||
const res = await window.slopsmith.midiInput.open({ requester: 'input_setup', logicalSourceKey: 'web-midi::dev1' });
|
||||
await window.feedBack.midiInput.discover();
|
||||
await window.feedBack.midiInput.select('web-midi::dev1');
|
||||
const res = await window.feedBack.midiInput.open({ requester: 'input_setup', logicalSourceKey: 'web-midi::dev1' });
|
||||
assert.equal(res.outcome, 'handled');
|
||||
assert.ok(res.handle && typeof res.handle.addListener === 'function', 'live handle exposed via public global');
|
||||
});
|
||||
@@ -193,12 +193,12 @@ test('built-in Web-MIDI provider self-registers + discovers, filtering loopback
|
||||
{ id: 'kb1', name: 'My Keyboard' },
|
||||
{ id: 'thru', name: 'Midi Through Port-0' }, // loopback → filtered out
|
||||
]);
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
assert.ok(api.inspect('midi-input').participants.some(p => p.pluginId === 'core.midi-input'),
|
||||
'built-in provider registered without any plugin');
|
||||
const r = await api.dispatch({ capability: 'midi-input', command: 'discover', source: 'tester' });
|
||||
assert.equal(r.outcome, 'handled');
|
||||
const sources = window.slopsmith.midiInput.listSources();
|
||||
const sources = window.feedBack.midiInput.listSources();
|
||||
assert.equal(sources.length, 1, 'loopback/passthrough ports are filtered');
|
||||
assert.equal(sources[0].logicalSourceKey, 'web-midi::kb1');
|
||||
});
|
||||
@@ -206,10 +206,10 @@ test('built-in Web-MIDI provider self-registers + discovers, filtering loopback
|
||||
test('diagnostics are redaction-safe (no device labels, no raw messages)', async () => {
|
||||
const window = loadMidiInput();
|
||||
fakeProvider(window);
|
||||
await window.slopsmith.midiInput.discover();
|
||||
const contrib = window.slopsmith.diagnostics.snapshotContributions()['midi-input-capability'];
|
||||
await window.feedBack.midiInput.discover();
|
||||
const contrib = window.feedBack.diagnostics.snapshotContributions()['midi-input-capability'];
|
||||
assert.ok(contrib, 'midi-input contributes diagnostics');
|
||||
assert.equal(contrib.schema, 'slopsmith.midi_input.diagnostics.v1');
|
||||
assert.equal(contrib.schema, 'feedBack.midi_input.diagnostics.v1');
|
||||
const serialized = JSON.stringify(contrib);
|
||||
assert.ok(!serialized.includes('My Keyboard'), 'device labels are redacted from diagnostics');
|
||||
for (const s of contrib.sources) assert.ok(!('label' in s), 'source entries carry no label');
|
||||
|
||||
@@ -34,19 +34,19 @@ async function registerMidiProvider(api) {
|
||||
|
||||
test('note-detection domain registers an active sensitive provider-coordinator', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const pipeline = api.inspect('note-detection');
|
||||
assert.ok(pipeline, 'note-detection pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.note-detection');
|
||||
assert.ok(owner, 'core.note-detection owner registered');
|
||||
assert.equal(owner.safety, 'sensitive');
|
||||
assert.ok(owner.commands.includes('open-binding'));
|
||||
assert.equal(window.slopsmith.noteDetection.version, 1);
|
||||
assert.equal(window.feedBack.noteDetection.version, 1);
|
||||
});
|
||||
|
||||
test('open-binding without a provider reports unavailable, never a silent verdict', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'keys_highway_3d', payload: { context: { arrangement: 'keys' } },
|
||||
@@ -57,7 +57,7 @@ test('open-binding without a provider reports unavailable, never a silent verdic
|
||||
|
||||
test('provider registration + binding lifecycle with per-binding context', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, [
|
||||
'note-detection:provider-registered',
|
||||
'note-detection:binding-opened',
|
||||
@@ -104,7 +104,7 @@ test('provider registration + binding lifecycle with per-binding context', async
|
||||
|
||||
test('concurrent bindings keep independent contexts (FR-003)', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const a = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
@@ -114,7 +114,7 @@ test('concurrent bindings keep independent contexts (FR-003)', async () => {
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'slopscale', payload: { context: { arrangement: 'bass', stringCount: 4, capo: 0 } },
|
||||
});
|
||||
const snapshot = window.slopsmith.noteDetection.snapshot();
|
||||
const snapshot = window.feedBack.noteDetection.snapshot();
|
||||
const ctxA = snapshot.bindings.find(x => x.id === a.payload.bindingId).context;
|
||||
const ctxB = snapshot.bindings.find(x => x.id === b.payload.bindingId).context;
|
||||
assert.equal(ctxA.arrangement, 'guitar');
|
||||
@@ -125,18 +125,18 @@ test('concurrent bindings keep independent contexts (FR-003)', async () => {
|
||||
|
||||
test('unregistering a provider closes its bindings and flips availability', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, ['note-detection:availability-changed', 'note-detection:binding-closed']);
|
||||
await registerMidiProvider(api);
|
||||
const openResult = await api.dispatch({ capability: 'note-detection', command: 'open-binding', source: 'keys_highway_3d', payload: {} });
|
||||
assert.equal(openResult.outcome, 'handled');
|
||||
assert.equal(window.slopsmith.noteDetection.snapshot().bindings.length, 1);
|
||||
assert.equal(window.feedBack.noteDetection.snapshot().bindings.length, 1);
|
||||
const result = await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
|
||||
});
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(window.slopsmith.noteDetection.snapshot().bindings.length, 0);
|
||||
assert.equal(window.feedBack.noteDetection.snapshot().bindings.length, 0);
|
||||
const availability = events.filter(e => e.event === 'availability-changed').map(e => e.payload.available);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(availability)), [true, false]);
|
||||
assert.ok(events.some(e => e.event === 'binding-closed' && e.payload.reason === 'provider-unregistered'));
|
||||
@@ -149,10 +149,10 @@ test('unregistering a provider closes its bindings and flips availability', asyn
|
||||
|
||||
test('hit/miss reports flow as observability events with bounded fields', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, ['note-detection:hit', 'note-detection:miss']);
|
||||
window.slopsmith.noteDetection.reportHit({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 64, hit: true, secretDevice: 'Yamaha P-125' });
|
||||
window.slopsmith.noteDetection.reportMiss({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 65, hit: false });
|
||||
window.feedBack.noteDetection.reportHit({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 64, hit: true, secretDevice: 'Yamaha P-125' });
|
||||
window.feedBack.noteDetection.reportMiss({ bindingId: 'ndb-1', providerId: 'keys-midi', midi: 65, hit: false });
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0].payload.midi, 64);
|
||||
// Unknown fields are dropped — payloads stay bounded and device-label free.
|
||||
@@ -162,27 +162,27 @@ test('hit/miss reports flow as observability events with bounded fields', () =>
|
||||
|
||||
test('diagnostics contribution is redaction-safe', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'keys_highway_3d', payload: { context: { arrangement: 'keys', deviceLabel: 'Yamaha P-125' } },
|
||||
});
|
||||
window.slopsmith.noteDetection.reportHit({ bindingId: 'ndb-1', midi: 60, hit: true });
|
||||
window.feedBack.noteDetection.reportHit({ bindingId: 'ndb-1', midi: 60, hit: true });
|
||||
const contribution = window.__diagnosticsContributions.get('note-detection-capability');
|
||||
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
|
||||
assert.equal(contribution.schema, 'feedBack.note_detection_capability.v1');
|
||||
const serialized = JSON.stringify(contribution);
|
||||
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.archive/i.test(serialized), serialized);
|
||||
});
|
||||
|
||||
test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
// Simulate highway.js arriving after the host, then the notedetect
|
||||
// plugin installing its chart-coupled provider.
|
||||
let installed = null;
|
||||
window.highway = { setNoteStateProvider(fn) { installed = fn; } };
|
||||
window.slopsmith.emit('song:loaded', {});
|
||||
window.feedBack.emit('song:loaded', {});
|
||||
const provider = () => ({ state: 'hit' });
|
||||
window.highway.setNoteStateProvider(provider);
|
||||
assert.equal(installed, provider, 'legacy behavior preserved');
|
||||
@@ -196,7 +196,7 @@ test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
|
||||
|
||||
test('unsupported binding/provider ids degrade with bounded reasons (FR-008)', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const badProvider = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
@@ -212,7 +212,7 @@ test('unsupported binding/provider ids degrade with bounded reasons (FR-008)', a
|
||||
|
||||
test('_contextSummary whitelists arrangement kind — unknown values are dropped', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
// Known arrangement kinds pass through.
|
||||
const open = await api.dispatch({
|
||||
@@ -220,7 +220,7 @@ test('_contextSummary whitelists arrangement kind — unknown values are dropped
|
||||
source: 'caller', payload: { context: { arrangement: 'keys', stringCount: 6 } },
|
||||
});
|
||||
assert.equal(open.outcome, 'handled');
|
||||
const snap = window.slopsmith.noteDetection.snapshot();
|
||||
const snap = window.feedBack.noteDetection.snapshot();
|
||||
const ctx = snap.bindings.find(b => b.id === open.payload.bindingId).context;
|
||||
assert.equal(ctx.arrangement, 'keys');
|
||||
|
||||
@@ -230,28 +230,28 @@ test('_contextSummary whitelists arrangement kind — unknown values are dropped
|
||||
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.archive' } },
|
||||
});
|
||||
assert.equal(open2.outcome, 'handled');
|
||||
const snap2 = window.slopsmith.noteDetection.snapshot();
|
||||
const snap2 = window.feedBack.noteDetection.snapshot();
|
||||
const ctx2 = snap2.bindings.find(b => b.id === open2.payload.bindingId).context;
|
||||
assert.equal(ctx2.arrangement, undefined, 'path-bearing arrangement must be dropped');
|
||||
});
|
||||
|
||||
test('snapshot primitives are deep-copied — caller cannot mutate provider internals', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const snap1 = window.slopsmith.noteDetection.snapshot();
|
||||
const snap1 = window.feedBack.noteDetection.snapshot();
|
||||
const providerEntry = snap1.providers.find(p => p.id === 'keys-midi');
|
||||
assert.ok(Array.isArray(providerEntry.primitives));
|
||||
// Mutate the copy — must not affect subsequent snapshots.
|
||||
providerEntry.primitives.push('injected');
|
||||
const snap2 = window.slopsmith.noteDetection.snapshot();
|
||||
const snap2 = window.feedBack.noteDetection.snapshot();
|
||||
const providerEntry2 = snap2.providers.find(p => p.id === 'keys-midi');
|
||||
assert.ok(!providerEntry2.primitives.includes('injected'), 'live primitives must not be mutated via snapshot');
|
||||
});
|
||||
|
||||
test('close-binding and set-target enforce requester ownership', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const open = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
@@ -283,7 +283,7 @@ test('close-binding and set-target enforce requester ownership', async () => {
|
||||
|
||||
test('register-provider rejects cross-owner re-registration', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
// First registration by plugin-a.
|
||||
const first = await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
@@ -311,7 +311,7 @@ test('register-provider rejects cross-owner re-registration', async () => {
|
||||
|
||||
test('unregister-provider rejects cross-owner unregister', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
|
||||
// A different participant must not unregister a provider it does not own.
|
||||
@@ -322,7 +322,7 @@ test('unregister-provider rejects cross-owner unregister', async () => {
|
||||
assert.equal(steal.outcome, 'degraded', 'cross-owner unregister must be rejected');
|
||||
|
||||
// Provider must still be present after the failed cross-owner attempt.
|
||||
const snap = window.slopsmith.noteDetection.snapshot();
|
||||
const snap = window.feedBack.noteDetection.snapshot();
|
||||
assert.ok(snap.providers.some(p => p.id === 'keys-midi'), 'provider must survive cross-owner unregister attempt');
|
||||
|
||||
// The original owner can still unregister.
|
||||
@@ -335,7 +335,7 @@ test('unregister-provider rejects cross-owner unregister', async () => {
|
||||
|
||||
test('availability-changed fires only on 0→1 and 1→0 transitions', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, ['note-detection:availability-changed']);
|
||||
|
||||
// First registration: 0→1, should emit.
|
||||
@@ -364,7 +364,7 @@ test('availability-changed fires only on 0→1 and 1→0 transitions', async ()
|
||||
|
||||
test('unregistering one of two providers from the same participant keeps participant in the pipeline', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
// One plugin registers two providers under the same participantId.
|
||||
await api.dispatch({
|
||||
@@ -386,7 +386,7 @@ test('unregistering one of two providers from the same participant keeps partici
|
||||
});
|
||||
assert.equal(unreg.outcome, 'handled');
|
||||
|
||||
const snap = window.slopsmith.noteDetection.snapshot();
|
||||
const snap = window.feedBack.noteDetection.snapshot();
|
||||
assert.ok(!snap.providers.some(p => p.id === 'multi-provider-a'), 'provider-a must be removed');
|
||||
assert.ok(snap.providers.some(p => p.id === 'multi-provider-b'), 'provider-b must still be present');
|
||||
const participants = api.inspect('note-detection').participants || [];
|
||||
|
||||
@@ -21,7 +21,7 @@ function loadCables(opts) {
|
||||
opts = opts || {};
|
||||
const rafCalls = [];
|
||||
const created = []; // every createElementNS node
|
||||
const win = { slopsmith: null };
|
||||
const win = { feedBack: null };
|
||||
win.addEventListener = () => {};
|
||||
win.matchMedia = () => ({ matches: !!opts.reduce });
|
||||
const doc = {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Regression: the play/pause button must not be reset to "Play" when an
|
||||
// in-flight togglePlay() audio.play() is rejected *because the engine reroute
|
||||
// (HTML5 -> JUCE) deliberately paused the <audio> element*. Playback continues
|
||||
// on the JUCE transport, so the button must stay "Pause" (isPlaying true).
|
||||
//
|
||||
// Bug: first song after a fresh load on desktop — the reroute's audio.pause()
|
||||
// aborts autoplay's play(); togglePlay's catch then flipped the button to Play
|
||||
// while the song kept playing, so it took two clicks to actually pause.
|
||||
//
|
||||
// Same isolation strategy as autoplay_exit.test.js: extract togglePlay() from
|
||||
// app.js by brace-matching and run it in a vm sandbox with stubbed deps.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
|
||||
|
||||
// Drive togglePlay() from the not-playing state with an HTML5 audio.play() that
|
||||
// rejects, optionally with a reroute in progress. Returns the observed button
|
||||
// states and the final isPlaying flag.
|
||||
async function runTogglePlayRejecting({ rerouteInProgress }) {
|
||||
const buttonStates = [];
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
// not-playing -> togglePlay takes the HTML5 play branch
|
||||
isPlaying: false,
|
||||
_audioSeekGen: 0,
|
||||
_playAttemptGen: 0,
|
||||
setPlayButtonState(v) { buttonStates.push(v); },
|
||||
audio: {
|
||||
// Reject like the browser does when a pending play() is interrupted
|
||||
// by a pause() (the reroute's deliberate audio.pause()).
|
||||
play: () => Promise.reject(new DOMException('aborted by pause', 'AbortError')),
|
||||
pause() {},
|
||||
},
|
||||
jucePlayer: { play: () => Promise.resolve(true), pause: () => Promise.resolve() },
|
||||
window: {
|
||||
_juceMode: false,
|
||||
_juceRerouteInProgress: rerouteInProgress ? 1 : 0,
|
||||
feedBack: { isPlaying: false, emit() {} },
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
|
||||
await vm.runInContext('togglePlay()', sandbox);
|
||||
return { buttonStates, isPlaying: sandbox.isPlaying };
|
||||
}
|
||||
|
||||
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
|
||||
const { buttonStates, isPlaying } = await runTogglePlayRejecting({ rerouteInProgress: true });
|
||||
// Optimistic flip to Pause happened; the reroute guard must prevent the
|
||||
// catch from flipping it back to Play.
|
||||
assert.deepEqual(buttonStates, [true], 'button should only have been set to Pause, never reset to Play');
|
||||
assert.equal(isPlaying, true, 'isPlaying must stay true — the JUCE transport owns playback');
|
||||
});
|
||||
|
||||
test('a genuine play() rejection (no reroute) still resets the button to Play', async () => {
|
||||
const { buttonStates, isPlaying } = await runTogglePlayRejecting({ rerouteInProgress: false });
|
||||
assert.deepEqual(buttonStates, [true, false], 'button set to Pause then correctly reset to Play on real failure');
|
||||
assert.equal(isPlaying, false, 'isPlaying must reflect the failed start');
|
||||
});
|
||||
@@ -26,7 +26,7 @@ function buildReadySandbox() {
|
||||
const listeners = new Map();
|
||||
const sandbox = {
|
||||
window: {
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
on(event, fn) { listeners.set(event, fn); },
|
||||
off(event, fn) { if (listeners.get(event) === fn) listeners.delete(event); },
|
||||
},
|
||||
@@ -84,5 +84,5 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.slopsmith\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||
});
|
||||
|
||||
@@ -25,14 +25,14 @@ test('plugin fresh starts require user action and incompatible playback particip
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities', 'unsupported_capability_version.json'), 'utf8'));
|
||||
fixture.id = 'future_playback';
|
||||
fixture.capabilities = { playback: { roles: ['owner'], commands: ['inspect'], runtime: true, version: 999, handlers: { inspect: () => ({ outcome: 'handled' }) } } };
|
||||
incompatibleWindow.slopsmith.capabilities.registerParticipants([fixture]);
|
||||
const result = await incompatibleWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
incompatibleWindow.feedBack.capabilities.registerParticipants([fixture]);
|
||||
const result = await incompatibleWindow.feedBack.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
assert.equal(result.status, 'incompatible-version');
|
||||
});
|
||||
|
||||
test('same-priority latest controls remain non-stale while user-priority commands deny background automation', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const firstPause = await dispatch(window, 'pause', { requesterId: 'plugin.a', priority: 'normal' });
|
||||
const normalResume = await dispatch(window, 'resume', { requesterId: 'plugin.b', priority: 'normal' });
|
||||
@@ -48,7 +48,7 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
|
||||
const window = loadPlayback();
|
||||
const bridgeEvents = captureEvents(window, 'playback:bridge-hit');
|
||||
|
||||
window.slopsmith.playback.recordBridgeHit({
|
||||
window.feedBack.playback.recordBridgeHit({
|
||||
bridgeId: 'playback.window-play-song',
|
||||
legacySurface: 'window.playSong',
|
||||
source: 'core.app',
|
||||
@@ -56,7 +56,7 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
|
||||
});
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
const runtime = window.slopsmith.capabilities.snapshotDiagnostics();
|
||||
const runtime = window.feedBack.capabilities.snapshotDiagnostics();
|
||||
const shim = runtime.compatibilityShims.find(item => item.capability === 'playback' && item.legacySurface === 'window.playSong');
|
||||
|
||||
assert.equal(bridgeEvents.length, 1);
|
||||
@@ -68,10 +68,10 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
|
||||
|
||||
test('legacy song events update playback state without exposing raw filenames', () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.archive', arrangement: 0 });
|
||||
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.archive' }));
|
||||
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
|
||||
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
|
||||
window.feedBack.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.archive', arrangement: 0 });
|
||||
window.feedBack.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.archive' }));
|
||||
window.feedBack.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
|
||||
window.feedBack.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(playback);
|
||||
@@ -89,8 +89,8 @@ test('route changes are captured as redaction-safe playback lifecycle events', (
|
||||
const changing = captureEvents(window, 'playback:route-changing');
|
||||
const changed = captureEvents(window, 'playback:route-changed');
|
||||
|
||||
window.slopsmith.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'switching', preservedTime: true, safeReason: 'desktop engine active' });
|
||||
window.slopsmith.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'active', preservedTime: true, safeReason: 'desktop route active' });
|
||||
window.feedBack.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'switching', preservedTime: true, safeReason: 'desktop engine active' });
|
||||
window.feedBack.playback.recordRouteChange({ routeKind: 'desktop-native', state: 'active', preservedTime: true, safeReason: 'desktop route active' });
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
assert.equal(changing.length, 1);
|
||||
|
||||
@@ -4,7 +4,7 @@ const { loadPlayback, captureEvents, dispatch, diagnosticsSnapshot, makeTarget,
|
||||
|
||||
test('exported diagnostics pseudonymize targets while local inspector may show display names', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', {
|
||||
authorization: 'user-action',
|
||||
requesterId: 'core.player.controls',
|
||||
@@ -33,7 +33,7 @@ test('exported diagnostics pseudonymize targets while local inspector may show d
|
||||
|
||||
test('diagnostic history is bounded for current and stopped sessions', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.archive`, title: `Song ${index}` }) });
|
||||
@@ -55,11 +55,11 @@ test('diagnostic history is bounded for current and stopped sessions', async ()
|
||||
|
||||
test('diagnostics contribution is exported under playback schema', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
|
||||
const contribution = window.slopsmith.diagnostics.snapshotContributions().playback;
|
||||
assert.equal(contribution.schema, 'slopsmith.playback.diagnostics.v1');
|
||||
const contribution = window.feedBack.diagnostics.snapshotContributions().playback;
|
||||
assert.equal(contribution.schema, 'feedBack.playback.diagnostics.v1');
|
||||
assert.equal(contribution.domain, 'playback');
|
||||
assert.equal(contribution.exportMode, 'exported');
|
||||
assert.match(contribution.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
@@ -68,11 +68,11 @@ test('diagnostics contribution is exported under playback schema', async () => {
|
||||
|
||||
test('diagnostics redact caller-supplied route and stale session ids', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
|
||||
await dispatch(window, 'pause', { sessionId: '/Users/example/private-session?token=secret' }, 'plugin.remote');
|
||||
window.slopsmith.playback.recordRouteChange({ routeId: '/Users/example/native-route?token=secret', routeKind: 'desktop-native', state: 'active', safeReason: 'ok' });
|
||||
window.feedBack.playback.recordRouteChange({ routeId: '/Users/example/native-route?token=secret', routeKind: 'desktop-native', state: 'active', safeReason: 'ok' });
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
@@ -87,11 +87,11 @@ test('diagnostics redact caller-supplied route and stale session ids', async ()
|
||||
|
||||
test('diagnostics redact requester ids and raw camel-case payload keys', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', target: makeTarget() }, '/Users/example/plugin token=secret');
|
||||
|
||||
const degradedEvents = captureEvents(window, 'playback:degraded');
|
||||
window.slopsmith.playback.transportEvent('degraded', {
|
||||
window.feedBack.playback.transportEvent('degraded', {
|
||||
requesterId: '/Users/example/transport token=secret',
|
||||
accessToken: 'plain-secret-token',
|
||||
nativeHandleRef: 'native-secret-handle',
|
||||
@@ -99,7 +99,7 @@ test('diagnostics redact requester ids and raw camel-case payload keys', async (
|
||||
reason: '/Users/example/private song.archive token=secret',
|
||||
safeDetail: 'safe value',
|
||||
});
|
||||
window.slopsmith.playback.recordBridgeHit({
|
||||
window.feedBack.playback.recordBridgeHit({
|
||||
bridgeId: '/Users/example/bridge token=secret',
|
||||
legacySurface: 'window.playSong',
|
||||
source: '/Users/example/source token=secret',
|
||||
|
||||
@@ -5,12 +5,12 @@ const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('playback reports no-owner no-handler and unsupported command outcomes explicitly', async () => {
|
||||
const noOwnerWindow = loadCapabilities();
|
||||
const noOwner = await noOwnerWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
const noOwner = await noOwnerWindow.feedBack.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
assert.equal(noOwner.status, 'no-owner');
|
||||
|
||||
const noHandlerWindow = loadCapabilities();
|
||||
noHandlerWindow.slopsmith.capabilities.registerOwner('playback', { pluginId: 'test-owner', commands: ['inspect'], events: [] });
|
||||
const noHandler = await noHandlerWindow.slopsmith.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
noHandlerWindow.feedBack.capabilities.registerOwner('playback', { pluginId: 'test-owner', commands: ['inspect'], events: [] });
|
||||
const noHandler = await noHandlerWindow.feedBack.capabilities.dispatch({ capability: 'playback', command: 'inspect', requester: 'test' });
|
||||
assert.equal(noHandler.status, 'no-handler');
|
||||
|
||||
const unsupportedWindow = loadPlayback();
|
||||
@@ -20,7 +20,7 @@ test('playback reports no-owner no-handler and unsupported command outcomes expl
|
||||
|
||||
test('playback registers as an active core owner', async () => {
|
||||
const window = loadPlayback();
|
||||
const snapshot = window.slopsmith.capabilities.snapshotDiagnostics();
|
||||
const snapshot = window.feedBack.capabilities.snapshotDiagnostics();
|
||||
const playback = snapshot.pipelines.find(pipeline => pipeline.name === 'playback');
|
||||
|
||||
assert.ok(playback, 'playback pipeline exists');
|
||||
@@ -40,7 +40,7 @@ test('start requires a target and explicit user authorization for fresh audible
|
||||
assert.equal(noGesture.status, 'user-action-required');
|
||||
|
||||
const adapter = makeAdapter();
|
||||
window.slopsmith.playback.registerTransportAdapter(adapter);
|
||||
window.feedBack.playback.registerTransportAdapter(adapter);
|
||||
const events = captureEvents(window, 'playback:ready');
|
||||
const result = await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: makeTarget() });
|
||||
|
||||
@@ -56,7 +56,7 @@ test('start requires a target and explicit user authorization for fresh audible
|
||||
|
||||
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.archive', arrangement: 'Lead', arrangementIndex: 0 });
|
||||
|
||||
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
|
||||
@@ -71,7 +71,7 @@ test('settings key is stable across arrangements while target id remains arrange
|
||||
|
||||
test('unsafe caller-supplied settings keys are hashed before exposure', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
await dispatch(window, 'start', {
|
||||
requesterId: 'core.player.controls',
|
||||
@@ -86,7 +86,7 @@ test('unsafe caller-supplied settings keys are hashed before exposure', async ()
|
||||
|
||||
test('unsafe caller-supplied target ids are hashed before exposure', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
await dispatch(window, 'start', {
|
||||
requesterId: 'core.player.controls',
|
||||
@@ -101,7 +101,7 @@ test('unsafe caller-supplied target ids are hashed before exposure', async () =>
|
||||
|
||||
test('dispatch requester owns playback command attribution', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
const started = await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: makeTarget() }, 'plugin.remote');
|
||||
const paused = await dispatch(window, 'pause', { requesterId: 'core.player.controls' }, 'plugin.remote');
|
||||
@@ -118,10 +118,10 @@ test('dispatch requester owns playback command attribution', async () => {
|
||||
test('transport commands emit ordered lifecycle events and normalize outcomes', async () => {
|
||||
const window = loadPlayback();
|
||||
const adapter = makeAdapter({ duration: 10 });
|
||||
window.slopsmith.playback.registerTransportAdapter(adapter);
|
||||
window.feedBack.playback.registerTransportAdapter(adapter);
|
||||
const events = [];
|
||||
for (const eventName of ['playback:requested', 'playback:loading', 'playback:ready', 'playback:paused', 'playback:resumed', 'playback:seeking', 'playback:seeked', 'playback:loop-set', 'playback:loop-cleared']) {
|
||||
window.slopsmith.on(eventName, event => events.push(event.type.replace('playback:', '')));
|
||||
window.feedBack.on(eventName, event => events.push(event.type.replace('playback:', '')));
|
||||
}
|
||||
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
@@ -141,22 +141,22 @@ test('transport commands emit ordered lifecycle events and normalize outcomes',
|
||||
|
||||
test('seek preserves pre-seek playback state and external seek events update state', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ startPlaying: true, seekResult: { completed: true, from: 1, to: 5 } }));
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter({ startPlaying: true, seekResult: { completed: true, from: 1, to: 5 } }));
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
|
||||
const seek = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 5 });
|
||||
assert.equal(seek.status, 'completed');
|
||||
assert.equal(diagnosticsSnapshot(window).state.state, 'playing');
|
||||
|
||||
window.slopsmith.playback.transportEvent('seeking', { requesterId: 'core.player.controls', media: { currentTime: 5 }, isPlaying: true });
|
||||
window.feedBack.playback.transportEvent('seeking', { requesterId: 'core.player.controls', media: { currentTime: 5 }, isPlaying: true });
|
||||
assert.equal(diagnosticsSnapshot(window).state.state, 'seeking');
|
||||
window.slopsmith.playback.transportEvent('seeked', { requesterId: 'core.player.controls', media: { currentTime: 9 }, isPlaying: true });
|
||||
window.feedBack.playback.transportEvent('seeked', { requesterId: 'core.player.controls', media: { currentTime: 9 }, isPlaying: true });
|
||||
assert.equal(diagnosticsSnapshot(window).state.state, 'playing');
|
||||
});
|
||||
|
||||
test('clear-loop requires an active playback session', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
const cleared = await dispatch(window, 'clear-loop', { requesterId: 'core.player.controls' });
|
||||
assert.equal(cleared.status, 'no-target');
|
||||
@@ -164,13 +164,13 @@ test('clear-loop requires an active playback session', async () => {
|
||||
|
||||
test('ended transport events and seek failure/rollback outcomes are distinguishable', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 5, to: 4.5 } }));
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 5, to: 4.5 } }));
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const rolledBack = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 8 });
|
||||
assert.equal(rolledBack.status, 'rolled-back');
|
||||
|
||||
const failedWindow = loadPlayback();
|
||||
failedWindow.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekError: 'seek failed' }));
|
||||
failedWindow.feedBack.playback.registerTransportAdapter(makeAdapter({ seekError: 'seek failed' }));
|
||||
await dispatch(failedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const failed = await dispatch(failedWindow, 'seek', { requesterId: 'core.player.controls', time: 3 });
|
||||
assert.equal(failed.status, 'failed');
|
||||
@@ -178,26 +178,26 @@ test('ended transport events and seek failure/rollback outcomes are distinguisha
|
||||
assert.equal(diagnosticsSnapshot(failedWindow).state.state, 'paused');
|
||||
|
||||
const unsupportedWindow = loadPlayback();
|
||||
unsupportedWindow.slopsmith.playback.registerTransportAdapter({ inspect: () => ({ currentTime: 0, duration: 120, isPlaying: true }), start: () => ({ currentTime: 0, duration: 120, isPlaying: true }) });
|
||||
unsupportedWindow.feedBack.playback.registerTransportAdapter({ inspect: () => ({ currentTime: 0, duration: 120, isPlaying: true }), start: () => ({ currentTime: 0, duration: 120, isPlaying: true }) });
|
||||
await dispatch(unsupportedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const unsupported = await dispatch(unsupportedWindow, 'seek', { requesterId: 'core.player.controls', time: 3 });
|
||||
assert.equal(unsupported.status, 'unsupported-command');
|
||||
assert.equal(diagnosticsSnapshot(unsupportedWindow).state.state, 'playing');
|
||||
|
||||
const malformedWindow = loadPlayback();
|
||||
malformedWindow.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 2, to: NaN } }));
|
||||
malformedWindow.feedBack.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: true, from: 2, to: NaN } }));
|
||||
await dispatch(malformedWindow, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const malformed = await dispatch(malformedWindow, 'seek', { requesterId: 'core.player.controls', time: 6 });
|
||||
assert.equal(malformed.status, 'failed');
|
||||
assert.match(malformed.reason, /malformed seek result/i);
|
||||
|
||||
window.slopsmith.playback.transportEvent('ended', { requesterId: 'core.player.controls', currentTime: 120 });
|
||||
window.feedBack.playback.transportEvent('ended', { requesterId: 'core.player.controls', currentTime: 120 });
|
||||
assert.equal(diagnosticsSnapshot(window).state.state, 'ended');
|
||||
});
|
||||
|
||||
test('invalid loop boundaries do not mutate an active loop', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 2, endTime: 4 });
|
||||
|
||||
@@ -212,7 +212,7 @@ test('invalid loop boundaries do not mutate an active loop', async () => {
|
||||
|
||||
test('normal resume is denied after a user-priority pause until a user action resumes', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
|
||||
@@ -225,7 +225,7 @@ test('normal resume is denied after a user-priority pause until a user action re
|
||||
|
||||
test('stale and cancelled operations are reported distinctly', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: false, from: 2, to: NaN } }));
|
||||
window.feedBack.playback.registerTransportAdapter(makeAdapter({ seekResult: { completed: false, from: 2, to: NaN } }));
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
const sessionId = diagnosticsSnapshot(window).state.sessionId;
|
||||
|
||||
|
||||
@@ -27,16 +27,16 @@ function loadInspector(window) {
|
||||
|
||||
function captureEvents(window, eventName) {
|
||||
const events = [];
|
||||
window.slopsmith.on(eventName, event => events.push(event.detail));
|
||||
window.feedBack.on(eventName, event => events.push(event.detail));
|
||||
return events;
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window, options = {}) {
|
||||
return window.slopsmith.playback.snapshot(options);
|
||||
return window.feedBack.playback.snapshot(options);
|
||||
}
|
||||
|
||||
function dispatch(window, command, payload = {}, requester = 'test') {
|
||||
return window.slopsmith.capabilities.dispatch({ capability: 'playback', command, args: payload, requester });
|
||||
return window.feedBack.capabilities.dispatch({ capability: 'playback', command, args: payload, requester });
|
||||
}
|
||||
|
||||
function makeTarget(overrides = {}) {
|
||||
|
||||
@@ -21,7 +21,7 @@ const SRC = path.join(__dirname, '..', '..', 'static', 'v3', 'plugins-page.js');
|
||||
function loadPage(opts) {
|
||||
opts = opts || {};
|
||||
const store = opts.store || {};
|
||||
const win = { slopsmith: null };
|
||||
const win = { feedBack: null };
|
||||
win.localStorage = {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { if (opts.throwOnSet) throw new Error('quota'); store[k] = String(v); },
|
||||
@@ -73,11 +73,15 @@ test('thumbUrl: manifest icon routes through the asset endpoint; else default',
|
||||
assert.equal(t.thumbUrl({ id: 'x', icon: '' }), '/static/v3/pedal-default.svg');
|
||||
});
|
||||
|
||||
test('settingsTarget: settings > screen > none', () => {
|
||||
test('settingsTarget: screen > settings > none', () => {
|
||||
const { t } = loadPage();
|
||||
assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'settings', id: 'a' });
|
||||
// A screen wins even when the plugin also has settings (e.g. audio_engine):
|
||||
// the pedal opens the plugin's page, not its settings panel.
|
||||
assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'screen', id: 'a' });
|
||||
assert.deepEqual(t.settingsTarget({ id: 'b', has_settings: false, nav: true }), { kind: 'screen', id: 'b' });
|
||||
assert.deepEqual(t.settingsTarget({ id: 'c', has_settings: false, has_screen: true }), { kind: 'screen', id: 'c' });
|
||||
// Settings-only plugin (no screen) falls back to its settings panel.
|
||||
assert.deepEqual(t.settingsTarget({ id: 'e', has_settings: true }), { kind: 'settings', id: 'e' });
|
||||
assert.deepEqual(t.settingsTarget({ id: 'd' }), { kind: 'none', id: 'd' });
|
||||
});
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ function createProgressWindow(progressionState) {
|
||||
refresh() { return Promise.resolve(progressionState); },
|
||||
},
|
||||
playSong() {},
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
on(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
list.push(handler);
|
||||
@@ -163,14 +163,14 @@ test('progression:calibration-attempt with 0.92 shows retry overlay and So close
|
||||
mastery_rank: 0,
|
||||
onboarding: {
|
||||
calibration_status: 'pending',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
diagnostic_filename: 'diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-attempt', { accuracy: 0.92 });
|
||||
win.feedBack.emit('progression:calibration-attempt', { accuracy: 0.92 });
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-retry');
|
||||
assert.ok(overlay, 'retry overlay should exist');
|
||||
@@ -184,14 +184,14 @@ test('progression:calibration-completed shows success overlay and Setup verified
|
||||
mastery_rank: 0,
|
||||
onboarding: {
|
||||
calibration_status: 'pending',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
diagnostic_filename: 'diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
win.feedBack.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-success');
|
||||
assert.ok(overlay, 'success overlay should exist');
|
||||
@@ -205,14 +205,14 @@ test('success overlay for skipped state does not claim rank-up', () => {
|
||||
mastery_rank: 1,
|
||||
onboarding: {
|
||||
calibration_status: 'skipped',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
diagnostic_filename: 'diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
win.feedBack.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-success');
|
||||
assert.ok(overlay);
|
||||
@@ -226,15 +226,15 @@ test('calibration-completed does not stack duplicate success overlays', () => {
|
||||
mastery_rank: 1,
|
||||
onboarding: {
|
||||
calibration_status: 'skipped',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
diagnostic_filename: 'diagnostics-builtin/feedBack-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
win.feedBack.emit('progression:calibration-completed', {});
|
||||
win.feedBack.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlays = win.__bodyChildren.filter((el) => el.id === 'v3-calibration-success');
|
||||
assert.equal(overlays.length, 1);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Contract tests for notifications.js: the fbNotify toast surface and the
|
||||
// progression:* → toast wiring (period labels, celebratory vs subtle, path
|
||||
// name lookup, rank-up-only guard).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'notifications.js'), 'utf8');
|
||||
|
||||
// Minimal DOM: enough for createElement/append/insertBefore/getElementById and
|
||||
// the inline-style/innerHTML the toast sets.
|
||||
function fakeDom() {
|
||||
function mkEl(tag) {
|
||||
return {
|
||||
tagName: tag, id: '', className: '', innerHTML: '', style: {},
|
||||
children: [], get firstChild() { return this.children[0] || null; },
|
||||
appendChild(c) { this.children.push(c); c.parent = this; return c; },
|
||||
insertBefore(c, ref) {
|
||||
const i = ref ? this.children.indexOf(ref) : -1;
|
||||
if (i < 0) this.children.push(c); else this.children.splice(i, 0, c);
|
||||
c.parent = this; return c;
|
||||
},
|
||||
remove() { const p = this.parent; if (p) p.children = p.children.filter((x) => x !== this); },
|
||||
addEventListener(type, fn) { (this._h || (this._h = {}))[type] = fn; },
|
||||
_text() { return (this.innerHTML || '').replace(/<[^>]*>/g, ''); },
|
||||
};
|
||||
}
|
||||
const body = mkEl('body');
|
||||
const byId = (node, id) => {
|
||||
if (node.id === id) return node;
|
||||
for (const c of node.children) { const hit = byId(c, id); if (hit) return hit; }
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
body,
|
||||
createElement: mkEl,
|
||||
getElementById: (id) => byId(body, id),
|
||||
};
|
||||
}
|
||||
|
||||
function load(progressionState) {
|
||||
const handlers = {};
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
requestAnimationFrame: (fn) => fn(), // run animation callbacks synchronously
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.document = fakeDom();
|
||||
// Deliver a CustomEvent-like wrapper ({detail}), exactly as the real bus
|
||||
// does (capabilities.js: bus.on → addEventListener, fn gets a CustomEvent).
|
||||
// Test call sites pass the raw payload; the handler must unwrap e.detail.
|
||||
sandbox.window.feedBack = { on: (name, fn) => { handlers[name] = (payload) => fn({ detail: payload }); } };
|
||||
sandbox.window.v3Progression = { get: () => progressionState };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SRC, sandbox);
|
||||
const stack = () => sandbox.document.getElementById('fb-notify-stack');
|
||||
return { sandbox, handlers, stack };
|
||||
}
|
||||
|
||||
test('fbNotify.show renders a card with the title and message', () => {
|
||||
const { sandbox, stack } = load(null);
|
||||
assert.equal(typeof sandbox.window.fbNotify.show, 'function');
|
||||
sandbox.window.fbNotify.show({ title: 'Hello', message: 'World' });
|
||||
const cards = stack().children;
|
||||
assert.equal(cards.length, 1);
|
||||
assert.match(cards[0]._text(), /Hello/);
|
||||
assert.match(cards[0]._text(), /World/);
|
||||
});
|
||||
|
||||
test('quest-completed makes a celebratory toast with the period label and reward', () => {
|
||||
const { handlers, stack } = load(null);
|
||||
handlers['progression:quest-completed']({ id: 'q1', title: 'Play 3 songs', period_type: 'weekly', reward_db: 200 });
|
||||
const card = stack().children[0];
|
||||
assert.match(card._text(), /Weekly Quest complete!/);
|
||||
assert.match(card._text(), /Play 3 songs/);
|
||||
assert.match(card._text(), /\+200 dB/);
|
||||
});
|
||||
|
||||
test('quest-progressed makes a subtle toast showing N/M and the daily label', () => {
|
||||
const { handlers, stack } = load(null);
|
||||
handlers['progression:quest-progressed']({ id: 'q1', title: 'Play 3 songs', period_type: 'daily', count: 2, target: 3 });
|
||||
assert.match(stack().children[0]._text(), /Daily Quest advanced/);
|
||||
assert.match(stack().children[0]._text(), /2\/3/);
|
||||
});
|
||||
|
||||
test('path-level-up resolves the path name from progression state', () => {
|
||||
const { handlers, stack } = load({ paths: [{ id: 'guitar', name: 'Lead Guitar' }] });
|
||||
handlers['progression:path-level-up']({ path_id: 'guitar', new_level: 4 });
|
||||
assert.match(stack().children[0]._text(), /Lead Guitar — Level 4!/);
|
||||
});
|
||||
|
||||
test('path-progressed shows path name and challenge count toward the next level', () => {
|
||||
const { handlers, stack } = load(null);
|
||||
handlers['progression:path-progressed']({ id: 'bass', name: 'Bass', completed: 2, required: 3, next_level: 2 });
|
||||
assert.match(stack().children[0]._text(), /Bass progress/);
|
||||
assert.match(stack().children[0]._text(), /2\/3 to Level 2/);
|
||||
});
|
||||
|
||||
test('rank-changed toasts on a rank up but not a rank drop', () => {
|
||||
const up = load(null);
|
||||
up.handlers['progression:rank-changed']({ from: 2, to: 3 });
|
||||
assert.equal(up.stack().children.length, 1);
|
||||
assert.match(up.stack().children[0]._text(), /Mastery Rank 3!/);
|
||||
|
||||
const down = load(null);
|
||||
down.handlers['progression:rank-changed']({ from: 3, to: 2 });
|
||||
assert.equal(down.stack() ? down.stack().children.length : 0, 0); // no toast on a drop
|
||||
});
|
||||
|
||||
test('newest toast is inserted on top of the stack', () => {
|
||||
const { sandbox, stack } = load(null);
|
||||
sandbox.window.fbNotify.show({ title: 'first' });
|
||||
sandbox.window.fbNotify.show({ title: 'second' });
|
||||
assert.match(stack().children[0]._text(), /second/);
|
||||
assert.match(stack().children[1]._text(), /first/);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// Contract tests for progression-core's _diff(): the quest-progressed /
|
||||
// path-progressed "advance" events that feed the achievement toasts, plus the
|
||||
// guards that keep a completion / level-up from also firing a progress event.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'progression-core.js'), 'utf8');
|
||||
|
||||
// Load progression-core.js in a sandbox whose fetch returns `states` in order.
|
||||
// Boot consumes states[0] (prev=null → no diff); each later refresh() diffs
|
||||
// against the previous state.
|
||||
function load(states) {
|
||||
const events = [];
|
||||
let i = 0;
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout, clearTimeout,
|
||||
fetch: async () => ({ ok: true, json: async () => states[Math.min(i++, states.length - 1)] }),
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.feedBack = { emit: (name, detail) => events.push({ name, detail }) };
|
||||
sandbox.document = { readyState: 'complete', addEventListener: () => {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SRC, sandbox);
|
||||
return { sandbox, events };
|
||||
}
|
||||
|
||||
const stateA = {
|
||||
mastery_rank: 2,
|
||||
wallet: { balance: 100 },
|
||||
quests: {
|
||||
daily: { quests: [
|
||||
{ id: 'q1', title: 'Play 3 songs', count: 1, target: 3, completed: false, reward_db: 50 },
|
||||
{ id: 'q2', title: 'Finish one', count: 0, target: 1, completed: false, reward_db: 20 },
|
||||
] },
|
||||
weekly: { quests: [
|
||||
{ id: 'w1', title: 'Weekly grind', count: 2, target: 10, completed: false, reward_db: 200 },
|
||||
] },
|
||||
},
|
||||
paths: [
|
||||
{ id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 1 } },
|
||||
{ id: 'bass', name: 'Bass', level: 0, max_level: 10, next: { level: 1, required: 2, completed: 0 } },
|
||||
],
|
||||
};
|
||||
|
||||
const stateB = {
|
||||
mastery_rank: 3, // rank up
|
||||
wallet: { balance: 170 }, // dB changed
|
||||
quests: {
|
||||
daily: { quests: [
|
||||
{ id: 'q1', title: 'Play 3 songs', count: 2, target: 3, completed: false, reward_db: 50 }, // advanced
|
||||
{ id: 'q2', title: 'Finish one', count: 1, target: 1, completed: true, reward_db: 20 }, // COMPLETED
|
||||
] },
|
||||
weekly: { quests: [
|
||||
{ id: 'w1', title: 'Weekly grind', count: 3, target: 10, completed: false, reward_db: 200 }, // advanced
|
||||
] },
|
||||
},
|
||||
paths: [
|
||||
{ id: 'guitar', name: 'Guitar', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 2 } }, // progressed
|
||||
{ id: 'bass', name: 'Bass', level: 1, max_level: 10, next: { level: 2, required: 3, completed: 0 } }, // LEVELED UP
|
||||
],
|
||||
};
|
||||
|
||||
async function diffEvents() {
|
||||
const { sandbox, events } = load([stateA, stateB]);
|
||||
await sandbox.window.v3Progression.refresh(); // coalesces with boot → state = A
|
||||
events.length = 0; // drop boot's progression:updated
|
||||
await sandbox.window.v3Progression.refresh(); // state = B → _diff(A, B)
|
||||
return events.filter((e) => e.name !== 'progression:updated');
|
||||
}
|
||||
|
||||
test('quest advance emits quest-progressed with period_type, completion does not', async () => {
|
||||
const ev = await diffEvents();
|
||||
const progressed = ev.filter((e) => e.name === 'progression:quest-progressed');
|
||||
const ids = progressed.map((e) => e.detail.id).sort();
|
||||
assert.deepEqual(ids, ['q1', 'w1']); // q2 completed → not a progress event
|
||||
const q1 = progressed.find((e) => e.detail.id === 'q1').detail;
|
||||
assert.equal(q1.period_type, 'daily');
|
||||
assert.equal(q1.count, 2);
|
||||
assert.equal(q1.target, 3);
|
||||
const w1 = progressed.find((e) => e.detail.id === 'w1').detail;
|
||||
assert.equal(w1.period_type, 'weekly');
|
||||
});
|
||||
|
||||
test('path challenge progress emits path-progressed; a level-up does not', async () => {
|
||||
const ev = await diffEvents();
|
||||
const progressed = ev.filter((e) => e.name === 'progression:path-progressed');
|
||||
assert.equal(progressed.length, 1);
|
||||
const g = progressed[0].detail;
|
||||
assert.equal(g.id, 'guitar');
|
||||
assert.equal(g.name, 'Guitar');
|
||||
assert.equal(g.completed, 2);
|
||||
assert.equal(g.required, 3);
|
||||
assert.equal(g.next_level, 2);
|
||||
// bass leveled up (level 0 → 1) → handled by path-level-up, not path-progressed.
|
||||
assert.ok(!progressed.some((e) => e.detail.id === 'bass'));
|
||||
});
|
||||
|
||||
test('rank-up and dB change still emit their events', async () => {
|
||||
const ev = await diffEvents();
|
||||
const rank = ev.find((e) => e.name === 'progression:rank-changed');
|
||||
assert.ok(rank && rank.detail.from === 2 && rank.detail.to === 3);
|
||||
assert.ok(ev.some((e) => e.name === 'progression:db-changed'));
|
||||
});
|
||||
|
||||
test('no progress events fire on the very first state (prev=null)', async () => {
|
||||
const { sandbox, events } = load([stateA]);
|
||||
await sandbox.window.v3Progression.refresh();
|
||||
assert.ok(!events.some((e) => /progressed|changed/.test(e.name)));
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Guards the Section Practice popover's outside-click dismiss in static/app.js
|
||||
// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call
|
||||
// e.stopPropagation() in their click handler (static/v3/player-chrome.js
|
||||
// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks
|
||||
// a different rail icon (Plugins, Audio, …) — leaving the Practice popover
|
||||
// stranded open under the newly-opened one (feedBack#638). The dismiss must bind
|
||||
// in the CAPTURE phase (runs before the target's stopPropagation can swallow it).
|
||||
// Esc must stay bubble-phase so it doesn't reorder ahead of the player's
|
||||
// Escape-to-exit handling. A revert to bubble-phase should fail here.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
||||
const body = m[0];
|
||||
|
||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||
assert.match(
|
||||
body,
|
||||
/addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/,
|
||||
'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s '
|
||||
+ 'stopPropagation() cannot swallow it',
|
||||
);
|
||||
});
|
||||
|
||||
test('only the click listener is capture (Escape keydown stays bubble-phase)', () => {
|
||||
// Exactly one capture binding in the installer — the click. The keydown
|
||||
// (Escape) listener must NOT be capture.
|
||||
const captureBinds = body.match(/,\s*true\s*\)/g) || [];
|
||||
assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)');
|
||||
});
|
||||
|
||||
test('the dismiss ignores clicks inside the control (no self-close)', () => {
|
||||
assert.match(body, /section-practice-control/, 'must scope to #section-practice-control');
|
||||
assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/,
|
||||
'a click inside the control (incl. the pill) must not dismiss the popover');
|
||||
});
|
||||
@@ -62,10 +62,10 @@ function loadClose(sandbox, src) {
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('closeCurrentSong is exported on window and window.slopsmith', () => {
|
||||
test('closeCurrentSong is exported on window and window.feedBack', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /window\.closeCurrentSong\s*=\s*closeCurrentSong/);
|
||||
assert.match(src, /window\.slopsmith\.closeCurrentSong\s*=\s*closeCurrentSong/);
|
||||
assert.match(src, /window\.feedBack\.closeCurrentSong\s*=\s*closeCurrentSong/);
|
||||
});
|
||||
|
||||
test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Verify the feedpak credits overlay helpers in app.js:
|
||||
// - _creditLineLabel() role → friendly "<verb> by" label
|
||||
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
|
||||
// - hideSongCreditsOverlay() removes the overlay element
|
||||
//
|
||||
// Same isolation strategy as autoplay_exit.test.js — extract the functions
|
||||
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
// names were set via textContent (not innerHTML) — the XSS-safety contract.
|
||||
function makeEl() {
|
||||
return {
|
||||
className: '',
|
||||
children: [],
|
||||
_text: '',
|
||||
set textContent(v) { this._text = String(v); this.children = []; },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
replaceChildren() { this.children = []; },
|
||||
remove() { this.removed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function allText(node) {
|
||||
let s = node._text || '';
|
||||
for (const c of node.children) s += allText(c);
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildSandbox(currentSong) {
|
||||
const body = makeEl();
|
||||
const sandbox = {
|
||||
document: { body, createElement: () => makeEl() },
|
||||
window: { feedBack: { currentSong, off() {} } },
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const preamble = `
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
|
||||
charter: 'Charted by', transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
|
||||
engineer: 'Engineered by', proofreader: 'Proofread by',
|
||||
})};
|
||||
`;
|
||||
vm.runInContext(
|
||||
preamble
|
||||
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
|
||||
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
|
||||
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
|
||||
+ 'globalThis._creditLineLabel = _creditLineLabel;'
|
||||
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
|
||||
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
|
||||
+ 'globalThis._getOverlay = () => _creditsOverlay;',
|
||||
sandbox,
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
|
||||
const s = buildSandbox({});
|
||||
assert.equal(s._creditLineLabel('charter'), 'Charted by');
|
||||
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
|
||||
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
|
||||
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
|
||||
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
|
||||
assert.equal(s._creditLineLabel(''), '');
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
|
||||
const s = buildSandbox({ title: 'My Song' });
|
||||
s.showSongCreditsOverlay([
|
||||
{ name: 'Azure', role: 'charter' },
|
||||
{ name: 'Bob Lee', role: 'editor' },
|
||||
{ name: 'Solo', role: null },
|
||||
]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay, 'overlay created');
|
||||
assert.equal(overlay.className, 'song-credits-overlay');
|
||||
assert.equal(s.document.body.children.length, 1);
|
||||
const text = allText(overlay);
|
||||
assert.match(text, /My Song/); // heading is the song title
|
||||
assert.match(text, /Charted by/);
|
||||
assert.match(text, /Azure/);
|
||||
assert.match(text, /Edited by/);
|
||||
assert.match(text, /Bob Lee/);
|
||||
assert.match(text, /Solo/); // role-less entry still shows the name
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
// The raw string survives verbatim as text — proving it was never parsed
|
||||
// as HTML (no innerHTML interpolation anywhere on the path).
|
||||
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([]);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
s.showSongCreditsOverlay(undefined);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
assert.equal(s.document.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('hideSongCreditsOverlay removes the overlay', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay);
|
||||
s.hideSongCreditsOverlay();
|
||||
assert.equal(overlay.removed, true);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
});
|
||||
@@ -112,8 +112,8 @@ test('every song:play/pause/ended emit uses _songEventPayload', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const lines = src.split('\n');
|
||||
// Accept aliased calls like `sm.emit(...)` (the JUCE shim caches
|
||||
// window.slopsmith in `sm`) — not just literal `window.slopsmith.emit`.
|
||||
const emitRe = /(?:window\.slopsmith|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"]/;
|
||||
// window.feedBack in `sm`) — not just literal `window.feedBack.emit`.
|
||||
const emitRe = /(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"]/;
|
||||
const okRe = /_songEventPayload\(\)|,\s*payload\s*\)/;
|
||||
const offending = [];
|
||||
for (const line of lines) {
|
||||
@@ -134,7 +134,7 @@ test('there are at least 8 song:* emit sites threaded through the helper', () =>
|
||||
// count drops, someone removed an emit (regression) or refactored an
|
||||
// event away (intentional — this test then needs updating).
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const matches = src.match(/(?:window\.slopsmith|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
|
||||
const matches = src.match(/(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 8,
|
||||
`expected ≥8 song:* emits, found ${matches.length}`,
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
__togglePlayCalls: 0,
|
||||
__clearLoopCalls: 0,
|
||||
window: {
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
getLoop() {
|
||||
return { loopA: sandbox.loopA, loopB: sandbox.loopB };
|
||||
},
|
||||
@@ -68,10 +68,10 @@ function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('restartCurrentSong is exported on window and window.slopsmith', () => {
|
||||
test('restartCurrentSong is exported on window and window.feedBack', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /window\.restartCurrentSong\s*=\s*restartCurrentSong/);
|
||||
assert.match(src, /window\.slopsmith\.restartCurrentSong\s*=\s*restartCurrentSong/);
|
||||
assert.match(src, /window\.feedBack\.restartCurrentSong\s*=\s*restartCurrentSong/);
|
||||
});
|
||||
|
||||
test('no loop: seeks to 0 with song-restart and starts playback when stopped', async () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ function buildSandbox({ juceMode = false, currentTime = 10, duration = Infinity
|
||||
jucePlayer,
|
||||
window: {
|
||||
_juceMode: juceMode,
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
emit(event, detail) { emitCalls.push({ event, detail }); },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -74,11 +74,11 @@ function buildSandbox({ juceMode = false } = {}) {
|
||||
_juceAudioUrl: juceMode ? '/audio/old-song.ogg' : null,
|
||||
_currentSongAudio: { url: '/audio/old-song.ogg' },
|
||||
_clearJuceRerouteMemo() {},
|
||||
slopsmith: {
|
||||
feedBack: {
|
||||
isPlaying: true,
|
||||
emit() {},
|
||||
},
|
||||
slopsmithDesktop: {
|
||||
feedBackDesktop: {
|
||||
audio: {
|
||||
setBackingSpeed(rate) {
|
||||
backingCalls.push(['setBackingSpeed', rate]);
|
||||
@@ -146,6 +146,9 @@ function loadPlaySong(sandbox) {
|
||||
var isPlaying = true;
|
||||
var currentFilename = null;
|
||||
var _playerOriginScreen = null;
|
||||
var _pendingAutostart = false;
|
||||
function _clearAutoExit() {}
|
||||
function _resolvePlayerOrigin() { return 'home'; }
|
||||
function _recordPlaybackBridge() {}
|
||||
function _cancelCountIn() {}
|
||||
function _resetJuceAudioShimChain() {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Source-level guards for the consolidated tour menu (slopsmith#272).
|
||||
// The engine lives in a DOMContentLoaded handler that wires window.slopsmith,
|
||||
// Source-level guards for the consolidated tour menu (feedBack#272).
|
||||
// The engine lives in a DOMContentLoaded handler that wires window.feedBack,
|
||||
// localStorage, and Shepherd — too much browser surface to reproduce cleanly
|
||||
// in a vm sandbox. These checks lock in the contract (viz relevance gating,
|
||||
// complete-vs-cancel semantics, waitFor validation, focus management,
|
||||
@@ -115,9 +115,9 @@ test('_updateMenuVisibility dismisses orphan toast when relevance drops to zero'
|
||||
|
||||
test('popover has role=dialog with aria-controls wired from the trigger', () => {
|
||||
const fn = extractBlock(SRC, 'function _ensureMenu()');
|
||||
assert.match(fn, /setAttribute\(\s*['"]aria-controls['"]\s*,\s*['"]slopsmith-tour-menu-popover['"]/,
|
||||
assert.match(fn, /setAttribute\(\s*['"]aria-controls['"]\s*,\s*['"]feedBack-tour-menu-popover['"]/,
|
||||
'trigger must wire aria-controls to the popover id');
|
||||
assert.match(fn, /_menuPopover\.id\s*=\s*['"]slopsmith-tour-menu-popover['"]/,
|
||||
assert.match(fn, /_menuPopover\.id\s*=\s*['"]feedBack-tour-menu-popover['"]/,
|
||||
'popover must carry the matching id');
|
||||
assert.match(fn, /setAttribute\(\s*['"]role['"]\s*,\s*['"]dialog['"]/,
|
||||
'popover must use role=dialog (not the menu role we don\'t implement)');
|
||||
|
||||
@@ -12,19 +12,19 @@ const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'sc
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.slopsmith.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const sandbox = { window: { slopsmith: {} }, exports: {} };
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.slopsmith;
|
||||
return sandbox.window.feedBack;
|
||||
}
|
||||
|
||||
const slopsmithHelpers = loadTuningHelpers();
|
||||
const feedBackHelpers = loadTuningHelpers();
|
||||
|
||||
function createTunerSandbox() {
|
||||
const enableCalls = [];
|
||||
@@ -101,7 +101,7 @@ function createTunerSandbox() {
|
||||
__setPlayerActive(v) { playerActive = v; },
|
||||
__setSongInfo(info) {
|
||||
songInfo = info;
|
||||
sandbox.window.slopsmith.currentSong = info ? {
|
||||
sandbox.window.feedBack.currentSong = info ? {
|
||||
filename: info.filename || 'song.sloppak',
|
||||
arrangementIndex: info.arrangement_index,
|
||||
tuning: info.tuning,
|
||||
@@ -111,8 +111,8 @@ function createTunerSandbox() {
|
||||
};
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.slopsmith = {
|
||||
...slopsmithHelpers,
|
||||
sandbox.window.feedBack = {
|
||||
...feedBackHelpers,
|
||||
on() {},
|
||||
off() {},
|
||||
currentSong: null,
|
||||
@@ -318,8 +318,8 @@ test('song:loading clears dismiss state for next load', async () => {
|
||||
test('screen.js registers song:loading and song:ready auto-open listeners at boot', () => {
|
||||
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
assert.match(src, /function _installAutoOpenListeners/);
|
||||
assert.match(src, /window\.slopsmith\.on\('song:loading', _onAutoOpenSongLoading\)/);
|
||||
assert.match(src, /window\.slopsmith\.on\('song:ready', _onAutoOpenSongReady\)/);
|
||||
assert.match(src, /window\.feedBack\.on\('song:loading', _onAutoOpenSongLoading\)/);
|
||||
assert.match(src, /window\.feedBack\.on\('song:ready', _onAutoOpenSongReady\)/);
|
||||
assert.match(src, /function _tuningIdentityKey/);
|
||||
assert.doesNotMatch(src, /restartCurrentSong/);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ function loadTuningDisplayHelpers() {
|
||||
extractBlock(src, 'function parseRawTuningOffsets('),
|
||||
extractBlock(src, 'function displayTuningName('),
|
||||
].join('\n');
|
||||
const sandbox = { window: { slopsmith: {} }, exports: {} };
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(block + '\nexports.displayTuningName = displayTuningName;', sandbox);
|
||||
return sandbox.exports;
|
||||
@@ -57,7 +57,7 @@ test('displayTuningName sanitizes raw offset strings to Custom Tuning', () => {
|
||||
assert.equal(displayTuningName('-3,-1,0,1,2,3'), 'Custom Tuning');
|
||||
});
|
||||
|
||||
test('displayTuningName names a known raw offset string (slopsmith#867)', () => {
|
||||
test('displayTuningName names a known raw offset string (feedBack#867)', () => {
|
||||
// Now that the API serves raw offsets, a known tuning passed as a raw
|
||||
// string must resolve to its real name, not collapse to Custom Tuning.
|
||||
assert.equal(displayTuningName('-1 -1 -1 -1 -1 -1'), 'Eb Standard');
|
||||
@@ -67,7 +67,7 @@ test('displayTuningName names a known raw offset string (slopsmith#867)', () =>
|
||||
assert.equal(displayTuningName('-2 0 0 0 -2 1'), 'Custom Tuning');
|
||||
});
|
||||
|
||||
test('displayTuningName recognizes 4/5-string uniform standard (slopsmith#867)', () => {
|
||||
test('displayTuningName recognizes 4/5-string uniform standard (feedBack#867)', () => {
|
||||
// A normal 4-string bass [0,0,0,0] must not fall through to Custom Tuning.
|
||||
assert.equal(displayTuningName(null, [0, 0, 0, 0]), 'E Standard');
|
||||
assert.equal(displayTuningName(null, [-2, -2, -2, -2]), 'D Standard');
|
||||
|
||||
@@ -16,10 +16,10 @@ const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.slopsmith.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const sandbox = { window: { slopsmith: {} }, exports: {} };
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Guard: a song's ⋮ "More" menu offers "Add to playlist" for a single song —
|
||||
// not only the select-mode checkbox + batch-bar flow. Both paths share the
|
||||
// extracted addFilenamesToPlaylist() helper. (Menu/DOM wiring isn't headlessly
|
||||
// unit-testable, so these are source-level guards.)
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js'), 'utf8');
|
||||
|
||||
test('the ⋮ card menu lists an "Add to playlist" row', () => {
|
||||
assert.match(SONGS, /id:\s*'__playlist',\s*label:\s*'Add to playlist'/);
|
||||
});
|
||||
|
||||
test('the menu row adds the single song via the shared helper', () => {
|
||||
assert.match(SONGS, /id === '__playlist'[\s\S]{0,100}addFilenamesToPlaylist\(\[song\.filename\]\)/);
|
||||
});
|
||||
|
||||
test('batch and single-song add share addFilenamesToPlaylist()', () => {
|
||||
assert.match(SONGS, /async function addFilenamesToPlaylist\(filenames\)/);
|
||||
assert.match(SONGS, /async function batchAddToPlaylist\(\)[\s\S]{0,120}addFilenamesToPlaylist\(state\.selected\)/);
|
||||
});
|
||||
|
||||
test('batch only finishes (clears selection) when the add succeeded, not on cancel', () => {
|
||||
// addFilenamesToPlaylist returns null on a cancelled/failed picker; the
|
||||
// batch caller must capture it and gate finishBatch() on a truthy pid, so
|
||||
// cancelling preserves the multi-select (regression guard for the
|
||||
// extract-helper refactor — previously finishBatch ran unconditionally).
|
||||
assert.match(SONGS, /const pid = await addFilenamesToPlaylist\(state\.selected\)/,
|
||||
'batch must capture the returned playlist id');
|
||||
assert.match(SONGS, /if \(pid\) finishBatch\(\)/,
|
||||
'finishBatch must be gated on a successful add (truthy pid)');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js.
|
||||
//
|
||||
// The rail lets a user jump the library grid to artists/titles starting with a
|
||||
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
|
||||
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
|
||||
// card's absolute index (cumulative of prior buckets), which converts to a
|
||||
// scrollTop — no page-through. The rail only offers letters the server reports
|
||||
// present for the active sort+filter (so a tap always lands on a real card). It
|
||||
// is shown only for the grid view + alphabetical (artist/title) sorts.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('the rail is context-gated to grid view + alphabetical sorts', () => {
|
||||
// railSortColumn returns the active alpha column or null (recent/year/tuning).
|
||||
assert.match(src, /function\s+railSortColumn\s*\(\)/);
|
||||
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
|
||||
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
|
||||
'the rail must be visible only for the grid view + an alphabetical sort',
|
||||
);
|
||||
});
|
||||
|
||||
test('cards carry a data-letter bucket and non-A–Z buckets under #', () => {
|
||||
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
|
||||
'each card must tag its sort-letter bucket via songBucket(song)');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
|
||||
'songBucket must bucket non-A–Z first chars under "#"',
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
|
||||
'refreshRail must query /api/library/stats with the active filter params');
|
||||
// Opts into the active-sort breakdown so non-rail callers skip the scan.
|
||||
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
|
||||
'refreshRail must request the sort_letters breakdown');
|
||||
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
|
||||
'refreshRail must prefer the active-sort breakdown (sort_letters)');
|
||||
// The legacy artist `letters` is only a valid fallback for an artist sort;
|
||||
// a title sort with no sort_letters hides the rail rather than mislabel it.
|
||||
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
|
||||
'refreshRail must only fall back to letters for an artist sort');
|
||||
// Absent letters are disabled (non-interactive), not just dimmed.
|
||||
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
|
||||
});
|
||||
|
||||
test('reload() refreshes the rail', () => {
|
||||
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
|
||||
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
|
||||
});
|
||||
|
||||
test('the rail + drag bubble are rendered in the Songs markup', () => {
|
||||
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
|
||||
assert.match(src, /id="v3-songs-azbubble"/);
|
||||
});
|
||||
|
||||
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
|
||||
// The cumulative-count seek: sum the song-counts of buckets ordered before
|
||||
// the target to get its first row's absolute index.
|
||||
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
|
||||
'jumpToLetter must derive the target index from sort_letters counts');
|
||||
assert.match(
|
||||
src,
|
||||
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
|
||||
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
|
||||
);
|
||||
// It pre-fetches the destination window so cards are ready when the scroll lands.
|
||||
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
|
||||
'jumpToLetter must pre-fetch the destination window before scrolling');
|
||||
// The old forward-paging helper is gone (the seek is O(1)).
|
||||
assert.doesNotMatch(src, /_loadNextAwait/,
|
||||
'the page-through helper must be removed under the windowed grid');
|
||||
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
|
||||
assert.match(src, /_jumpToken\s*!==\s*myToken/);
|
||||
});
|
||||
|
||||
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
|
||||
assert.match(src, /addEventListener\('pointerdown'/);
|
||||
assert.match(src, /addEventListener\('pointermove'/);
|
||||
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
|
||||
'arrow keys must move between present letters');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2).
|
||||
// A smart collection is a saved live library filter, surfaced as a source in
|
||||
// the provider picker; the drawer can save the current filter set as one.
|
||||
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('currentFilterRules builds the raw query-param rule object', () => {
|
||||
assert.match(src, /function\s+currentFilterRules/);
|
||||
// Multi-value filters are CSV strings (what the backend stores / re-parses).
|
||||
assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/);
|
||||
assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/);
|
||||
});
|
||||
|
||||
test('saving POSTs to /api/collections with name + rules', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/,
|
||||
'saveCurrentAsCollection must POST {name, rules} to /api/collections',
|
||||
);
|
||||
// After save, switch the source to the new collection and rebuild the UI.
|
||||
assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/);
|
||||
});
|
||||
|
||||
test('the drawer shows a Save-as-collection action only when filters are set', () => {
|
||||
assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/);
|
||||
assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Pins the practice-aware library home in static/v3/songs.js:
|
||||
// - a "Repertoire" progress meter (mastered / total library songs), and
|
||||
// - a "Keep practicing" shelf (recently played, not yet mastered).
|
||||
// Both reuse existing data (/api/stats/best already in state.accuracy, and
|
||||
// /api/stats/recent) and are shown only on the unfiltered grid front door.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('repertoire uses the same mastery threshold as the green accuracy badge', () => {
|
||||
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
|
||||
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
|
||||
);
|
||||
});
|
||||
|
||||
test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
|
||||
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
|
||||
);
|
||||
});
|
||||
|
||||
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||
// shows), not the per-arrangement recents row, and each filename appears
|
||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||
);
|
||||
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||
});
|
||||
|
||||
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||
'the two reads must be issued together (Promise.all), not sequentially');
|
||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||
'a stale render must be superseded by a newer one via a token');
|
||||
});
|
||||
|
||||
test('the repertoire denominator is the unfiltered library total', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?provider='/);
|
||||
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
|
||||
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
|
||||
});
|
||||
|
||||
test('the home + #v3-lib-home host are wired into render and reload', () => {
|
||||
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
|
||||
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
|
||||
'reload() must refresh/toggle the home');
|
||||
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
|
||||
'a new score must refresh the meter + shelf');
|
||||
});
|
||||
|
||||
test('shelf cards play the song on click', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
|
||||
'a shelf card click must call window.playSong with the recents filename',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Regression guard for "No DLC until restart": a library scan triggered from
|
||||
// Settings (rescan / full rescan, e.g. right after pointing at a DLC folder)
|
||||
// reloaded only the classic library — the v3 Songs grid kept its cached
|
||||
// (pre-DLC, empty) state until an app restart.
|
||||
//
|
||||
// The fix wires a `library:changed` event (emitted by the rescan handlers in
|
||||
// app.js) to a reload in static/v3/songs.js. That's DOM/event glue, not a pure
|
||||
// function, so these are source-level guards that the wiring isn't dropped; the
|
||||
// end-to-end behavior is verified in-app / by a browser test.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
|
||||
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
|
||||
|
||||
test('app.js emits library:changed when a Settings rescan completes', () => {
|
||||
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
|
||||
'a completed rescan must broadcast library:changed for the v3 grid');
|
||||
});
|
||||
|
||||
test('songs.js handles library:changed — reload when active, else mark dirty', () => {
|
||||
const m = SONGS.match(/sm\.on\(\s*['"]library:changed['"][\s\S]{0,500}?\}\);/);
|
||||
assert.ok(m, 'songs.js must subscribe to library:changed');
|
||||
assert.match(m[0], /reload\(\)/, 'reloads the grid when the screen is active');
|
||||
assert.match(m[0], /_libraryDirty\s*=\s*true/, 'marks dirty when off-screen');
|
||||
});
|
||||
|
||||
test('onV3SongsScreenEnter forces a reload when the library is dirty', () => {
|
||||
const m = SONGS.match(/function onV3SongsScreenEnter\(\)[\s\S]{0,400}?\{/);
|
||||
assert.ok(m, 'onV3SongsScreenEnter present');
|
||||
// The dirty check must short-circuit to a reload before the cached-DOM
|
||||
// fast-paths get a chance to restore the stale grid.
|
||||
assert.match(SONGS, /if\s*\(_libraryDirty\)\s*\{[^}]*reload\(\)[^}]*return;/,
|
||||
'a dirty library must force a full reload on entry, ahead of any fast-path');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// Guard for the content-dependent playlist cover (playlists.js). A custom
|
||||
// uploaded cover wins; otherwise the playlist's song art decides: icon when
|
||||
// empty, a single cover for a few songs, a 2×2 mosaic at 4+. (Rendering is DOM
|
||||
// glue, so this is a source-level guard on the decision branches.)
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PL = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js'), 'utf8');
|
||||
|
||||
test('custom cover_url takes priority', () => {
|
||||
assert.match(PL, /function playlistCoverHtml\(p\)/);
|
||||
assert.match(PL, /if \(p\.cover_url\) return/);
|
||||
});
|
||||
|
||||
test('empty → icon, <4 → single art, 4+ → 2×2 mosaic', () => {
|
||||
assert.match(PL, /if \(!arts\.length\)[\s\S]{0,160}(🔖|🎵)/); // empty → icon
|
||||
assert.match(PL, /arts\.length < 4\) return[\s\S]{0,120}arts\[0\]/); // a few → single cover
|
||||
assert.match(PL, /grid-cols-2 grid-rows-2[\s\S]{0,120}slice\(0, 4\)/); // 4+ → mosaic
|
||||
});
|
||||
|
||||
test('the card uses playlistCoverHtml (not the old static emoji box)', () => {
|
||||
assert.match(PL, /playlistCoverHtml\(p\)/);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Regression guard for the post-play score-badge refresh bug
|
||||
// (#574 follow-up): after finishing a song, its accuracy badge on the
|
||||
// Songs screen stayed stale until a full re-render (app restart / search /
|
||||
// re-enter), even though stats-recorder fired `stats:recorded`.
|
||||
//
|
||||
// Root cause: `stats:recorded` (like `song:loading`) carries the filename
|
||||
// exactly as handed to playSong — encodeURIComponent'd (see playCard) — but
|
||||
// library cards key on the DECODED filename (data-fn = cardKey → localFilename)
|
||||
// and /api/stats/best is server-canonicalized to that same decoded key. So the
|
||||
// in-place repaint (repaintAccuracy) matched no card and silently no-oped.
|
||||
//
|
||||
// The fix is a `decFn` helper in static/v3/songs.js that decodes the event
|
||||
// filename back into the card / state.accuracy key space before matching. This
|
||||
// test extracts the REAL decFn from the shipped source (not a mirror) and proves
|
||||
// the encoded event filename round-trips to the raw card key.
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
|
||||
// Brace-balanced extraction so nested braces / template strings survive.
|
||||
function extractFunctionSource(src, name) {
|
||||
const sig = `function ${name}`;
|
||||
const start = src.indexOf(sig);
|
||||
assert.ok(start !== -1, `function declaration '${name}' not found in songs.js`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function loadDecFn() {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
const fnSrc = extractFunctionSource(src, 'decFn');
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
// decodeURIComponent is an intrinsic global in the fresh context.
|
||||
vm.runInContext(`${fnSrc}\nglobalThis.__decFn = decFn;`, sandbox);
|
||||
return sandbox.__decFn;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent; // exactly what playCard passes to playSong
|
||||
|
||||
// The on-disk library filenames from the bug report's screenshots, plus a
|
||||
// subfolder path (encodeURIComponent turns '/' into %2F too).
|
||||
const CARD_KEYS = [
|
||||
'Black Me Out.sloppak',
|
||||
'All In Now.sloppak',
|
||||
'Dogstar - All In Now.feedpak',
|
||||
'Subdir/Song (Live).sloppak',
|
||||
];
|
||||
|
||||
test('decFn decodes an encoded event filename back to the raw card key', () => {
|
||||
const decFn = loadDecFn();
|
||||
for (const key of CARD_KEYS) {
|
||||
const eventFilename = enc(key); // how stats:recorded carries it
|
||||
// Precondition: the encoded form does NOT equal the card key — this is
|
||||
// exactly why the un-decoded match failed and the badge stayed stale.
|
||||
assert.notEqual(eventFilename, key, `expected '${key}' to encode to something different`);
|
||||
// The fix: decoding lands back on the card / state.accuracy key.
|
||||
assert.equal(decFn(eventFilename), key, `decFn must recover the card key for '${key}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn is idempotent for already-decoded filenames (no % present)', () => {
|
||||
const decFn = loadDecFn();
|
||||
for (const key of CARD_KEYS) {
|
||||
assert.equal(decFn(key), key, `decFn must leave the already-decoded '${key}' unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn leaves a real literal-% filename intact rather than throwing', () => {
|
||||
const decFn = loadDecFn();
|
||||
// '%.sloppak' / '100%.sloppak' are malformed percent-escapes —
|
||||
// decodeURIComponent would throw; decFn must fall back to the original.
|
||||
for (const name of ['100%.sloppak', 'mix %.feedpak', '%zz.sloppak']) {
|
||||
assert.equal(decFn(name), name, `decFn must not corrupt/throw on '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('decFn coerces non-string / empty input to an empty string', () => {
|
||||
const decFn = loadDecFn();
|
||||
assert.equal(decFn(null), '');
|
||||
assert.equal(decFn(undefined), '');
|
||||
assert.equal(decFn(''), '');
|
||||
});
|
||||
@@ -36,13 +36,15 @@ function makeStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
|
||||
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
|
||||
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
|
||||
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
|
||||
// scrollTop and re-renders the window that maps to it).
|
||||
function saveSnapshot(storage, state, scrollTop) {
|
||||
const snap = {
|
||||
hash: buildLibraryStateHash(state),
|
||||
scrollTop,
|
||||
view: state.view,
|
||||
page,
|
||||
loadedCount,
|
||||
};
|
||||
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
|
||||
}
|
||||
@@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
|
||||
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
|
||||
});
|
||||
|
||||
test('snapshot stores scrollTop and page', () => {
|
||||
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 1840, 3, 96);
|
||||
saveSnapshot(storage, baseState, 1840);
|
||||
const snap = readSnapshot(storage);
|
||||
assert.strictEqual(snap.scrollTop, 1840);
|
||||
assert.strictEqual(snap.page, 3);
|
||||
assert.strictEqual(snap.loadedCount, 96);
|
||||
assert.strictEqual(snap.view, 'grid');
|
||||
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
|
||||
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
|
||||
assert.strictEqual(snap.page, undefined);
|
||||
assert.strictEqual(snap.loadedCount, undefined);
|
||||
});
|
||||
|
||||
test('stale snapshot is detected when filters change', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 500, 1, 48);
|
||||
saveSnapshot(storage, baseState, 500);
|
||||
const snap = readSnapshot(storage);
|
||||
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
|
||||
assert.notStrictEqual(snap.hash, changed);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user