mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// Verify the alpha-build heads-up banner: markup is present in
|
||||
// static/index.html and `_updateAlphaWarningBanner(version)` in
|
||||
// static/app.js toggles its visibility correctly per the version string.
|
||||
|
||||
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 INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
test('index.html ships the alpha-warning banner inside the library section', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
|
||||
// Locate the library section so we can prove the banner lives there
|
||||
// and not stuck somewhere it would render off-screen.
|
||||
const libStart = html.indexOf('id="library-section"');
|
||||
assert.ok(libStart !== -1, 'library-section anchor not found in index.html');
|
||||
const libEnd = html.indexOf('</section>', libStart);
|
||||
assert.ok(libEnd !== -1, 'library-section closing tag not found');
|
||||
const librarySection = html.slice(libStart, libEnd);
|
||||
|
||||
assert.match(
|
||||
librarySection,
|
||||
/id="alpha-warning-banner"/,
|
||||
'alpha-warning-banner must live inside library-section',
|
||||
);
|
||||
// `hidden` Tailwind class must be present so the banner stays invisible
|
||||
// until JS opts it in via classList.toggle('hidden', false).
|
||||
assert.match(
|
||||
librarySection,
|
||||
/id="alpha-warning-banner"[^>]*\bhidden\b/,
|
||||
'alpha-warning-banner must start with the `hidden` class',
|
||||
);
|
||||
// role="status" gives screen readers a non-interrupting announcement
|
||||
// rather than treating it as decorative.
|
||||
assert.match(
|
||||
librarySection,
|
||||
/id="alpha-warning-banner"[^>]*role="status"/,
|
||||
'alpha-warning-banner must declare role="status"',
|
||||
);
|
||||
});
|
||||
|
||||
// Brace-balanced extraction so the function body — including nested
|
||||
// object literals, template strings, or future guards — survives a
|
||||
// naive regex stopping at the first `}`.
|
||||
function extractFunctionSource(src, name) {
|
||||
const sig = `function ${name}`;
|
||||
const start = src.indexOf(sig);
|
||||
assert.ok(start !== -1, `function declaration '${name}' not found`);
|
||||
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);
|
||||
}
|
||||
|
||||
// Build a fresh sandbox per test so cross-case state can't leak —
|
||||
// classList.toggle mutations on one fake banner can't bias the next case.
|
||||
function setupSandbox({ bannerExists = true } = {}) {
|
||||
const classes = new Set(['hidden']);
|
||||
const banner = bannerExists ? {
|
||||
classList: {
|
||||
// Mirror DOMTokenList.toggle: when `force` is provided, it
|
||||
// sets/removes deterministically; the production code relies
|
||||
// on that signature, so test it the same way.
|
||||
toggle: (cls, force) => {
|
||||
if (force === true) classes.add(cls);
|
||||
else if (force === false) classes.delete(cls);
|
||||
else if (classes.has(cls)) classes.delete(cls);
|
||||
else classes.add(cls);
|
||||
},
|
||||
},
|
||||
} : null;
|
||||
const sandbox = {
|
||||
document: {
|
||||
getElementById: (id) => (id === 'alpha-warning-banner' ? banner : null),
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fnSrc = extractFunctionSource(src, '_updateAlphaWarningBanner');
|
||||
// Hoist into the sandbox global so the test can call it like a
|
||||
// regular function. The production declaration is inside an IIFE,
|
||||
// but the function body itself is independent of that closure.
|
||||
vm.runInContext(`${fnSrc}\nglobalThis.__update = _updateAlphaWarningBanner;`, sandbox);
|
||||
return { update: sandbox.__update, classes };
|
||||
}
|
||||
|
||||
test('unhides the banner on an alpha version string', () => {
|
||||
const { update, classes } = setupSandbox();
|
||||
update('0.2.9-alpha.5');
|
||||
assert.equal(classes.has('hidden'), false, 'banner should be visible on alpha versions');
|
||||
});
|
||||
|
||||
test('keeps the banner hidden on a stable version string', () => {
|
||||
const { update, classes } = setupSandbox();
|
||||
update('0.2.9');
|
||||
assert.equal(classes.has('hidden'), true, 'banner must stay hidden on stable versions');
|
||||
});
|
||||
|
||||
test('alpha detection is case-insensitive', () => {
|
||||
const { update, classes } = setupSandbox();
|
||||
update('0.2.9-ALPHA.5');
|
||||
assert.equal(classes.has('hidden'), false, 'uppercase ALPHA should also trigger the banner');
|
||||
});
|
||||
|
||||
test('does not confuse beta or rc with alpha', () => {
|
||||
for (const v of ['0.2.9-beta.1', '0.2.9-rc.2', '1.0.0']) {
|
||||
const { update, classes } = setupSandbox();
|
||||
update(v);
|
||||
assert.equal(
|
||||
classes.has('hidden'),
|
||||
true,
|
||||
`banner must remain hidden on non-alpha version '${v}'`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('handles non-string or missing version input gracefully', () => {
|
||||
for (const v of [null, undefined, 42, {}, '']) {
|
||||
const { update, classes } = setupSandbox();
|
||||
update(v);
|
||||
assert.equal(
|
||||
classes.has('hidden'),
|
||||
true,
|
||||
`banner must stay hidden when version is ${JSON.stringify(v)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no-ops without throwing when the banner element is absent', () => {
|
||||
const { update } = setupSandbox({ bannerExists: false });
|
||||
assert.doesNotThrow(() => update('0.2.9-alpha.5'));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const tourScript = path.join(__dirname, '..', '..', 'plugins', 'app_tour_library', 'script.js');
|
||||
const SRC = fs.readFileSync(tourScript, 'utf8');
|
||||
|
||||
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('library tour registers dynamic steps for provider-aware instructions', () => {
|
||||
const register = extractBlock(SRC, 'function _register()');
|
||||
assert.match(register, /buildSteps:\s*_buildSteps/,
|
||||
'Library tour registration must use buildSteps so provider instructions can be conditional');
|
||||
});
|
||||
|
||||
test('library tour adds provider step only when multiple browsable providers exist', () => {
|
||||
const providerCheck = extractBlock(SRC, 'async function _hasMultipleProviders()');
|
||||
assert.match(providerCheck, /fetch\(\s*['"]\/api\/library\/providers['"]\s*\)/,
|
||||
'Provider count must come from the library providers endpoint');
|
||||
assert.match(providerCheck, /filter\(_isBrowsableProvider\)/,
|
||||
'Provider count must ignore non-browsable providers');
|
||||
assert.match(providerCheck, /providers\.length\s*>\s*1/,
|
||||
'Provider step must require more than one provider');
|
||||
|
||||
const buildSteps = extractBlock(SRC, 'async function _buildSteps()');
|
||||
assert.match(buildSteps, /!\(await\s+_hasMultipleProviders\(\)\)/,
|
||||
'buildSteps must skip the provider step unless multiple providers are present');
|
||||
assert.match(buildSteps, /splice\([\s\S]*PROVIDER_STEP/,
|
||||
'buildSteps must insert the provider instruction into the tour');
|
||||
});
|
||||
|
||||
test('library tour inserts provider step after the search step', () => {
|
||||
const buildSteps = extractBlock(SRC, 'async function _buildSteps()');
|
||||
assert.match(buildSteps, /insertAt\s*===\s*-1\s*\?\s*1\s*:\s*insertAt\s*\+\s*1/,
|
||||
'Provider step must be inserted after the search step when search is found');
|
||||
assert.match(buildSteps, /splice\([\s\S]*PROVIDER_STEP/,
|
||||
'buildSteps must splice PROVIDER_STEP into the tour');
|
||||
});
|
||||
|
||||
test('provider tour step targets the library provider selector', () => {
|
||||
assert.match(SRC, /id:\s*['"]library-provider['"]/,
|
||||
'Provider step must have a stable id');
|
||||
assert.match(SRC, /selector:\s*['"]#lib-provider['"]/,
|
||||
'Provider step must spotlight the provider selector');
|
||||
assert.match(SRC, /waitFor:\s*['"]#lib-provider['"]/,
|
||||
'Provider step must wait for the selector before showing');
|
||||
});
|
||||
@@ -0,0 +1,921 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioEffects, diagnosticsSnapshot } = require('./audio_effects_test_harness');
|
||||
|
||||
function registerProvider(api, overrides = {}) {
|
||||
return api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-provider',
|
||||
source: 'test',
|
||||
payload: {
|
||||
providerId: overrides.providerId || 'rig-builder',
|
||||
pluginId: overrides.pluginId || 'rig_builder',
|
||||
routeKey: overrides.routeKey || 'desktop-main',
|
||||
priority: overrides.priority == null ? 40 : overrides.priority,
|
||||
operations: overrides.operations || ['chain.resolve', 'segment.activate', 'stage.set-bypass', 'stage.set-parameter'],
|
||||
requests: overrides.requests || [],
|
||||
operationHandlers: overrides.operationHandlers || {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'plan-1',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: overrides.providerId || 'rig-builder',
|
||||
stages: [
|
||||
{ stageId: 'pre-1', kind: 'nam', role: 'pre-pedal', assetRef: 'provider:nam:pre', bypassed: false },
|
||||
{ stageId: 'amp-1', kind: 'nam', role: 'amp', assetRef: 'provider:nam:amp', bypassed: false },
|
||||
{ stageId: 'cab-1', kind: 'ir', role: 'cab', assetRef: 'provider:ir:cab', bypassed: false },
|
||||
],
|
||||
segments: [{ segmentId: 'ToneA', stageIds: ['pre-1', 'amp-1', 'cab-1'], stageBypass: { 'pre-1': true, 'amp-1': false } }],
|
||||
summary: { stageCount: 3, kinds: ['nam', 'ir'], assetRefs: ['provider:nam:amp'] },
|
||||
},
|
||||
summary: { stageCount: 3, categoryCount: 3, assetRefs: ['provider:nam:amp'] },
|
||||
}),
|
||||
'segment.activate': () => ({ outcome: 'handled', summary: { active: true } }),
|
||||
'stage.set-bypass': () => ({ outcome: 'handled', summary: { bypassed: true } }),
|
||||
'stage.set-parameter': () => ({ outcome: 'handled', summary: { changed: true } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('audio-effects host registers active domain and contributes diagnostics', () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const pipeline = api.inspect('audio-effects');
|
||||
const diagnostics = window.slopsmith.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');
|
||||
});
|
||||
|
||||
test('audio-effects runtime providers and executors are capability participants', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
pluginId: 'nam-tone',
|
||||
routeKey: 'live-guitar',
|
||||
operations: ['chain.resolve', 'chain.inspect', 'stage.set-bypass'],
|
||||
requests: ['list-mappings', 'upsert-mapping'],
|
||||
});
|
||||
const executor = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam-tone',
|
||||
routeKey: 'live-guitar',
|
||||
providerIds: ['nam-tone'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
operations: ['loadChainPlan'],
|
||||
},
|
||||
});
|
||||
const pipeline = api.inspect('audio-effects');
|
||||
const participant = pipeline.participants.find(item => item.pluginId === 'nam-tone');
|
||||
|
||||
assert.equal(executor.outcome, 'handled');
|
||||
assert.ok(participant);
|
||||
assert.deepEqual([...participant.roles].sort(), ['executor', 'provider', 'requester']);
|
||||
assert.equal(participant.runtime, true);
|
||||
assert.equal(participant.safety, 'sensitive');
|
||||
assert.equal(participant.ownership, 'multi-provider');
|
||||
assert.deepEqual(Array.from(participant.requests), ['list-mappings', 'upsert-mapping']);
|
||||
assert.equal(participant.operations.includes('chain.resolve'), true);
|
||||
assert.equal(participant.operations.includes('executor.load-chain-plan'), true);
|
||||
});
|
||||
|
||||
test('unregistering a provider drops its role/operations and clearing all removes the participant', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
pluginId: 'nam-tone',
|
||||
routeKey: 'live-guitar',
|
||||
operations: ['chain.resolve', 'chain.inspect', 'stage.set-bypass'],
|
||||
requests: ['list-mappings'],
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam-tone',
|
||||
routeKey: 'live-guitar',
|
||||
providerIds: ['nam-tone'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
operations: ['loadChainPlan'],
|
||||
},
|
||||
});
|
||||
|
||||
// Unregister only the provider; the executor for the same plugin remains.
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'unregister-provider',
|
||||
source: 'test',
|
||||
payload: { providerId: 'nam-tone', pluginId: 'nam-tone' },
|
||||
});
|
||||
|
||||
const afterProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'nam-tone');
|
||||
assert.ok(afterProvider, 'participant should survive while the executor is still registered');
|
||||
// The merge-only registry would otherwise keep the stale provider role/operations/requests.
|
||||
assert.deepEqual([...afterProvider.roles].sort(), ['executor']);
|
||||
assert.equal(afterProvider.operations.includes('chain.resolve'), false);
|
||||
assert.equal(afterProvider.operations.includes('chain.inspect'), false);
|
||||
assert.equal(afterProvider.operations.includes('executor.load-chain-plan'), true);
|
||||
assert.deepEqual(Array.from(afterProvider.requests), []);
|
||||
|
||||
// Removing the last executor must remove the participant entirely.
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'unregister-executor',
|
||||
source: 'test',
|
||||
payload: { executorId: 'nam-tone-browser-wasm', pluginId: 'nam-tone' },
|
||||
});
|
||||
const afterAll = api.inspect('audio-effects').participants.find(item => item.pluginId === 'nam-tone');
|
||||
assert.equal(afterAll, undefined, 'participant should be removed once no providers or executors remain');
|
||||
});
|
||||
|
||||
test('host runtime overlay preserves a plugin-declared audio-effects manifest entry', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
// The plugin declares its own audio-effects participancy in its manifest before any runtime
|
||||
// registration goes through the host.
|
||||
api.registerParticipant('rig_builder', {
|
||||
'audio-effects': {
|
||||
roles: ['observer'],
|
||||
operations: ['chain.inspect'],
|
||||
description: 'Manifest-declared audio-effects observer.',
|
||||
},
|
||||
});
|
||||
|
||||
await registerProvider(api, { providerId: 'rig-builder', pluginId: 'rig_builder', routeKey: 'desktop-main' });
|
||||
|
||||
const withProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'rig_builder');
|
||||
assert.ok(withProvider);
|
||||
// Manifest role/operation survive alongside the host-added provider overlay.
|
||||
assert.equal(withProvider.roles.includes('observer'), true, 'manifest role must survive host sync');
|
||||
assert.equal(withProvider.roles.includes('provider'), true);
|
||||
assert.equal(withProvider.operations.includes('chain.inspect'), true);
|
||||
|
||||
// Unregistering the runtime provider must leave the manifest declaration intact, not wipe it.
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'unregister-provider',
|
||||
source: 'test',
|
||||
payload: { providerId: 'rig-builder', pluginId: 'rig_builder' },
|
||||
});
|
||||
const afterProvider = api.inspect('audio-effects').participants.find(item => item.pluginId === 'rig_builder');
|
||||
assert.ok(afterProvider, 'manifest-declared participant must remain after the host overlay is removed');
|
||||
assert.deepEqual([...afterProvider.roles].sort(), ['observer']);
|
||||
assert.equal(afterProvider.operations.includes('chain.inspect'), true);
|
||||
assert.equal(afterProvider.roles.includes('provider'), false, 'stale host role must be cleared');
|
||||
});
|
||||
|
||||
test('select-chain requires user action and records selected provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerProvider(api);
|
||||
|
||||
const denied = await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main' } });
|
||||
const selected = await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action', requesterId: 'spoofed-plugin' } });
|
||||
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
|
||||
|
||||
assert.equal(denied.outcome, 'user-action-required');
|
||||
assert.equal(selected.outcome, 'handled');
|
||||
assert.equal(selected.payload.route.providerId, 'rig-builder');
|
||||
assert.equal(route.payload.route.state, 'selected');
|
||||
assert.equal(JSON.stringify(diagnosticsSnapshot(window)).includes('spoofed-plugin'), false);
|
||||
});
|
||||
|
||||
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;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
|
||||
|
||||
const resolved = await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main', target: { settingsKey: 'settings-v1-abc1234' } } });
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
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.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');
|
||||
assert.equal(snapshot.routes[0].activePlanId, 'plan-1');
|
||||
assert.equal(encoded.includes('provider:nam:amp'), false);
|
||||
assert.equal(encoded.includes('assetRef'), false);
|
||||
assert.equal(encoded.includes('categoryCount'), false);
|
||||
});
|
||||
|
||||
test('resolve-plan rejects raw file paths and records fallback state', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerProvider(api, {
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'bad-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: '/Users/example/private/amp.nam' }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main' } });
|
||||
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.equal(resolved.outcome, 'failed');
|
||||
assert.match(resolved.reason, /invalid/i);
|
||||
assert.equal(route.payload.route.state, 'fallback');
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('amp.nam'), false);
|
||||
});
|
||||
|
||||
test('load-plan calls trusted executor with provider-private assets without diagnostic leakage', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
let executorRequest = null;
|
||||
window.slopsmithDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan(request) {
|
||||
executorRequest = request;
|
||||
return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } };
|
||||
},
|
||||
},
|
||||
};
|
||||
await registerProvider(api, {
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'private-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:amp' }],
|
||||
},
|
||||
assets: {
|
||||
'provider:asset:amp': { kind: 'nam', path: '/Users/example/private/amp.nam', stateBase64: 'secret-state' },
|
||||
},
|
||||
summary: { stageCount: 1 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
routeKey: 'desktop-main',
|
||||
authorization: 'playback-session',
|
||||
target: { presetRef: 'safe-ref' },
|
||||
options: { preloadMute: { targetGain: 4, holdMs: 25 }, gains: { input: 8, chain: 4 }, startAudio: true },
|
||||
},
|
||||
});
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(executorRequest.authorization, 'playback-session');
|
||||
assert.equal(executorRequest.plan.planId, 'private-plan');
|
||||
assert.equal(executorRequest.assets['provider:asset:amp'].path, '/Users/example/private/amp.nam');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(executorRequest.options)), { preloadMute: { enabled: true, dryDuringLoad: true, targetGain: 4, holdMs: 25 }, gains: { input: 8, chain: 4 }, startAudio: true });
|
||||
assert.equal(snapshot.routes[0].state, 'loaded');
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('secret-state'), false);
|
||||
assert.equal(encoded.includes('provider:asset:amp'), false);
|
||||
});
|
||||
|
||||
test('route gain and release delegate to the selected executor', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
window.slopsmithDesktop = {
|
||||
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 } }; },
|
||||
releaseRoute(request) { calls.push(['release', request.routeKey]); return { outcome: 'handled', payload: { released: true } }; },
|
||||
},
|
||||
};
|
||||
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' });
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(gained.outcome, 'handled');
|
||||
assert.equal(released.outcome, 'handled');
|
||||
assert.equal(inspected.payload.route, null);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['load'], ['gain', { input: 3, chain: 2 }], ['release', 'desktop-main']]);
|
||||
});
|
||||
|
||||
test('provider unregister clears route plan and executor state', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
|
||||
},
|
||||
};
|
||||
await registerProvider(api);
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'nam_tone',
|
||||
payload: { routeKey: 'desktop-main', authorization: 'playback-session' },
|
||||
});
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
|
||||
const unregistered = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'unregister-provider',
|
||||
source: 'rig_builder',
|
||||
payload: { pluginId: 'rig_builder', providerId: 'rig-builder' },
|
||||
});
|
||||
const route = await api.dispatch({ capability: 'audio-effects', command: 'inspect-route', source: 'test', payload: { routeKey: 'desktop-main' } });
|
||||
|
||||
assert.equal(unregistered.outcome, 'handled');
|
||||
assert.equal(route.payload.route.state, 'provider-unavailable');
|
||||
assert.equal(route.payload.route.activePlanId, '');
|
||||
assert.equal(route.payload.route.activeSegmentId, '');
|
||||
assert.equal(route.payload.route.executorId, '');
|
||||
assert.equal(JSON.stringify(route.payload.route.planSummary), '{}');
|
||||
});
|
||||
|
||||
test('registry caps still allow provider and executor refreshes', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = await registerProvider(api, { providerId: `provider-${i}`, pluginId: `plugin-${i}` });
|
||||
assert.equal(result.outcome, 'handled');
|
||||
}
|
||||
const newProvider = await registerProvider(api, { providerId: 'provider-overflow', pluginId: 'plugin-overflow' });
|
||||
const refreshedProvider = await registerProvider(api, { providerId: 'provider-0', pluginId: 'plugin-0', priority: 99 });
|
||||
assert.equal(newProvider.outcome, 'failed');
|
||||
assert.equal(refreshedProvider.outcome, 'handled');
|
||||
assert.equal(refreshedProvider.payload.provider.priority, 99);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: { executorId: `executor-${i}`, pluginId: `executor-plugin-${i}`, routeKey: 'desktop-main', supportedKinds: ['nam'] },
|
||||
});
|
||||
assert.equal(result.outcome, 'handled');
|
||||
}
|
||||
const newExecutor = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: { executorId: 'executor-overflow', pluginId: 'executor-plugin-overflow', routeKey: 'desktop-main' },
|
||||
});
|
||||
const refreshedExecutor = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: { executorId: 'executor-0', pluginId: 'executor-plugin-0', routeKey: 'desktop-main', priority: 77, supportedKinds: ['ir'] },
|
||||
});
|
||||
assert.equal(newExecutor.outcome, 'failed');
|
||||
assert.equal(refreshedExecutor.outcome, 'handled');
|
||||
assert.equal(refreshedExecutor.payload.executor.priority, 77);
|
||||
assert.equal(JSON.stringify(refreshedExecutor.payload.executor.supportedKinds), '["ir"]');
|
||||
});
|
||||
|
||||
test('route restore preserves loaded state when an executor remains active', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithDesktop = {
|
||||
audioEffects: {
|
||||
loadChainPlan() { return { outcome: 'handled', status: 'loaded', payload: { slotsLoaded: 1 } }; },
|
||||
},
|
||||
};
|
||||
await registerProvider(api);
|
||||
const loaded = await api.dispatch({ capability: 'audio-effects', command: 'load-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main', authorization: 'playback-session' } });
|
||||
const bypassed = await api.dispatch({ capability: 'audio-effects', command: 'bypass', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
|
||||
const restored = await api.dispatch({ capability: 'audio-effects', command: 'restore', source: 'test', payload: { routeKey: 'desktop-main', authorization: 'user-action' } });
|
||||
|
||||
assert.equal(loaded.payload.route.state, 'loaded');
|
||||
assert.equal(bypassed.payload.route.state, 'bypassed');
|
||||
assert.equal(restored.payload.route.state, 'loaded');
|
||||
assert.equal(restored.payload.route.executorId, loaded.payload.route.executorId);
|
||||
});
|
||||
|
||||
test('load-plan uses a registered compatible executor when Desktop is unavailable', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
let executorRequest = null;
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
pluginId: 'nam_tone',
|
||||
operationHandlers: {
|
||||
'chain.resolve': request => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'browser-plan',
|
||||
routeKey: request.routeKey,
|
||||
providerId: 'nam-tone',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:browser-amp' }],
|
||||
},
|
||||
assets: {
|
||||
'provider:asset:browser-amp': { kind: 'nam', browserFile: 'clean.json', path: '/Users/example/private/clean.nam' },
|
||||
},
|
||||
summary: { stageCount: 1 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam_tone',
|
||||
routeKey: 'desktop-main',
|
||||
sourceMode: 'browser',
|
||||
providerIds: ['nam-tone'],
|
||||
operations: ['loadChainPlan'],
|
||||
loadChainPlan(request) {
|
||||
executorRequest = request;
|
||||
return { outcome: 'handled', status: 'loaded', payload: { engineMode: 'wasm', slotsLoaded: 1 } };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'nam_tone',
|
||||
payload: { routeKey: 'desktop-main', authorization: 'playback-session', target: { presetId: 42 } },
|
||||
});
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
|
||||
assert.equal(executorRequest.target.presetId, 42);
|
||||
assert.equal(executorRequest.assets['provider:asset:browser-amp'].browserFile, 'clean.json');
|
||||
assert.equal(snapshot.routes[0].state, 'loaded');
|
||||
assert.equal(snapshot.routes[0].executorId, 'nam-tone-browser-wasm');
|
||||
assert.equal(snapshot.executors.some(executor => executor.executorId === 'nam-tone-browser-wasm' && executor.sourceMode === 'browser'), true);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('clean.nam'), false);
|
||||
assert.equal(encoded.includes('provider:asset:browser-amp'), false);
|
||||
});
|
||||
|
||||
test('load-plan redacts circular executor payloads without overflowing', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const circular = { loaded: true };
|
||||
circular.self = circular;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'test',
|
||||
payload: {
|
||||
executorId: 'cycle-executor',
|
||||
pluginId: 'cycle_executor',
|
||||
routeKey: 'desktop-main',
|
||||
providerIds: ['rig-builder'],
|
||||
operations: ['loadChainPlan'],
|
||||
loadChainPlan() {
|
||||
return { outcome: 'handled', status: 'loaded', payload: circular };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'test',
|
||||
payload: { routeKey: 'desktop-main', executorId: 'cycle-executor', authorization: 'playback-session' },
|
||||
});
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(loaded.payload.result.self, '[circular]');
|
||||
assert.doesNotThrow(() => diagnosticsSnapshot(window));
|
||||
});
|
||||
|
||||
test('load-plan does not use an executor registered for a different provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:rig-amp' }],
|
||||
},
|
||||
assets: { 'provider:asset:rig-amp': { kind: 'nam', path: '/Users/example/private/rig.nam' } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam_tone',
|
||||
providerIds: ['nam-tone'],
|
||||
loadChainPlan() { return { outcome: 'handled' }; },
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'rig_builder',
|
||||
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'playback-session' },
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.equal(loaded.outcome, 'unavailable');
|
||||
assert.match(loaded.reason, /No compatible/);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('rig.nam'), false);
|
||||
});
|
||||
|
||||
test('load-plan rejects executors that cannot support the resolved stage kinds', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
let called = false;
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-vst-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'drive', kind: 'vst', role: 'pre-pedal', assetRef: 'provider:asset:drive' }],
|
||||
},
|
||||
assets: { 'provider:asset:drive': { kind: 'vst', path: '/Users/example/private/drive.vst3' } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'nam-only-test',
|
||||
pluginId: 'nam_tone',
|
||||
providerIds: ['rig-builder'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
loadChainPlan() { called = true; return { outcome: 'handled' }; },
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'rig_builder',
|
||||
payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'playback-session' },
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.equal(loaded.outcome, 'unavailable');
|
||||
assert.match(loaded.payload.reason, /vst/);
|
||||
assert.equal(called, false);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('drive.vst3'), false);
|
||||
});
|
||||
|
||||
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;
|
||||
let loadedProviderId = '';
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
priority: 40,
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:rig-amp' }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
pluginId: 'nam_tone',
|
||||
priority: 10,
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'nam-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'nam-tone',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:nam-amp' }],
|
||||
},
|
||||
assets: { 'provider:asset:nam-amp': { kind: 'nam', browserFile: 'fallback.json' } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam_tone',
|
||||
providerIds: ['nam-tone'],
|
||||
loadChainPlan(request) {
|
||||
loadedProviderId = request.plan.providerId;
|
||||
return { outcome: 'handled', payload: { slotsLoaded: 1 } };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'nam_tone',
|
||||
payload: { routeKey: 'desktop-main', authorization: 'playback-session', fallbackProviderId: 'nam-tone' },
|
||||
});
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(loaded.payload.route.providerId, 'nam-tone');
|
||||
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
|
||||
assert.equal(loadedProviderId, 'nam-tone');
|
||||
});
|
||||
|
||||
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;
|
||||
let loadedProviderId = '';
|
||||
await registerProvider(api, {
|
||||
providerId: 'rig-builder',
|
||||
pluginId: 'rig_builder',
|
||||
priority: 40,
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'rig-vst-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'drive', kind: 'vst', role: 'pre-pedal', assetRef: 'provider:asset:drive' }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
await registerProvider(api, {
|
||||
providerId: 'nam-tone',
|
||||
pluginId: 'nam_tone',
|
||||
priority: 10,
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'nam-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'nam-tone',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:asset:nam-amp' }],
|
||||
},
|
||||
assets: { 'provider:asset:nam-amp': { kind: 'nam', browserFile: 'fallback.json' } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'rig-nam-only',
|
||||
pluginId: 'nam_tone',
|
||||
providerIds: ['rig-builder'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
loadChainPlan() { throw new Error('should not load VST rig plan'); },
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'register-executor',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
executorId: 'nam-tone-browser-wasm',
|
||||
pluginId: 'nam_tone',
|
||||
providerIds: ['nam-tone'],
|
||||
supportedKinds: ['nam', 'ir'],
|
||||
loadChainPlan(request) {
|
||||
loadedProviderId = request.plan.providerId;
|
||||
return { outcome: 'handled', payload: { slotsLoaded: 1 } };
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-effects', command: 'select-chain', source: 'user', payload: { routeKey: 'desktop-main', providerId: 'rig-builder', authorization: 'user-action' } });
|
||||
|
||||
const loaded = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'load-plan',
|
||||
source: 'playback',
|
||||
payload: { routeKey: 'desktop-main', authorization: 'playback-session', fallbackProviderId: 'nam-tone' },
|
||||
});
|
||||
|
||||
assert.equal(loaded.outcome, 'handled');
|
||||
assert.equal(loaded.payload.route.providerId, 'nam-tone');
|
||||
assert.equal(loaded.payload.executor.executorId, 'nam-tone-browser-wasm');
|
||||
assert.equal(loadedProviderId, 'nam-tone');
|
||||
});
|
||||
|
||||
test('provider operations route stage and segment changes through selected provider', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
await registerProvider(api, {
|
||||
operationHandlers: {
|
||||
'chain.resolve': () => ({
|
||||
outcome: 'handled',
|
||||
plan: {
|
||||
schema: 'slopsmith.audio_effects.chain_plan.v1',
|
||||
planId: 'switch-plan',
|
||||
routeKey: 'desktop-main',
|
||||
providerId: 'rig-builder',
|
||||
stages: [{ stageId: 'amp', kind: 'nam', role: 'amp', assetRef: 'provider:nam:amp' }],
|
||||
segments: [{ segmentId: 'dist', stageIds: ['amp'] }],
|
||||
},
|
||||
}),
|
||||
'segment.activate': request => { calls.push(['segment.activate', request.segmentId]); return { outcome: 'handled' }; },
|
||||
'stage.set-bypass': request => { calls.push(['stage.set-bypass', request.stageId, request.bypassed]); return { outcome: 'handled' }; },
|
||||
'stage.set-parameter': request => { calls.push(['stage.set-parameter', request.stageId, request.parameterId]); return { outcome: 'handled' }; },
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-effects', command: 'resolve-plan', source: 'nam_tone', payload: { routeKey: 'desktop-main' } });
|
||||
|
||||
const segment = await api.dispatch({ capability: 'audio-effects', command: 'activate-segment', source: 'playback', payload: { routeKey: 'desktop-main', segmentId: 'dist' } });
|
||||
const bypass = await api.dispatch({ capability: 'audio-effects', command: 'set-stage-bypass', source: 'rig_builder', payload: { routeKey: 'desktop-main', stageId: 'amp', bypassed: true } });
|
||||
const parameter = await api.dispatch({ capability: 'audio-effects', command: 'set-stage-parameter', source: 'rig_builder', payload: { routeKey: 'desktop-main', stageId: 'amp', parameterId: 'gain', value: 0.7 } });
|
||||
|
||||
assert.equal(segment.outcome, 'handled');
|
||||
assert.equal(bypass.outcome, 'handled');
|
||||
assert.equal(parameter.outcome, 'handled');
|
||||
assert.deepEqual(calls, [['segment.activate', 'dist'], ['stage.set-bypass', 'amp', true], ['stage.set-parameter', 'amp', 'gain']]);
|
||||
});
|
||||
|
||||
test('mapping helpers call core mapping API with provider-tagged payloads', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
window.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
if (String(url).includes('/activate')) {
|
||||
return { ok: true, mapping: { id: 7, provider_id: 'rig-builder', active: true } };
|
||||
}
|
||||
if ((options.method || 'GET') === 'DELETE') {
|
||||
return { ok: true, cleared: true };
|
||||
}
|
||||
if ((options.method || 'GET') === 'POST') {
|
||||
return { ok: true, mapping: { id: 7, song_key: 'settings-v1-song', tone_key: 'Dist', provider_id: 'rig-builder', provider_ref: 'chain:99', active: true } };
|
||||
}
|
||||
return { mappings: [{ id: 7, song_key: 'settings-v1-song', tone_key: 'Dist', provider_id: 'rig-builder', provider_ref: 'chain:99', active: true }] };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const saved = await window.slopsmith.audioEffects.upsertMapping({
|
||||
song_key: 'settings-v1-song',
|
||||
filename: 'Artist - Song_p.psarc',
|
||||
tone_key: 'Dist',
|
||||
provider_id: 'rig-builder',
|
||||
provider_ref: 'chain:99',
|
||||
active: true,
|
||||
});
|
||||
const listed = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'list-mappings',
|
||||
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' });
|
||||
|
||||
assert.equal(saved.outcome, 'handled');
|
||||
assert.equal(saved.payload.mapping.provider_ref, 'chain:99');
|
||||
assert.equal(listed.outcome, 'handled');
|
||||
assert.equal(listed.payload.mappings[0].provider_id, 'rig-builder');
|
||||
assert.equal(activated.payload.mapping.active, true);
|
||||
assert.equal(cleared.payload.cleared, true);
|
||||
assert.equal(calls[0].url, '/api/audio-effects/mappings');
|
||||
assert.equal(JSON.parse(calls[0].options.body).provider_id, 'rig-builder');
|
||||
assert.match(calls[1].url, /song_key=settings-v1-song/);
|
||||
assert.match(calls[1].url, /provider_id=rig-builder/);
|
||||
assert.equal(calls[2].url, '/api/audio-effects/mappings/7/activate');
|
||||
assert.match(calls[3].url, /\/api\/audio-effects\/active-mapping\?/);
|
||||
});
|
||||
|
||||
test('mapping helpers forward present falsey fields to the server instead of swallowing them', async () => {
|
||||
const window = loadAudioEffects();
|
||||
const calls = [];
|
||||
window.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return { ok: true, status: 200, async json() { return { ok: true }; } };
|
||||
};
|
||||
|
||||
// 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 });
|
||||
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 });
|
||||
assert.match(calls[1].url, /provider_id=0/);
|
||||
|
||||
// An omitted filter stays omitted (no spurious empty filter).
|
||||
await window.slopsmith.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 result = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'record-bridge-hit',
|
||||
source: 'rig_builder',
|
||||
payload: {
|
||||
routeKey: 'desktop-main',
|
||||
bridgeId: 'audio-effects.legacy-nam-routing',
|
||||
pluginId: 'rig_builder',
|
||||
legacySurface: 'fetch /Users/example/song.psarc token=abc123',
|
||||
},
|
||||
});
|
||||
const dbResult = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'record-bridge-hit',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
routeKey: 'desktop-main',
|
||||
bridgeId: 'audio-effects.legacy-tone-db',
|
||||
pluginId: 'nam_tone',
|
||||
legacySurface: 'nam_tone.db tone_mappings /Users/example/nam_tone.db',
|
||||
},
|
||||
});
|
||||
const nativeResult = await api.dispatch({
|
||||
capability: 'audio-effects',
|
||||
command: 'record-bridge-hit',
|
||||
source: 'nam_tone',
|
||||
payload: {
|
||||
routeKey: 'desktop-main',
|
||||
bridgeId: 'audio-effects.legacy-native-load',
|
||||
pluginId: 'nam_tone',
|
||||
legacySurface: 'window.slopsmithDesktop.audio.loadPreset /Users/example/model.nam',
|
||||
},
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
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');
|
||||
assert.equal(sharedShim.status, 'used');
|
||||
assert.equal(sharedShim.hitCount >= 1, true);
|
||||
assert.equal(encoded.includes('audio-effects.legacy-nam-routing'), true);
|
||||
assert.equal(encoded.includes('audio-effects.legacy-tone-db'), true);
|
||||
assert.equal(encoded.includes('audio-effects.legacy-native-load'), true);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('token=abc123'), false);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const AUDIO_EFFECTS_JS = path.join(ROOT, 'static', 'capabilities', 'audio-effects.js');
|
||||
|
||||
function loadAudioEffects(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(AUDIO_EFFECTS_JS, 'utf8'), context, { filename: AUDIO_EFFECTS_JS });
|
||||
window.__vmContext = context;
|
||||
return window;
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window) {
|
||||
return window.slopsmith.audioEffects.snapshot();
|
||||
}
|
||||
|
||||
module.exports = { loadAudioEffects, diagnosticsSnapshot, ROOT };
|
||||
@@ -0,0 +1,72 @@
|
||||
// Verify static/highway.js exposes `getAudioElement()` on the public api
|
||||
// so plugins don't have to reach for `document.getElementById('audio')`
|
||||
// directly. Static + behavioral checks.
|
||||
|
||||
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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
test('highway.api object declares getAudioElement', () => {
|
||||
// Source-level guard: catches a future contributor renaming or
|
||||
// dropping the method silently. The api object lives at the bottom
|
||||
// of the closure; we look for the method's signature inside it.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
// Locate the api block boundaries.
|
||||
const apiStart = src.indexOf('const api = {');
|
||||
assert.ok(apiStart !== -1, 'highway api block not found');
|
||||
// The api block extends to `return api;`.
|
||||
const apiEnd = src.indexOf('return api;', apiStart);
|
||||
assert.ok(apiEnd !== -1, 'api block end (return api) not found');
|
||||
const apiBlock = src.slice(apiStart, apiEnd);
|
||||
|
||||
assert.match(
|
||||
apiBlock,
|
||||
/getAudioElement\s*\(\s*\)\s*\{/,
|
||||
'api object must declare getAudioElement()',
|
||||
);
|
||||
});
|
||||
|
||||
// Brace-balanced extraction so a future getAudioElement that grows
|
||||
// (guards, try/catch, nested object literals) doesn't get truncated by
|
||||
// a naive `[^}]*\}` regex.
|
||||
function extractMethodBody(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('getAudioElement returns the #audio element from document', () => {
|
||||
// Behavioral check: extract the getAudioElement method body and
|
||||
// exercise it against a fake document. Confirms it's calling
|
||||
// getElementById('audio') (not 'btn-play' or anything else).
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const body = extractMethodBody(src, 'getAudioElement()');
|
||||
|
||||
// Wrap it so we can call it standalone.
|
||||
const stub = { id: 'audio-stub' };
|
||||
const sandbox = {
|
||||
document: {
|
||||
getElementById: (id) => (id === 'audio' ? stub : null),
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`globalThis.__getAudioElement = function ${body};`, sandbox);
|
||||
|
||||
const result = sandbox.__getAudioElement();
|
||||
assert.equal(result, stub, 'getAudioElement must return getElementById("audio")');
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, runBrowserScript, installMixerDom, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
|
||||
|
||||
function installAnalyserDom(window) {
|
||||
const audio = { addEventListener() {} };
|
||||
window.document.getElementById = id => (id === 'audio' ? audio : null);
|
||||
window.document.createElement = () => ({ getContext: () => null, style: {}, addEventListener() {}, setAttribute() {} });
|
||||
window.document.addEventListener = () => {};
|
||||
window.document.removeEventListener = () => {};
|
||||
window.Image = class Image {};
|
||||
window.URL = { createObjectURL: () => 'blob:test', revokeObjectURL() {} };
|
||||
window.Blob = class Blob {};
|
||||
window.AudioContext = class AudioContext {
|
||||
constructor() { this.state = 'running'; this.destination = {}; }
|
||||
createMediaElementSource() { return { connect() {} }; }
|
||||
createAnalyser() { return { context: this, frequencyBinCount: 128, fftSize: 0, connect() {}, getByteFrequencyData(data) { data.fill(1); } }; }
|
||||
resume() { return Promise.resolve(); }
|
||||
close() { return Promise.resolve(); }
|
||||
};
|
||||
}
|
||||
|
||||
test('legacy fader API remains compatible while bridge hits are attributed', async () => {
|
||||
const window = loadAudioSession();
|
||||
installMixerDom(window);
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
|
||||
let volume = 0.5;
|
||||
window.slopsmith.audio.registerFader({
|
||||
id: 'plugin.delay',
|
||||
label: 'Delay',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
defaultValue: 0.5,
|
||||
getValue: () => volume,
|
||||
setValue: value => { volume = value; },
|
||||
});
|
||||
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
assert.equal(window.slopsmith.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);
|
||||
});
|
||||
|
||||
test('legacy analyser fallback records bridge status without losing analyser output', () => {
|
||||
const window = loadAudioSession();
|
||||
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');
|
||||
|
||||
assert.equal(analyser.source, 'core');
|
||||
assert.equal(bands.bass > 0, true);
|
||||
assert.equal(bridge.outcome, 'handled');
|
||||
});
|
||||
|
||||
test('barrier and input compatibility surfaces are visible in diagnostics', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.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-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect', outcome: 'denied', reason: 'permission denied' });
|
||||
|
||||
const snapshot = audioSession.snapshot();
|
||||
assert.equal(snapshot.domains['audio-monitoring'].bridges.some(bridge => bridge.bridgeId === 'audio-monitoring.audio-barrier' && bridge.outcome === 'degraded'), true);
|
||||
assert.equal(snapshot.domains['audio-input'].bridges.some(bridge => bridge.bridgeId === 'audio-input.legacy-source' && bridge.outcome === 'denied'), true);
|
||||
});
|
||||
|
||||
test('a legacy bridge hit with unsafe fields never leaks a path/token into diagnostics', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
audioSession.recordBridgeHit({
|
||||
domain: 'audio-input',
|
||||
legacySurface: '/Users/me/legacy token=brk1',
|
||||
participantId: '/Users/me/who token=brk2',
|
||||
logicalSourceKey: '/Users/me/key token=brk3',
|
||||
outcome: 'degraded',
|
||||
reason: 'legacy handoff',
|
||||
});
|
||||
|
||||
const encoded = JSON.stringify(audioSession.snapshot());
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('token=brk1'), false);
|
||||
assert.equal(encoded.includes('token=brk2'), false);
|
||||
assert.equal(encoded.includes('token=brk3'), false);
|
||||
});
|
||||
|
||||
test('audio-input explicit enumeration registers provider sources without list prompting', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'desktop_audio',
|
||||
sourceId: 'bootstrap-source',
|
||||
logicalSourceKey: 'desktop:bootstrap',
|
||||
sources: [
|
||||
{ sourceId: 'desktop-source-2', logicalSourceKey: 'desktop:instrument:secondary', kind: 'instrument', safeLabel: 'Desktop Input 2', channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } },
|
||||
],
|
||||
});
|
||||
|
||||
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'desktop_audio', payload: provider.source });
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
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 after = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
|
||||
assert.equal(enumerated.outcome, 'handled');
|
||||
assert.equal(provider.calls.length, 1);
|
||||
assert.equal(provider.calls[0][0], 'source.enumerate');
|
||||
assert.equal(after.payload.sources.some(source => source.logicalSourceKey === 'desktop:instrument:secondary'), true);
|
||||
});
|
||||
|
||||
test('audio-input native source wins over compatibility-backed duplicate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: 'legacy_input',
|
||||
payload: {
|
||||
sourceId: 'legacy-raw-source',
|
||||
logicalSourceKey: 'shared:instrument:primary',
|
||||
providerId: 'legacy_input',
|
||||
kind: 'instrument',
|
||||
safeLabel: 'Legacy Input',
|
||||
sourceMode: 'compatibility',
|
||||
compatibilitySource: 'navigator.mediaDevices.getUserMedia',
|
||||
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: 'native_input',
|
||||
payload: {
|
||||
sourceId: 'native-raw-source',
|
||||
logicalSourceKey: 'shared:instrument:primary',
|
||||
providerId: 'native_input',
|
||||
kind: 'instrument',
|
||||
safeLabel: 'Native Input',
|
||||
sourceMode: 'native',
|
||||
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
|
||||
},
|
||||
});
|
||||
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
const snapshot = window.slopsmith.audioSession.snapshot().domains['audio-input'];
|
||||
|
||||
assert.equal(listed.payload.sources.length, 1);
|
||||
assert.equal(listed.payload.sources[0].providerId, 'native_input');
|
||||
assert.equal(snapshot.sources.some(source => source.providerId === 'legacy_input' && source.supersededBy), true);
|
||||
assert.equal(snapshot.bridges.some(bridge => bridge.bridgeId === 'audio-input.legacy-source' && bridge.status === 'overshadowed'), true);
|
||||
});
|
||||
|
||||
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 legacy = makeMonitoringProvider({
|
||||
providerId: 'legacy_monitor',
|
||||
logicalMonitoringKey: 'shared:monitor:primary',
|
||||
sourceMode: 'compatibility',
|
||||
compatibilitySource: 'audio-monitoring.audio-barrier',
|
||||
});
|
||||
const native = makeMonitoringProvider({
|
||||
providerId: 'native_monitor',
|
||||
logicalMonitoringKey: 'shared:monitor:primary',
|
||||
sourceMode: 'native',
|
||||
});
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'legacy_monitor', payload: legacy.provider });
|
||||
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'];
|
||||
|
||||
// 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
|
||||
// compatibility layer ever stops overshadowing superseded providers.
|
||||
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);
|
||||
assert.equal(snapshot.bridges.some(bridge => bridge.bridgeId === 'audio-monitoring.audio-barrier' && bridge.status === 'overshadowed'), true);
|
||||
});
|
||||
|
||||
test('legacy registerFader callbacks are usable through audio-mix get and set operations', async () => {
|
||||
const window = loadAudioSession();
|
||||
installMixerDom(window);
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
|
||||
let gain = 0.35;
|
||||
window.slopsmith.audio.registerFader({
|
||||
id: 'plugin.gain',
|
||||
label: 'Plugin Gain',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
defaultValue: 0.35,
|
||||
getValue: () => gain,
|
||||
setValue: value => { gain = Math.min(0.9, value); return gain; },
|
||||
});
|
||||
|
||||
const api = window.slopsmith.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 } });
|
||||
|
||||
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'fader.plugin.gain' && fader.sourceMode === 'compatibility'), true);
|
||||
assert.equal(read.payload.committedValue, 0.35);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, diagnosticsSnapshot, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
|
||||
|
||||
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();
|
||||
|
||||
for (const domain of ['audio-mix', 'audio-input', 'audio-monitoring']) {
|
||||
const pipeline = api.inspect(domain);
|
||||
assert.equal(pipeline.review.lifecycle, 'active');
|
||||
assert.equal(pipeline.participants.some(p => p.pluginId === 'core.audio.session' && p.roles.includes('owner')), true);
|
||||
}
|
||||
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');
|
||||
});
|
||||
|
||||
test('audio session lifecycle and snapshots redact source identity with per-snapshot pseudonyms', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc', songFormat: 'psarc' });
|
||||
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
|
||||
audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' });
|
||||
|
||||
const snapshot = audioSession.snapshot();
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
assert.equal(snapshot.session.songFormat, 'psarc');
|
||||
assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/);
|
||||
assert.equal(encoded.includes('Scarlett'), false);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
});
|
||||
|
||||
test('audio-input diagnostics redact source ids labels handles and bounded reasons', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: 'secret_plugin',
|
||||
payload: {
|
||||
sourceId: 'Built-in Microphone Serial ABC123',
|
||||
logicalSourceKey: 'secret:instrument:primary',
|
||||
providerId: 'secret_plugin',
|
||||
kind: 'instrument',
|
||||
label: 'Built-in Microphone Serial ABC123',
|
||||
availability: 'available',
|
||||
reason: 'Path /Users/barlind/private/project token=abc123 should be redacted',
|
||||
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
operations: ['source.open'],
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', mediaStream: { secret: true }, audioNode: { secret: true } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'secret:instrument:primary' } });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
|
||||
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
assert.equal(encoded.includes('Built-in Microphone'), false);
|
||||
assert.equal(encoded.includes('ABC123'), false);
|
||||
assert.equal(encoded.includes('/Users/barlind'), false);
|
||||
assert.equal(encoded.includes('token=abc123'), false);
|
||||
assert.equal(encoded.includes('mediaStream'), false);
|
||||
assert.equal(encoded.includes('audioNode'), false);
|
||||
assert.match(encoded, /source-\d+/);
|
||||
});
|
||||
|
||||
test('audio-input shares compatible open sessions and closes provider after last release', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: 'provider',
|
||||
payload: {
|
||||
sourceId: 'shared-source',
|
||||
logicalSourceKey: 'shared:instrument:primary',
|
||||
providerId: 'provider',
|
||||
kind: 'instrument',
|
||||
safeLabel: 'Shared Input',
|
||||
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
operations: ['source.open', 'source.close'],
|
||||
operationHandlers: {
|
||||
'source.open': request => { calls.push(['open', request.requesterId]); return { outcome: 'handled' }; },
|
||||
'source.close': request => { calls.push(['close', request.openSessionId]); return { outcome: 'handled' }; },
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'shared:instrument:primary' } });
|
||||
|
||||
const first = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
|
||||
const second = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'practice_overlay', payload: { requesterId: 'practice_overlay', requiredChannelShape: 'mono' } });
|
||||
const closeFirst = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: first.payload.openSessionId } });
|
||||
const closeSecond = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'practice_overlay', payload: { requesterId: 'practice_overlay', openSessionId: second.payload.openSessionId } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
assert.equal(second.outcome, 'handled');
|
||||
assert.equal(first.payload.openSessionId, second.payload.openSessionId);
|
||||
assert.equal(closeFirst.payload.state, 'open');
|
||||
assert.equal(closeSecond.payload.state, 'closed');
|
||||
assert.deepEqual(calls.map(call => call[0]), ['open', 'close']);
|
||||
});
|
||||
|
||||
test('audio diagnostics record bounded runtime outcomes and domain statuses', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.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}` });
|
||||
}
|
||||
|
||||
const snapshot = audioSession.snapshot();
|
||||
assert.equal(snapshot.recentOutcomes.length, 100);
|
||||
assert.equal(snapshot.recentOutcomes.at(-1).status, 'unavailable');
|
||||
assert.equal(snapshot.recentOutcomes.at(-1).outcome, 'degraded');
|
||||
});
|
||||
|
||||
test('disabled missing incompatible unsupported and timeout paths are diagnosable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
audioSession.registerMixParticipant({ participantId: 'disabled-fader', availability: 'disabled' });
|
||||
assert.equal(audioSession.snapshot().domains['audio-mix'].participants[0].availability, 'disabled');
|
||||
|
||||
const missingOwner = await api.dispatch({ capability: 'stems', command: 'inspect', source: 'test' });
|
||||
assert.equal(missingOwner.outcome, 'no-owner');
|
||||
|
||||
const incompatible = await api.dispatch({ capability: 'audio-mix', command: 'register-participant', source: 'test', payload: { participantId: 'bad', version: 2 } });
|
||||
assert.equal(incompatible.outcome, 'incompatible-version');
|
||||
|
||||
const unsupported = await api.dispatch({ capability: 'audio-mix', command: 'not-a-command', source: 'test' });
|
||||
assert.equal(unsupported.outcome, 'unsupported-command');
|
||||
|
||||
api.registerParticipant('slow_audio_probe', {
|
||||
'audio-mix': {
|
||||
roles: ['provider'],
|
||||
commands: ['slow-probe'],
|
||||
runtime: true,
|
||||
handlers: { 'slow-probe': () => new Promise(resolve => setTimeout(() => resolve({ outcome: 'handled' }), 20)) },
|
||||
},
|
||||
});
|
||||
const timedOut = await api.command('audio-mix', 'slow-probe', { requester: 'test', timeoutMs: 1 });
|
||||
assert.equal(timedOut.outcome, 'failed');
|
||||
assert.match(timedOut.reason, /timed out/i);
|
||||
});
|
||||
|
||||
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;
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc' });
|
||||
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] });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' });
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId: 'plugin.delay',
|
||||
ownerPluginId: 'delay',
|
||||
label: 'Delay',
|
||||
kind: 'plugin',
|
||||
sourceMode: 'native',
|
||||
fader: { id: 'wet', label: 'Wet', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
operationHandlers: { 'fader.set-value': () => { throw new Error('failed near /Users/example/secret token=abc'); } },
|
||||
},
|
||||
});
|
||||
const failed = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'wet', value: 0.7 } });
|
||||
const route = await api.dispatch({ capability: 'audio-mix', command: 'inspect-route', source: 'test' });
|
||||
const analyser = await api.dispatch({ capability: 'audio-mix', command: 'inspect-analyser', source: 'test' });
|
||||
const snapshot = audioSession.snapshot();
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(failed.outcome, 'failed');
|
||||
assert.equal(route.payload.routeKind, 'desktop');
|
||||
assert.equal(analyser.payload.source, 'plugin');
|
||||
assert.equal(snapshot.domains['audio-mix'].faders.some(fader => fader.participantId === 'plugin.delay' && fader.sourceMode === 'native'), true);
|
||||
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.bridgeId === 'audio-mix.fader-registry' && bridge.outcome === 'failed'), true);
|
||||
assert.equal(snapshot.recentOutcomes.some(outcome => outcome.operation === 'set-fader-value' && outcome.faderId === 'wet' && outcome.outcome === 'failed'), true);
|
||||
assert.equal(encoded.includes('rawFft'), false);
|
||||
assert.equal(encoded.includes('Secret Studio Output'), false);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('token=abc'), false);
|
||||
assert.equal(encoded.includes('password=abc'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring diagnostics redact provider source session handles and private payloads', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const input = makeInputProvider({
|
||||
providerId: 'secret_input',
|
||||
sourceId: 'USB Interface Hardware ABC1234',
|
||||
logicalSourceKey: 'secret:instrument:primary',
|
||||
label: 'USB Interface Hardware ABC1234',
|
||||
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
openResult: { outcome: 'handled', status: 'open', mediaStream: { raw: true }, audioNode: { raw: true }, samples: [1, 2, 3] },
|
||||
});
|
||||
const monitoring = makeMonitoringProvider({
|
||||
providerId: 'secret_monitor',
|
||||
logicalMonitoringKey: 'secret:monitor:primary',
|
||||
safeLabel: 'Secret Monitor Serial 9988',
|
||||
startResult: {
|
||||
outcome: 'handled',
|
||||
status: 'active',
|
||||
summary: {
|
||||
directMonitor: { state: 'muted', control: 'supported', preference: 'muted', applied: true, reason: 'path /Users/barlind/private token=abc123 waveform raw-audio' },
|
||||
latencySummary: { bucket: 'low', minMs: 2, maxMs: 6, rawBuffer: [1, 2, 3] },
|
||||
mediaStream: { secret: true },
|
||||
nativeHandle: { secret: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: input.source.providerId, payload: input.source });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: input.source.logicalSourceKey } });
|
||||
const providerPayload = { ...monitoring.provider, privatePayload: { password: 'abc123' }, rawAudioBuffer: [1, 2, 3], nativeHandle: { secret: true }, label: 'Secret Monitor Serial 9988' };
|
||||
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' });
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(started.outcome, 'handled');
|
||||
assert.equal(encoded.includes('USB Interface'), false);
|
||||
assert.equal(encoded.includes('ABC1234'), false);
|
||||
assert.equal(encoded.includes('Secret Monitor Serial'), false);
|
||||
assert.equal(encoded.includes('/Users/barlind'), false);
|
||||
assert.equal(encoded.includes('token=abc123'), false);
|
||||
assert.equal(encoded.includes('secret=abc123'), false);
|
||||
assert.equal(encoded.includes('rawAudioBuffer'), false);
|
||||
assert.equal(encoded.includes('rawBuffer'), false);
|
||||
assert.equal(encoded.includes('samples'), false);
|
||||
assert.equal(encoded.includes('waveform'), false);
|
||||
assert.equal(encoded.includes('mediaStream'), false);
|
||||
assert.equal(encoded.includes('audioNode'), false);
|
||||
assert.equal(encoded.includes('nativeHandle'), false);
|
||||
assert.match(encoded, /monitoring-\d+/);
|
||||
assert.match(encoded, /source-\d+/);
|
||||
});
|
||||
@@ -0,0 +1,754 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, captureEvents, makeInputProvider, makeMonitoringProvider } = require('./audio_session_test_harness');
|
||||
|
||||
async function registerSource(api, payload = {}) {
|
||||
return api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: payload.providerId || 'test',
|
||||
payload: {
|
||||
sourceId: 'source-raw-id-12345',
|
||||
logicalSourceKey: 'test:instrument:primary',
|
||||
providerId: 'test_provider',
|
||||
kind: 'instrument',
|
||||
safeLabel: 'Input 1',
|
||||
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
operations: ['source.open', 'source.close'],
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open', mediaStream: { secret: true } }),
|
||||
'source.close': () => ({ outcome: 'handled', status: 'closed', nativeHandle: { secret: true } }),
|
||||
},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('audio-input requires sourceId providerId and logicalSourceKey', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' } });
|
||||
const missingLogical = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', providerId: 'test' } });
|
||||
const incompatible = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'test', payload: { sourceId: 'source-1', providerId: 'test', logicalSourceKey: 'test:key', version: 99 } });
|
||||
|
||||
assert.equal(missingSource.outcome, 'failed');
|
||||
assert.equal(missingProvider.outcome, 'failed');
|
||||
assert.equal(missingLogical.outcome, 'failed');
|
||||
assert.equal(incompatible.outcome, 'incompatible-version');
|
||||
});
|
||||
|
||||
test('audio-input registration list inspect select and snapshots pseudonymize source identity', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const registeredEvents = captureEvents(window, 'audio-input:source-registered');
|
||||
const selectedEvents = captureEvents(window, 'audio-input:source-selected');
|
||||
|
||||
const registered = await registerSource(api, { label: 'Scarlett 2i2 Serial 987654' });
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
|
||||
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'note_detect' });
|
||||
const selected = await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'note_detect', payload: { logicalSourceKey: 'test:instrument:primary' } });
|
||||
const missing = await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'note_detect', payload: { sourceId: 'missing-device' } });
|
||||
|
||||
assert.equal(registered.status, 'applied');
|
||||
assert.equal(listed.payload.sources.length, 1);
|
||||
assert.equal(inspected.payload.totalSources, 1);
|
||||
assert.equal(selected.status, 'applied');
|
||||
assert.equal(selected.payload.logicalSourceKey, 'test:instrument:primary');
|
||||
assert.equal(missing.outcome, 'degraded');
|
||||
assert.match(registered.payload.sourceId, /^source-\d+$/);
|
||||
assert.match(selected.payload.sourceId, /^source-\d+$/);
|
||||
assert.equal(registeredEvents.length, 1);
|
||||
assert.equal(selectedEvents.length, 1);
|
||||
|
||||
const encoded = JSON.stringify(window.slopsmith.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);
|
||||
});
|
||||
|
||||
test('audio-input pseudonyms are per-bundle: distinct within a snapshot, never leak raw identity', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.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' });
|
||||
|
||||
const inputDomain = audioSession.snapshot().domains['audio-input'];
|
||||
const pseudonyms = inputDomain.sources.map(source => source.diagnosticsPseudonym);
|
||||
|
||||
// Per-bundle pseudonyms are distinct within one snapshot (spec FR-011/SC-005).
|
||||
assert.equal(new Set(pseudonyms).size, pseudonyms.length);
|
||||
for (const pseudonym of pseudonyms) assert.match(pseudonym, /^source-\d+$/);
|
||||
|
||||
// Raw source ids/labels never leak into diagnostics.
|
||||
const encoded = JSON.stringify(inputDomain);
|
||||
assert.equal(encoded.includes('mic-A'), false);
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
});
|
||||
|
||||
test('audio-input degraded select and unknown unregister never leak the raw source id', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
const degraded = audioSession.selectInputSource('/Users/me/secret-device', 'note_detect');
|
||||
const removed = audioSession.unregisterInputSource('/Users/me/secret-device');
|
||||
|
||||
assert.equal(degraded.outcome, 'degraded');
|
||||
assert.match(degraded.payload.sourceId, /^source-\d+$/);
|
||||
assert.equal(removed.outcome, 'no-handler');
|
||||
assert.match(removed.payload.sourceId, /^source-\d+$/);
|
||||
|
||||
const encoded = JSON.stringify({ degraded, removed, snapshot: audioSession.snapshot() });
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('secret-device'), false);
|
||||
});
|
||||
|
||||
test('unknown unregister echoes the requested logicalSourceKey/providerId without pseudonymizing them', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
const removed = audioSession.unregisterInputSource({ logicalSourceKey: 'note_detect:instrument:primary', providerId: 'note_detect' });
|
||||
|
||||
assert.equal(removed.outcome, 'no-handler');
|
||||
// The logical key is a redaction-safe handle — echoed back verbatim, not pseudonymized as a sourceId.
|
||||
assert.equal(removed.payload.logicalSourceKey, 'note_detect:instrument:primary');
|
||||
assert.equal(removed.payload.providerId, 'note_detect');
|
||||
assert.equal(removed.payload.sourceId, '');
|
||||
assert.equal(removed.payload.removed, false);
|
||||
});
|
||||
|
||||
test('unregister-source uses providerId to target the right source among logical-key duplicates', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.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' });
|
||||
|
||||
// Without providerId, priority resolves to the native winner; the compat providerId must target compat.
|
||||
const removed = audioSession.unregisterInputSource({ logicalSourceKey: 'dup:disambig', providerId: 'compat_p' });
|
||||
|
||||
assert.equal(removed.outcome, 'handled');
|
||||
assert.equal(removed.payload.providerId, 'compat_p');
|
||||
assert.equal(audioSession.snapshot().domains['audio-input'].totalSources, 1);
|
||||
});
|
||||
|
||||
test('register-source rejects a sourceId already owned by another provider', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' } });
|
||||
// Re-registering the same source by the same provider is still an update, not a collision.
|
||||
const update = await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'prov_a', payload: { sourceId: 'shared-id', logicalSourceKey: 'a:key', providerId: 'prov_a' } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
assert.equal(collision.outcome, 'failed');
|
||||
assert.equal(update.outcome, 'handled');
|
||||
// The original provider's source survives the rejected collision.
|
||||
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'a:key' } });
|
||||
assert.equal(inspected.payload.source.providerId, 'prov_a');
|
||||
});
|
||||
|
||||
test('register-source rejects a logicalSourceKey that is not redaction-safe', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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());
|
||||
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;
|
||||
|
||||
// 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.
|
||||
const result = await audioSession.enumerateInputSources({ providerId: '/Users/me/p token=xyz789' });
|
||||
|
||||
assert.equal(result.outcome, 'denied');
|
||||
const outcomes = audioSession.snapshot().recentOutcomes;
|
||||
assert.equal(outcomes.some(outcome => outcome.operation === 'source.enumerate' && outcome.outcome === 'denied'), true);
|
||||
const encoded = JSON.stringify(audioSession.snapshot());
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('token=xyz789'), false);
|
||||
});
|
||||
|
||||
test('a malicious dispatch source is redacted before becoming a requesterId in diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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());
|
||||
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;
|
||||
|
||||
// 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.
|
||||
const selected = audioSession.selectInputSource({ logicalSourceKey: '/Users/me/secret token=abc123' }, 'note_detect');
|
||||
const removed = audioSession.unregisterInputSource({ logicalSourceKey: '/Users/me/secret token=abc123', providerId: '/Users/me/provider' });
|
||||
|
||||
assert.equal(selected.outcome, 'degraded');
|
||||
assert.equal(removed.outcome, 'no-handler');
|
||||
const encoded = JSON.stringify({ selected, removed, snapshot: audioSession.snapshot() });
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('token=abc123'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring distinguishes a failed state from transient unavailability', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await registerSource(api);
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:instrument:primary' } });
|
||||
const failedProvider = makeMonitoringProvider({ providerId: 'mon-failed', startResult: { outcome: 'failed', status: 'failed', reason: 'JUCE barrier failed' } });
|
||||
const unavailableProvider = makeMonitoringProvider({ providerId: 'mon-unavail', availability: 'unavailable' });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'mon-failed', payload: failedProvider.provider });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'mon-unavail', payload: unavailableProvider.provider });
|
||||
|
||||
const failed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: 'mon-failed', requesterId: 'note_detect', authorization: 'user-action' } });
|
||||
const unavailable = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: 'mon-unavail', requesterId: 'note_detect', authorization: 'user-action' } });
|
||||
|
||||
assert.equal(failed.outcome, 'failed');
|
||||
assert.equal(failed.payload.state, 'failed');
|
||||
assert.equal(unavailable.outcome, 'unavailable');
|
||||
assert.equal(unavailable.payload.availability, 'unavailable');
|
||||
});
|
||||
|
||||
test('inspect list and select do not call provider enumeration or open handlers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const provider = makeInputProvider({ providerId: 'note_detect', logicalSourceKey: 'note_detect:instrument:primary' });
|
||||
|
||||
await registerSource(api, provider.source);
|
||||
await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'test' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'test', payload: { logicalSourceKey: 'note_detect:instrument:primary' } });
|
||||
|
||||
assert.deepEqual(provider.calls, []);
|
||||
});
|
||||
|
||||
test('open-source and close-source record outcomes events and no live handles', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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');
|
||||
const deniedEvents = captureEvents(window, 'audio-input:permission-denied');
|
||||
|
||||
await registerSource(api);
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:instrument:primary' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono', purpose: 'note-detection' } });
|
||||
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
|
||||
|
||||
assert.equal(open.outcome, 'handled');
|
||||
assert.equal(open.payload.state, 'open');
|
||||
assert.equal(close.outcome, 'handled');
|
||||
assert.equal(close.payload.state, 'closed');
|
||||
assert.equal(openedEvents.length, 1);
|
||||
assert.equal(closedEvents.length, 1);
|
||||
|
||||
await registerSource(api, { sourceId: 'denied-source', logicalSourceKey: 'test:denied', availability: 'denied', reason: 'permission denied token=abc' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:denied' } });
|
||||
const denied = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(denied.outcome, 'denied');
|
||||
assert.equal(deniedEvents.length, 1);
|
||||
|
||||
await registerSource(api, { sourceId: 'mono-only', logicalSourceKey: 'test:mono', channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:mono' } });
|
||||
const incompatible = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'stereo' } });
|
||||
assert.equal(incompatible.outcome, 'degraded');
|
||||
assert.equal(incompatible.payload.state, 'incompatible');
|
||||
assert.equal(degradedEvents.length >= 1, true);
|
||||
|
||||
const encoded = JSON.stringify(window.slopsmith.audioSession.snapshot());
|
||||
assert.equal(encoded.includes('mediaStream'), false);
|
||||
assert.equal(encoded.includes('nativeHandle'), false);
|
||||
assert.equal(encoded.includes('token=abc'), false);
|
||||
});
|
||||
|
||||
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 noOwner = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(noOwner.outcome, 'no-owner');
|
||||
|
||||
await registerSource(api, { sourceId: 'no-open', logicalSourceKey: 'test:no-open', providerId: 'no_open_provider', operations: [], operationHandlers: {} });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:no-open' } });
|
||||
const unsupported = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(unsupported.outcome, 'unsupported-command');
|
||||
|
||||
await registerSource(api, { sourceId: 'no-handler', logicalSourceKey: 'test:no-handler', providerId: 'no_handler_provider', operations: ['source.open'], operationHandlers: {} });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:no-handler' } });
|
||||
const noHandler = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(noHandler.outcome, 'no-handler');
|
||||
|
||||
await registerSource(api, { sourceId: 'failed', logicalSourceKey: 'test:failed', providerId: 'failed_provider', operationHandlers: { 'source.open': () => { throw new Error('failed near /Users/example/source'); } } });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:failed' } });
|
||||
const failed = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(failed.outcome, 'failed');
|
||||
|
||||
await registerSource(api, { sourceId: 'malformed', logicalSourceKey: 'test:malformed', providerId: 'malformed_provider', operationHandlers: { 'source.open': () => 'ok' } });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:malformed' } });
|
||||
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');
|
||||
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);
|
||||
assert.equal(outcomes.some(outcome => outcome.outcome === 'failed'), true);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
await registerSource(api, { sourceId: 'primary-raw', logicalSourceKey: 'test:primary' });
|
||||
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:primary' } });
|
||||
|
||||
const bySourceId = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'other-raw' } });
|
||||
assert.equal(bySourceId.outcome, 'degraded');
|
||||
const byKey = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:other' } });
|
||||
assert.equal(byKey.outcome, 'degraded');
|
||||
|
||||
const selectedOpen = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(selectedOpen.outcome, 'handled');
|
||||
assert.equal(selectedOpen.payload.logicalSourceKey, 'test:primary');
|
||||
});
|
||||
|
||||
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 deniedEvents = captureEvents(window, 'audio-input:permission-denied');
|
||||
|
||||
await registerSource(api, { sourceId: 'predenied-raw', logicalSourceKey: 'test:predenied', availability: 'denied', reason: 'permission denied' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:predenied' } });
|
||||
const denied = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
|
||||
|
||||
assert.equal(denied.outcome, 'denied');
|
||||
const payload = deniedEvents[0].payload;
|
||||
assert.ok(payload.openSessionId);
|
||||
assert.equal(payload.requesters[0].requesterId, 'note_detect');
|
||||
assert.equal(payload.requesters[0].purpose, 'note-detection');
|
||||
// Requester entries match the real open-session shape — no per-requester openedAt.
|
||||
assert.equal('openedAt' in payload.requesters[0], false);
|
||||
});
|
||||
|
||||
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 degraded = captureEvents(window, 'audio-input:source-open-degraded');
|
||||
|
||||
// No source selected -> degraded; the event must share the OpenInputSessionSummary schema.
|
||||
const result = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
|
||||
|
||||
assert.equal(result.outcome, 'no-owner');
|
||||
const payload = degraded[degraded.length - 1].payload;
|
||||
assert.ok(payload.openSessionId);
|
||||
assert.equal(payload.state, 'unavailable');
|
||||
assert.equal(payload.requesters[0].requesterId, 'note_detect');
|
||||
assert.equal('openedAt' in payload.requesters[0], false);
|
||||
});
|
||||
|
||||
test('open-source reports the selected source as unavailable when no matching source is registered', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' } });
|
||||
// The provider unregisters the source while it is still the selected logical key.
|
||||
await api.dispatch({ capability: 'audio-input', command: 'unregister-source', source: 'test', payload: { logicalSourceKey: 'test:gone', providerId: 'test_provider' } });
|
||||
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
|
||||
assert.equal(open.outcome, 'degraded');
|
||||
// Distinct, accurate message — not the misleading "not the selected source".
|
||||
assert.match(open.reason, /selected input source is unavailable/i);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
await registerSource(api, { sourceId: 'owned-raw', logicalSourceKey: 'test:owned' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:owned' } });
|
||||
|
||||
// The authenticated dispatch caller is note_detect; a spoofed payload requesterId must be ignored.
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'victim_plugin' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
assert.equal(open.payload.requesters.some(item => item.requesterId === 'note_detect'), true);
|
||||
assert.equal(open.payload.requesters.some(item => item.requesterId === 'victim_plugin'), false);
|
||||
|
||||
// A different caller spoofing note_detect's id cannot release note_detect's reference.
|
||||
const spoofClose = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'attacker', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
|
||||
assert.equal(spoofClose.outcome, 'handled');
|
||||
assert.equal(spoofClose.payload.state, 'open');
|
||||
assert.equal(spoofClose.payload.requesters.some(item => item.requesterId === 'note_detect'), true);
|
||||
});
|
||||
|
||||
test('open-source and close-source propagate a provider non-handled outcome exactly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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: {
|
||||
'source.open': () => ({ outcome: 'unsupported-command', reason: 'no can do' }),
|
||||
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
|
||||
} });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:po' } });
|
||||
const openUnsupported = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(openUnsupported.outcome, 'unsupported-command');
|
||||
|
||||
// source.close returns an explicit non-failed outcome -> propagate exactly.
|
||||
await registerSource(api, { sourceId: 'pc-raw', logicalSourceKey: 'test:pc', providerId: 'pc_provider', operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open' }),
|
||||
'source.close': () => ({ outcome: 'unsupported-command', reason: 'nope' }),
|
||||
} });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:pc' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
const closeUnsupported = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', openSessionId: open.payload.openSessionId } });
|
||||
assert.equal(closeUnsupported.outcome, 'unsupported-command');
|
||||
});
|
||||
|
||||
test('close-source accepts logicalKey/sourceKey aliases like the other audio-input paths', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
// Close by the `logicalKey` alias (no openSessionId) — must resolve the same session.
|
||||
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalKey: 'test:alias' } });
|
||||
assert.equal(close.outcome, 'handled');
|
||||
assert.equal(close.payload.state, 'closed');
|
||||
});
|
||||
|
||||
test('close-source resolves by logicalSourceKey when requiredChannelShape is omitted', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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).
|
||||
await registerSource(api, { sourceId: 'closable-raw', logicalSourceKey: 'test:closable' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:closable' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
const close = await api.dispatch({ capability: 'audio-input', command: 'close-source', source: 'note_detect', payload: { requesterId: 'note_detect', logicalSourceKey: 'test:closable' } });
|
||||
assert.equal(close.outcome, 'handled');
|
||||
assert.equal(close.payload.state, 'closed');
|
||||
});
|
||||
|
||||
test('close-source with an explicit wrong requiredChannelShape does not close a differently-shaped session', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:shaped' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 } });
|
||||
assert.equal(right.outcome, 'handled');
|
||||
});
|
||||
|
||||
test('open-source rejects a non-selected duplicate sharing the logical key, addressed by sourceId', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
await registerSource(api, { sourceId: 'compat-raw', logicalSourceKey: 'dup:key', providerId: 'compat_provider', compatibilitySource: 'legacy browser handoff' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'dup:key' } });
|
||||
|
||||
// Opening by the compatibility duplicate's sourceId must not switch to it.
|
||||
const wrong = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'compat-raw' } });
|
||||
assert.equal(wrong.outcome, 'degraded');
|
||||
|
||||
// The selected native winner still opens, routed to its own provider.
|
||||
const right = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'native-raw' } });
|
||||
assert.equal(right.outcome, 'handled');
|
||||
assert.equal(right.payload.providerId, 'native_provider');
|
||||
});
|
||||
|
||||
test('inspect resolves a raw sourceId to its source via the stable logical key', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'inspect-raw-id', logicalSourceKey: 'test:inspectable' });
|
||||
|
||||
// The snapshot pseudonymizes sourceId, so inspect must resolve the raw provider sourceId itself.
|
||||
const byRaw = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { sourceId: 'inspect-raw-id' } });
|
||||
assert.ok(byRaw.payload.source);
|
||||
assert.equal(byRaw.payload.source.logicalSourceKey, 'test:inspectable');
|
||||
|
||||
const byKey = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'test:inspectable' } });
|
||||
assert.ok(byKey.payload.source);
|
||||
assert.equal(byKey.payload.source.logicalSourceKey, 'test:inspectable');
|
||||
});
|
||||
|
||||
test('inspect by a shared logical key returns the native winner, not a suppressed duplicate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
await registerSource(api, { sourceId: 'native-second', logicalSourceKey: 'dup:inspect', providerId: 'native_p' });
|
||||
|
||||
const inspected = await api.dispatch({ capability: 'audio-input', command: 'inspect', source: 'test', payload: { logicalSourceKey: 'dup:inspect' } });
|
||||
|
||||
assert.ok(inspected.payload.source);
|
||||
assert.equal(inspected.payload.source.providerId, 'native_p');
|
||||
assert.equal(inspected.payload.source.sourceMode, 'native');
|
||||
});
|
||||
|
||||
test('enumerate preserves a provider-supplied safeLabel through redaction', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'labelled',
|
||||
sourceId: 'labelled-bootstrap',
|
||||
logicalSourceKey: 'labelled:bootstrap',
|
||||
sources: [{ sourceId: 'lbl-1', logicalSourceKey: 'lbl:one', kind: 'instrument', safeLabel: 'Studio Mic' }],
|
||||
});
|
||||
audioSession.registerInputSource(provider.source);
|
||||
|
||||
const enumerated = await audioSession.enumerateInputSources({ providerId: 'labelled', explicit: true, requesterId: 'settings' });
|
||||
|
||||
assert.equal(enumerated.outcome, 'handled');
|
||||
const registered = enumerated.payload.sources.find(source => source.logicalSourceKey === 'lbl:one');
|
||||
assert.ok(registered);
|
||||
// The explicitly-safe label survives _safeInputValue instead of falling back to a pseudonym.
|
||||
assert.equal(registered.label, 'Studio Mic');
|
||||
});
|
||||
|
||||
test('enumerate returns no-handler when providers exist but none support source.enumerate', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
audioSession.registerInputSource({ sourceId: 'nohandler-raw', logicalSourceKey: 'nh:key', providerId: 'nh_provider', operations: ['source.open'], operationHandlers: { 'source.open': () => ({ outcome: 'handled', status: 'open' }) } });
|
||||
|
||||
const result = await audioSession.enumerateInputSources({ providerId: 'nh_provider', explicit: true, requesterId: 'settings' });
|
||||
assert.equal(result.outcome, 'no-handler');
|
||||
});
|
||||
|
||||
test('open-source hint mismatch echoes a pseudonymized sourceId hint without leaking the raw id', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await registerSource(api, { sourceId: 'sel-raw', logicalSourceKey: 'test:sel' });
|
||||
await registerSource(api, { sourceId: 'other-raw', logicalSourceKey: 'test:other' });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:sel' } });
|
||||
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', sourceId: 'other-raw' } });
|
||||
|
||||
assert.equal(open.outcome, 'degraded');
|
||||
assert.match(open.payload.sourceId, /^source-\d+$/);
|
||||
const encoded = JSON.stringify({ open, snapshot: window.slopsmith.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 provider = makeInputProvider({
|
||||
providerId: 'denier',
|
||||
sourceId: 'denier-bootstrap',
|
||||
logicalSourceKey: 'denier:bootstrap',
|
||||
operationHandlers: {
|
||||
'source.enumerate': () => ({ outcome: 'denied', reason: 'user declined microphone' }),
|
||||
},
|
||||
});
|
||||
audioSession.registerInputSource(provider.source);
|
||||
|
||||
const enumerated = await audioSession.enumerateInputSources({ providerId: 'denier', explicit: true, requesterId: 'settings' });
|
||||
|
||||
assert.equal(enumerated.outcome, 'denied');
|
||||
const outcomes = audioSession.snapshot().recentOutcomes;
|
||||
assert.equal(outcomes.some(outcome => outcome.operation === 'source.enumerate' && outcome.outcome === 'denied'), true);
|
||||
|
||||
// A non-denied/failed explicit outcome is preserved exactly (not collapsed to degraded).
|
||||
const incompatible = makeInputProvider({
|
||||
providerId: 'oldproto',
|
||||
sourceId: 'oldproto-bootstrap',
|
||||
logicalSourceKey: 'oldproto:bootstrap',
|
||||
operationHandlers: { 'source.enumerate': () => ({ outcome: 'incompatible-version', reason: 'needs v1' }) },
|
||||
});
|
||||
audioSession.registerInputSource(incompatible.source);
|
||||
const enumeratedOld = await audioSession.enumerateInputSources({ providerId: 'oldproto', explicit: true, requesterId: 'settings' });
|
||||
assert.equal(enumeratedOld.outcome, 'incompatible-version');
|
||||
});
|
||||
|
||||
test('enumerateInputSources returns distinct sourceId pseudonyms across sources', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const provider = makeInputProvider({
|
||||
providerId: 'multi_provider',
|
||||
sourceId: 'mp-bootstrap',
|
||||
logicalSourceKey: 'mp:bootstrap',
|
||||
sources: [
|
||||
{ sourceId: 'mp-raw-1', logicalSourceKey: 'mp:one', kind: 'instrument', safeLabel: 'In 1' },
|
||||
{ sourceId: 'mp-raw-2', logicalSourceKey: 'mp:two', kind: 'instrument', safeLabel: 'In 2' },
|
||||
],
|
||||
});
|
||||
audioSession.registerInputSource(provider.source);
|
||||
const enumerated = await audioSession.enumerateInputSources({ providerId: 'multi_provider', explicit: true, requesterId: 'settings' });
|
||||
|
||||
assert.equal(enumerated.outcome, 'handled');
|
||||
assert.equal(enumerated.payload.sources.length, 2);
|
||||
const ids = enumerated.payload.sources.map(source => source.sourceId);
|
||||
ids.forEach(id => assert.match(id, /^source-\d+$/));
|
||||
// Distinct sources must get distinct pseudonyms, not all collapse to `source-01`.
|
||||
assert.equal(new Set(ids).size, 2);
|
||||
});
|
||||
|
||||
test('open-session ids correlate between openSessions and recentOutcomes in a snapshot', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
const openId = open.payload.openSessionId;
|
||||
const snap = window.slopsmith.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);
|
||||
// The generated input-open id is left verbatim so an outcome correlates with its open session.
|
||||
assert.equal(openOutcome.openSessionId, openId);
|
||||
});
|
||||
|
||||
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;
|
||||
let providerClosed = 0;
|
||||
|
||||
await registerSource(api, {
|
||||
sourceId: 'restart-raw',
|
||||
logicalSourceKey: 'test:restart',
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open' }),
|
||||
'source.close': () => { providerClosed += 1; return { outcome: 'handled', status: 'closed' }; },
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:restart' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
// A direct song-switch startSession() (no stopSession first) must still release provider capture.
|
||||
audioSession.startSession({ sessionId: 'main:next-song' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.equal(providerClosed, 1);
|
||||
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 0);
|
||||
});
|
||||
|
||||
test('a persisted selected-source key that is not redaction-safe is ignored on restore', () => {
|
||||
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' });
|
||||
|
||||
assert.equal(snap.domains['audio-input'].selected, null);
|
||||
const encoded = JSON.stringify(snap);
|
||||
assert.equal(encoded.includes('/Users/me'), false);
|
||||
assert.equal(encoded.includes('token=zzz999'), false);
|
||||
});
|
||||
|
||||
test('stopSession closes open input sessions and notifies providers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const closedEvents = captureEvents(window, 'audio-input:source-closed');
|
||||
let providerClosed = 0;
|
||||
|
||||
await registerSource(api, {
|
||||
sourceId: 'live-raw',
|
||||
logicalSourceKey: 'test:live',
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled', status: 'open' }),
|
||||
'source.close': () => { providerClosed += 1; return { outcome: 'handled', status: 'closed' }; },
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:live' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 1);
|
||||
|
||||
audioSession.stopSession('song switch');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.equal(audioSession.snapshot().domains['audio-input'].totalOpenSessions, 0);
|
||||
assert.equal(closedEvents.length >= 1, true);
|
||||
assert.equal(providerClosed, 1);
|
||||
});
|
||||
|
||||
test('startSession keeps the in-memory selection when persistence has failed, ignoring stale storage', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
// Seed storage with a stale key, then make subsequent writes fail.
|
||||
window.localStorage.setItem('slopsmith.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');
|
||||
|
||||
const snap = window.slopsmith.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');
|
||||
assert.equal(snap.domains['audio-input'].storageStatus, 'failed');
|
||||
});
|
||||
|
||||
test('selected source persistence restore and storage-unavailable fallback are stable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
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;
|
||||
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' });
|
||||
assert.equal(noStorageSnapshot.domains['audio-input'].storageStatus, 'failed');
|
||||
assert.equal(noStorageSnapshot.domains['audio-input'].selected.logicalSourceKey, 'session:input');
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
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 events = [];
|
||||
window.slopsmith.on('audio-mix:participant-registered', event => events.push(event.detail.payload.participantId));
|
||||
|
||||
const registered = await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId: 'plugin.delay',
|
||||
ownerPluginId: 'delay_plugin',
|
||||
label: 'Delay Return',
|
||||
kind: 'plugin',
|
||||
fader: { id: 'wet', label: 'Wet', min: 0, max: 1, step: 0.01, defaultValue: 0.5, currentValue: 0.6 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
},
|
||||
});
|
||||
const inspected = await api.dispatch({ capability: 'audio-mix', command: 'inspect', source: 'test' });
|
||||
const removed = await api.dispatch({ capability: 'audio-mix', command: 'unregister-participant', source: 'test', payload: { participantId: 'plugin.delay' } });
|
||||
|
||||
assert.equal(registered.status, 'applied');
|
||||
assert.equal(registered.payload.participantId, 'plugin.delay');
|
||||
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);
|
||||
});
|
||||
|
||||
test('audio-mix registration reports incompatible participants explicitly', async () => {
|
||||
const window = loadAudioSession();
|
||||
const result = await window.slopsmith.capabilities.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: { participantId: 'future.plugin', version: 2 },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'incompatible-version');
|
||||
assert.equal(result.outcome, 'incompatible-version');
|
||||
assert.equal(window.slopsmith.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;
|
||||
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
|
||||
|
||||
let pluginValue = 0.25;
|
||||
for (const [participantId, kind] of [
|
||||
['core.song', 'song'],
|
||||
['plugin.delay', 'plugin'],
|
||||
['stems.master', 'stem'],
|
||||
['monitoring.input', 'monitoring'],
|
||||
['preview.player', 'preview'],
|
||||
]) {
|
||||
const isPlugin = participantId === 'plugin.delay';
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId,
|
||||
ownerPluginId: participantId.split('.')[0],
|
||||
label: kind,
|
||||
kind,
|
||||
fader: { id: 'volume', label: `${kind} volume`, min: 0, max: 1, step: 0.05, defaultValue: 0.5, currentValue: isPlugin ? pluginValue : 0.5 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
operationHandlers: isPlugin ? {
|
||||
'fader.get-value': () => pluginValue,
|
||||
'fader.set-value': value => { pluginValue = Math.round(value * 10) / 10; return { committedValue: pluginValue }; },
|
||||
} : {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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: 'plugin.delay', faderId: 'volume' } });
|
||||
const started = Date.now();
|
||||
const written = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'volume', value: 0.76 } });
|
||||
const latency = Date.now() - started;
|
||||
const clamped = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.delay', faderId: 'volume', value: 5 } });
|
||||
|
||||
assert.equal(listed.status, 'applied');
|
||||
for (const kind of ['song', 'plugin', 'stem', 'monitoring', 'preview']) assert.equal(listed.payload.requiredKinds[kind], true, kind);
|
||||
assert.equal(read.payload.committedValue, 0.25);
|
||||
assert.equal(written.payload.requestedValue, 0.76);
|
||||
assert.equal(written.payload.committedValue, 0.8);
|
||||
assert.equal(latency < 500, true, `committed display latency ${latency}ms`);
|
||||
assert.equal(clamped.payload.normalizedValue, 1);
|
||||
assert.equal(clamped.payload.committedValue, 1);
|
||||
});
|
||||
|
||||
test('audio-mix reports invalid unavailable and timed-out fader operations', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId: 'plugin.disabled',
|
||||
ownerPluginId: 'plugin.disabled',
|
||||
label: 'Disabled',
|
||||
kind: 'plugin',
|
||||
availability: 'unavailable',
|
||||
fader: { id: 'volume', label: 'Disabled', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
},
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId: 'plugin.slow',
|
||||
ownerPluginId: 'plugin.slow',
|
||||
label: 'Slow',
|
||||
kind: 'plugin',
|
||||
fader: { id: 'volume', label: 'Slow', min: 0, max: 1, step: 0.1, defaultValue: 0.4, currentValue: 0.4 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
operationHandlers: { 'fader.set-value': () => new Promise(() => {}) },
|
||||
},
|
||||
});
|
||||
|
||||
const invalid = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.slow', faderId: 'volume', value: 'loud' } });
|
||||
const unavailable = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.disabled', faderId: 'volume', value: 0.8 } });
|
||||
const started = Date.now();
|
||||
const timedOut = await api.dispatch({ capability: 'audio-mix', command: 'set-fader-value', source: 'test', payload: { participantId: 'plugin.slow', faderId: 'volume', value: 0.8 } });
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
assert.equal(invalid.outcome, 'denied');
|
||||
assert.equal(unavailable.outcome, 'degraded');
|
||||
assert.equal(events.includes('plugin.disabled'), true);
|
||||
assert.equal(timedOut.outcome, 'failed');
|
||||
assert.equal(timedOut.payload.timedOut, true);
|
||||
assert.equal(timedOut.payload.committedValue, 0.4);
|
||||
assert.equal(elapsed >= 1900 && elapsed < 2600, true, `timeout elapsed ${elapsed}ms`);
|
||||
});
|
||||
|
||||
test('audio-mix keeps pre-session participants pending then attaches them on session start', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'audio-mix',
|
||||
command: 'register-participant',
|
||||
source: 'test',
|
||||
payload: {
|
||||
participantId: 'plugin.presession',
|
||||
ownerPluginId: 'plugin.presession',
|
||||
label: 'Pre-session',
|
||||
kind: 'plugin',
|
||||
fader: { id: 'volume', label: 'Pre-session', min: 0, max: 1, step: 0.1, defaultValue: 0.3, currentValue: 0.3 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
},
|
||||
});
|
||||
const pending = await api.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
||||
window.slopsmith.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');
|
||||
assert.equal(active.payload.faders.find(fader => fader.participantId === 'plugin.presession').availability, 'available');
|
||||
});
|
||||
|
||||
test('audio-mix registration is idempotent and song switching keeps known participants without stale route', async () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song' });
|
||||
audioSession.setRoute({ routeKind: 'stems', availability: 'available' });
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
audioSession.registerMixParticipant({
|
||||
participantId: 'plugin.rehydrated',
|
||||
ownerPluginId: 'plugin.rehydrated',
|
||||
label: 'Rehydrated',
|
||||
kind: 'plugin',
|
||||
fader: { id: 'volume', label: 'Rehydrated', min: 0, max: 1, step: 0.1, defaultValue: 0.5, currentValue: 0.5 + i * 0.1 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
});
|
||||
}
|
||||
const beforeStop = await window.slopsmith.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' });
|
||||
|
||||
assert.equal(beforeStop.payload.faders.filter(fader => fader.participantId === 'plugin.rehydrated').length, 1);
|
||||
assert.equal(stopped.domains['audio-mix'].route.availability, 'unavailable');
|
||||
assert.equal(afterStart.payload.faders.filter(fader => fader.participantId === 'plugin.rehydrated').length, 1);
|
||||
assert.equal(afterStart.payload.faders.find(fader => fader.participantId === 'plugin.rehydrated').availability, 'available');
|
||||
});
|
||||
test('re-registering a mix participant without handlers preserves the existing set-fader-value handler', async () => {
|
||||
// Regression for the song-volume mixer no-op: _applySongVolume() runs on
|
||||
// every song load and re-registers core.song WITHOUT get/set handlers.
|
||||
// registerMixParticipant replaces the participant, so before the fix this
|
||||
// wiped the fader.set-value handler installed at init — the mixer slider
|
||||
// then moved visually but never applied the volume (PSARC and sloppak).
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
|
||||
|
||||
const applied = [];
|
||||
// Initial registration WITH handlers (mirrors _registerSongFader()).
|
||||
audioSession.registerMixParticipant({
|
||||
participantId: 'core.song',
|
||||
ownerPluginId: 'core',
|
||||
label: 'Song',
|
||||
kind: 'song',
|
||||
sourceMode: 'core',
|
||||
fader: { id: 'song', label: 'Song', unit: '%', min: 0, max: 100, step: 1, defaultValue: 80, currentValue: 80 },
|
||||
operations: ['fader.get-value', 'fader.set-value'],
|
||||
operationHandlers: {
|
||||
'fader.get-value': () => 80,
|
||||
'fader.set-value': (value) => { applied.push(value); return value; },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-registration WITHOUT handlers (mirrors _applySongVolume()'s spec).
|
||||
audioSession.registerMixParticipant({
|
||||
participantId: 'core.song',
|
||||
ownerPluginId: 'core',
|
||||
label: 'Song',
|
||||
kind: 'song',
|
||||
sourceMode: 'core',
|
||||
fader: { id: 'song', label: 'Song', unit: '%', min: 0, max: 100, step: 1, defaultValue: 80, currentValue: 55 },
|
||||
});
|
||||
|
||||
const result = await audioSession.setFaderValue({ participantId: 'core.song', faderId: 'song', value: 42 });
|
||||
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(result.payload.committedValue, 42);
|
||||
// The handler must still have been invoked — the actual volume was applied,
|
||||
// not silently swallowed by a dropped handler.
|
||||
assert.deepEqual(applied, [42]);
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, captureEvents, makeInputProvider, makeMonitoringProvider, storageEntries } = require('./audio_session_test_harness');
|
||||
|
||||
async function installInput(api, overrides = {}) {
|
||||
const input = makeInputProvider({
|
||||
providerId: 'desktop_input',
|
||||
sourceId: 'desktop-source',
|
||||
logicalSourceKey: 'desktop:instrument:primary',
|
||||
channelSummary: { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
...overrides,
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'register-source', source: 'desktop_input', payload: input.source });
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: input.source.logicalSourceKey } });
|
||||
return input;
|
||||
}
|
||||
|
||||
async function installMonitoring(api, overrides = {}) {
|
||||
const monitoring = makeMonitoringProvider({ providerId: 'desktop_monitor', logicalMonitoringKey: 'desktop:monitor:main', safeLabel: 'Desktop Monitor', ...overrides });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: monitoring.provider.providerId, payload: monitoring.provider });
|
||||
return monitoring;
|
||||
}
|
||||
|
||||
test('audio-monitoring starts and stops through selected provider and source', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
|
||||
const stoppedEvents = captureEvents(window, 'audio-monitoring:monitoring-stopped');
|
||||
const input = await installInput(api);
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
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'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(active.payload.state, 'active');
|
||||
assert.equal(active.payload.sourceRef.logicalSourceKey, 'desktop:instrument:primary');
|
||||
assert.equal(stopped.outcome, 'stopped');
|
||||
assert.equal(startedEvents.length, 1);
|
||||
assert.equal(stoppedEvents.length, 1);
|
||||
assert.deepEqual(monitoring.calls.map(call => call[0]), ['monitoring.start', 'monitoring.stop']);
|
||||
assert.deepEqual(input.calls.map(call => call[0]), ['source.open']);
|
||||
assert.equal(snapshot.sessions.some(session => session.state === 'stopped'), true);
|
||||
});
|
||||
|
||||
test('audio-monitoring redacts circular provider payloads without overflowing', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const circular = { directMonitor: { state: 'muted' }, latencySummary: { bucket: 'low' } };
|
||||
circular.self = circular;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, {
|
||||
operationHandlers: {
|
||||
'monitoring.start': () => ({ outcome: 'handled', status: 'active', payload: circular }),
|
||||
},
|
||||
});
|
||||
|
||||
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'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(snapshot.sessions[0].state, 'active');
|
||||
});
|
||||
|
||||
test('audio-monitoring snapshots redact session identifiers and source refs', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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 session = snapshot.sessions[0];
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.notEqual(session.monitoringId, active.payload.monitoringId);
|
||||
assert.notEqual(session.sessionKey, active.payload.sessionKey);
|
||||
assert.equal(session.sessionKey.includes('::'), false);
|
||||
assert.equal(encoded.includes('desktop-source'), false);
|
||||
});
|
||||
|
||||
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 degradedEvents = captureEvents(window, 'audio-monitoring:monitoring-degraded');
|
||||
const deniedEvents = captureEvents(window, 'audio-monitoring:monitoring-denied');
|
||||
const unavailableEvents = captureEvents(window, 'audio-monitoring:monitoring-unavailable');
|
||||
const failedEvents = captureEvents(window, 'audio-monitoring:monitoring-failed');
|
||||
const userActionEvents = captureEvents(window, 'audio-monitoring:monitoring-user-action-required');
|
||||
await installInput(api);
|
||||
|
||||
const noProvider = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action' } });
|
||||
const unavailableProvider = await installMonitoring(api, { providerId: 'unavailable_monitor', logicalMonitoringKey: 'unavailable:main', availability: 'unavailable', operations: ['monitoring.start'] });
|
||||
const unavailable = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: unavailableProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: unavailableProvider.provider.providerId } });
|
||||
|
||||
const degradedProvider = await installMonitoring(api, { providerId: 'degraded_monitor', logicalMonitoringKey: 'degraded:main', startResult: { outcome: 'degraded', status: 'degraded', reason: 'high latency', summary: { latencySummary: { bucket: 'high' } } } });
|
||||
const degraded = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: degradedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: degradedProvider.provider.providerId } });
|
||||
|
||||
const deniedProvider = await installMonitoring(api, { providerId: 'denied_monitor', logicalMonitoringKey: 'denied:main', startResult: { outcome: 'denied', status: 'denied', reason: 'permission denied' } });
|
||||
const denied = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: deniedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'test', payload: { providerId: deniedProvider.provider.providerId } });
|
||||
|
||||
const failedProvider = await installMonitoring(api, { providerId: 'failed_monitor', logicalMonitoringKey: 'failed:main', operationHandlers: { 'monitoring.start': () => { throw new Error('boom'); } } });
|
||||
const failed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: failedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
|
||||
const background = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { providerId: failedProvider.provider.providerId, requesterId: 'note_detect', authorization: 'background' } });
|
||||
const activeProvider = await installMonitoring(api, { providerId: 'active_monitor', logicalMonitoringKey: 'active:main' });
|
||||
const activeBeforeSwitch = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: activeProvider.provider.providerId, requesterId: 'user', authorization: 'user-action' } });
|
||||
|
||||
assert.equal(noProvider.outcome, 'no-owner');
|
||||
assert.equal(unavailable.outcome, 'unavailable');
|
||||
assert.equal(degraded.outcome, 'degraded');
|
||||
assert.equal(denied.outcome, 'denied');
|
||||
assert.equal(failed.outcome, 'failed');
|
||||
assert.equal(background.outcome, 'user-action-required');
|
||||
assert.equal(degradedEvents.length >= 1, true);
|
||||
assert.equal(deniedEvents.length >= 1, true);
|
||||
// user-action-required must surface as its own event, not as a 'monitoring-denied' permission signal.
|
||||
assert.equal(userActionEvents.length >= 1, true);
|
||||
assert.equal(deniedEvents.some(detail => detail && detail.requesterId === 'note_detect'), false);
|
||||
assert.equal(unavailableEvents.length >= 1, true);
|
||||
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'];
|
||||
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'];
|
||||
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;
|
||||
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'];
|
||||
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);
|
||||
|
||||
await installMonitoring(api, { providerId: 'browser_monitor', logicalMonitoringKey: 'browser:monitor' });
|
||||
const selectionRequired = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action' } });
|
||||
assert.equal(selectionRequired.outcome, 'provider-selection-required');
|
||||
|
||||
const selected = await api.dispatch({ capability: 'audio-monitoring', command: 'select-provider', source: 'user', payload: { providerId: 'native_monitor' } });
|
||||
assert.equal(selected.outcome, 'handled');
|
||||
assert.equal(selected.payload.logicalMonitoringKey, 'shared:monitor');
|
||||
assert.equal(legacy.calls.length, 0);
|
||||
});
|
||||
|
||||
test('audio-monitoring shares compatible sessions and stops provider after final requester', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const second = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { requesterId: 'note_detect', authorization: 'background', requiredChannelShape: 'mono' } });
|
||||
const stopFirst = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { requesterId: 'note_detect', monitoringId: first.payload.monitoringId } });
|
||||
const stopSecond = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'note_detect', payload: { requesterId: 'note_detect', monitoringId: second.payload.monitoringId } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
assert.equal(second.outcome, 'handled');
|
||||
assert.equal(first.payload.monitoringId, second.payload.monitoringId);
|
||||
assert.equal(stopFirst.payload.state, 'active');
|
||||
assert.equal(stopFirst.payload.requesters.map(item => item.requesterId).join(','), 'note_detect');
|
||||
assert.equal(stopSecond.outcome, 'stopped');
|
||||
assert.deepEqual(monitoring.calls.map(call => call[0]), ['monitoring.start', 'monitoring.stop']);
|
||||
|
||||
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'];
|
||||
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;
|
||||
await installInput(api);
|
||||
let stopCalls = 0;
|
||||
await installMonitoring(api, {
|
||||
operationHandlers: {
|
||||
'monitoring.stop': () => {
|
||||
stopCalls += 1;
|
||||
if (stopCalls === 1) return { outcome: 'failed', reason: 'transient stop failure' };
|
||||
return { outcome: 'handled', status: 'stopped' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const stopFails = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
|
||||
// The failed final stop emptied the requester list before the provider confirmed; the original
|
||||
// owner must still be able to retry rather than being locked out as a non-owner.
|
||||
const stopRetry = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(stopFails.outcome, 'failed');
|
||||
assert.equal(stopRetry.outcome, 'stopped');
|
||||
assert.equal(stopCalls, 2);
|
||||
});
|
||||
|
||||
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;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { startResult: { outcome: 'handled', status: 'denied', reason: 'microphone permission blocked' } });
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
// Provider reports a terminal denial while the request also carries a conflicting direct-monitor
|
||||
// requirement; the terminal provider reason must win, not the conflict message.
|
||||
const denied = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono', directMonitorRequirement: 'muted' } });
|
||||
|
||||
assert.equal(denied.outcome, 'denied');
|
||||
assert.match(denied.reason, /permission blocked/i);
|
||||
assert.equal(/conflict/i.test(denied.reason || ''), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring normalizes an unsafe provider id before storing it', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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 provider = snapshot.providers[0];
|
||||
|
||||
// The surfaced providerId must be redacted + charset-restricted, never the raw path/token.
|
||||
assert.equal(JSON.stringify(snapshot).includes('token=abcdef0123456789'), false);
|
||||
assert.equal(provider.providerId.includes('/'), false);
|
||||
assert.equal(provider.providerId.includes(' '), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring bounds caller-supplied identifiers reflected back in error reasons', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
const evil = '/Users/secret/private token=supersecretvalue123 ' + 'x'.repeat(400);
|
||||
|
||||
const stop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: evil } });
|
||||
const select = await api.dispatch({ capability: 'audio-monitoring', command: 'select-provider', source: 'user', payload: { providerId: evil } });
|
||||
const unregister = await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'user', payload: { providerId: evil } });
|
||||
|
||||
assert.equal(stop.outcome, 'no-handler');
|
||||
assert.equal(select.outcome, 'unavailable');
|
||||
assert.equal(unregister.outcome, 'no-handler');
|
||||
// Reflected identifiers must be bounded (no raw token, length-capped) before being returned.
|
||||
assert.equal(stop.payload.monitoringId.length <= 240, true);
|
||||
assert.equal(select.payload.logicalMonitoringKey.length <= 240, true);
|
||||
assert.equal(unregister.payload.providerId.length <= 240, true);
|
||||
for (const result of [stop, select, unregister]) {
|
||||
assert.equal(JSON.stringify(result).includes('token=supersecretvalue123'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('audio-monitoring attaching to a shared session honors a conflicting direct-monitor requirement', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const attach = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'user-action', requiredChannelShape: 'mono', directMonitorRequirement: 'muted' } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
// Same shared session, but the conflicting requirement must surface as degraded with an annotation.
|
||||
assert.equal(attach.payload.monitoringId, first.payload.monitoringId);
|
||||
assert.equal(attach.outcome, 'degraded');
|
||||
const requester = attach.payload.requesters.find(item => item.requesterId === 'note_detect');
|
||||
assert.equal(requester.status, 'degraded');
|
||||
});
|
||||
|
||||
test('audio-monitoring start never lets a caller inject raw sourceRef fields', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const realOpenId = first.payload.sourceRef.openSessionId;
|
||||
assert.match(realOpenId, /^input-open-\d+$/);
|
||||
// A caller passing a tracked openSessionId alongside an injected raw sourceId must not get that raw
|
||||
// value into the monitoring session/response — the sourceRef is derived only from openInputSource.
|
||||
const injected = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'user-action', requiredChannelShape: 'mono', sourceRef: { openSessionId: realOpenId, sourceId: 'EVIL-/Users/secret-token-abc123' } } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
assert.equal(JSON.stringify(injected.payload).includes('EVIL'), false);
|
||||
assert.equal(JSON.stringify(injected.payload).includes('secret-token'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring start treats a void provider result as failed', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { providerId: 'void_monitor', logicalMonitoringKey: 'void:main', operationHandlers: { 'monitoring.start': () => undefined } });
|
||||
|
||||
const started = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'void_monitor', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
|
||||
// A provider that returns nothing from monitoring.start must not be reported as active.
|
||||
assert.equal(started.outcome, 'failed');
|
||||
});
|
||||
|
||||
test('audio-monitoring stopAll requires explicit user action', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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'];
|
||||
// An explicit user action can.
|
||||
const user = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { stopAll: true, authorization: 'user-action' } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(background.outcome, 'user-action-required');
|
||||
assert.equal(mid.sessions.some(session => session.state === 'active'), true);
|
||||
assert.equal(user.outcome, 'stopped');
|
||||
});
|
||||
|
||||
test('audio-monitoring stop does not report stopped when the provider reports a terminal status', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { stopResult: { outcome: 'handled', status: 'failed', reason: 'device fell off the bus' } });
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const stop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
// Provider said handled but reported a terminal failed status — must surface failed, not stopped.
|
||||
assert.equal(stop.outcome, 'failed');
|
||||
assert.equal(stop.payload.state, 'failed');
|
||||
});
|
||||
|
||||
test('audio-monitoring stop reports no-owner when the provider has disappeared', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'unregister-provider', source: 'system', payload: { providerId: monitoring.provider.providerId } });
|
||||
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
// The provider is gone, so we cannot confirm the live capture stopped — orphan rather than 'stopped'.
|
||||
assert.equal(stopped.outcome, 'no-owner');
|
||||
assert.equal(stopped.payload.state, 'orphaned');
|
||||
});
|
||||
|
||||
test('audio-monitoring stop reports unsupported-command when the provider has no stop operation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { providerId: 'nostop_monitor', logicalMonitoringKey: 'nostop:main', operations: ['monitoring.start'] });
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: 'nostop_monitor', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const stopped = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'user', payload: { monitoringId: active.payload.monitoringId } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
// No stop operation means we must not pretend the live capture stopped.
|
||||
assert.equal(stopped.outcome, 'unsupported-command');
|
||||
});
|
||||
|
||||
test('audio-monitoring events emit redaction-safe sessions', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const startedEvents = captureEvents(window, 'audio-monitoring:monitoring-started');
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
const active = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(startedEvents.length, 1);
|
||||
const evt = startedEvents[0];
|
||||
// Events are broadcast to all observers, so internal ids/keys and the raw sourceId must be redacted
|
||||
// (matching diagnostics) rather than emitted as a raw clone.
|
||||
assert.notEqual(evt.monitoringId, active.payload.monitoringId);
|
||||
assert.notEqual(evt.sessionKey, active.payload.sessionKey);
|
||||
assert.equal(JSON.stringify(evt).includes('desktop-source'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring rejects a providerId collision from a different owner', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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' });
|
||||
|
||||
const reg1 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_a', payload: first.provider });
|
||||
const reg2 = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'plugin_b', payload: second.provider });
|
||||
// The guard must also hold when the colliding registration omits ownerPluginId (no silent inherit).
|
||||
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'];
|
||||
|
||||
assert.equal(reg1.outcome, 'handled');
|
||||
assert.equal(reg2.outcome, 'failed');
|
||||
assert.equal(reg3.outcome, 'failed');
|
||||
// The original owner's provider must remain intact, not be silently overwritten.
|
||||
const provider = snapshot.providers.find(entry => entry.providerId === 'shared_id');
|
||||
assert.equal(provider.logicalMonitoringKey, 'a:main');
|
||||
});
|
||||
|
||||
test('audio-monitoring session keeps openInputSessionId verbatim for cross-domain correlation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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);
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.match(active.payload.openInputSessionId, /^input-open-\d+$/);
|
||||
// The redacted snapshot must keep the real input-open id verbatim (not a fresh pseudonym) so it
|
||||
// still correlates with the audio-input open session inside the same diagnostics snapshot.
|
||||
assert.equal(session.openInputSessionId, active.payload.openInputSessionId);
|
||||
assert.equal(session.sourceRef.openSessionId, active.payload.openInputSessionId);
|
||||
});
|
||||
|
||||
test('audio-monitoring status refresh tolerates a void provider result without faking active', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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');
|
||||
|
||||
// A void status reply is tolerated but must not be recorded as an 'active' session.
|
||||
assert.equal(outcomes.length >= 1, true);
|
||||
assert.equal(outcomes.every(entry => entry.status !== 'active'), true);
|
||||
});
|
||||
|
||||
test('audio-monitoring status refresh does not leak the raw device sourceId to providers', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
let statusSourceRef = null;
|
||||
await installMonitoring(api, { operationHandlers: { 'monitoring.status': (request) => { statusSourceRef = request.sourceRef; return { outcome: 'handled', status: 'active' }; } } });
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
|
||||
|
||||
assert.notEqual(statusSourceRef, null);
|
||||
// The prompt-free status request must not carry the raw device sourceId.
|
||||
assert.equal(statusSourceRef.sourceId, '');
|
||||
assert.equal(JSON.stringify(statusSourceRef).includes('desktop-source'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring status refresh does not downgrade availability on a non-state provider reply', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, { availability: 'available', statusResult: { outcome: 'no-handler' } });
|
||||
|
||||
const inspected = await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'user', payload: { includeStatus: true } });
|
||||
const provider = inspected.payload.providers.find(entry => entry.providerId === 'desktop_monitor');
|
||||
|
||||
// A status handler that returns a non-state outcome (no availability) must leave the provider's
|
||||
// availability intact rather than clamping it to 'unknown'.
|
||||
assert.equal(provider.availability, 'available');
|
||||
});
|
||||
|
||||
test('audio-monitoring re-keys active sessions on a preference change so requesters still attach', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api);
|
||||
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'muted' } });
|
||||
const first = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
// User flips the preference; the active session's key must follow it.
|
||||
await api.dispatch({ capability: 'audio-monitoring', command: 'set-direct-monitor', source: 'user', payload: { state: 'unmuted' } });
|
||||
// A background requester should now attach to the existing session under the new preference,
|
||||
// rather than failing to match and being told user-action-required.
|
||||
const attach = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'note_detect', payload: { authorization: 'background', requiredChannelShape: 'mono' } });
|
||||
|
||||
assert.equal(first.outcome, 'handled');
|
||||
assert.equal(attach.outcome, 'handled');
|
||||
assert.equal(attach.payload.monitoringId, first.payload.monitoringId);
|
||||
});
|
||||
|
||||
test('audio-monitoring does not assume direct-monitor applied without provider confirmation', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
// The provider handled the request but did not confirm application, so applied must stay false.
|
||||
assert.equal(snapshot.sessions.at(-1).directMonitor.applied, false);
|
||||
assert.equal(snapshot.directMonitor.applied, false);
|
||||
});
|
||||
|
||||
test('audio-monitoring direct-monitor summary preserves an unavailable control state', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, {
|
||||
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'unavailable', applied: false, reason: 'temporarily unavailable' } } },
|
||||
});
|
||||
|
||||
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'];
|
||||
|
||||
// A session that reports control 'unavailable' must not be collapsed to 'unknown' in the rollup.
|
||||
assert.equal(changed.payload.directMonitor.control, 'unavailable');
|
||||
assert.equal(snapshot.directMonitor.control, 'unavailable');
|
||||
assert.equal(snapshot.directMonitor.applied, false);
|
||||
});
|
||||
|
||||
test('audio-monitoring direct-monitor summary reflects a provider that handles but does not apply', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await installInput(api);
|
||||
await installMonitoring(api, {
|
||||
directMonitorResult: { outcome: 'handled', summary: { directMonitor: { state: 'unmuted', control: 'supported', applied: false, reason: 'hardware busy' } } },
|
||||
});
|
||||
|
||||
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'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
// Provider handled the request but reported it was not applied; the domain summary must not
|
||||
// collapse that into applied:true just because the aggregate outcome was handled.
|
||||
assert.equal(changed.payload.directMonitor.applied, false);
|
||||
assert.equal(snapshot.directMonitor.applied, false);
|
||||
assert.equal(snapshot.directMonitor.control, 'supported');
|
||||
assert.equal(snapshot.sessions.at(-1).directMonitor.applied, false);
|
||||
// The provider's per-session note must survive into the domain-level summary.
|
||||
assert.match(snapshot.directMonitor.reason, /hardware busy/i);
|
||||
});
|
||||
|
||||
test('audio-monitoring direct monitor preference is user authoritative', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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'];
|
||||
|
||||
assert.equal(changed.outcome, 'handled');
|
||||
assert.equal(startConflict.outcome, 'degraded');
|
||||
assert.equal(startConflict.payload.directMonitor.preference, 'unmuted');
|
||||
assert.equal(snapshot.directMonitor.preference, 'unmuted');
|
||||
assert.equal(snapshot.sessions.at(-1).requesters[0].status, 'degraded');
|
||||
assert.equal(monitoring.calls.some(call => call[0] === 'monitoring.set-direct-monitor'), false);
|
||||
});
|
||||
|
||||
test('audio-monitoring direct monitor unsupported control remains diagnosable', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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'];
|
||||
|
||||
assert.equal(active.outcome, 'handled');
|
||||
assert.equal(changed.outcome, 'unsupported-command');
|
||||
assert.equal(changed.payload.directMonitor.preference, 'unmuted');
|
||||
assert.equal(snapshot.sessions.at(-1).directMonitor.control, 'unsupported');
|
||||
assert.equal(changedEvents.length, 1);
|
||||
});
|
||||
|
||||
test('audio-monitoring distinguishes failure outcomes and prompt-free status inspection', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const input = await installInput(api, { channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] } });
|
||||
const monitoring = await installMonitoring(api);
|
||||
|
||||
const inspect = await api.dispatch({ capability: 'audio-monitoring', command: 'inspect', source: 'support', payload: { includeStatus: true } });
|
||||
const unsupportedCommand = await api.dispatch({ capability: 'audio-monitoring', command: 'not-real', source: 'support' });
|
||||
const unsupportedProvider = await installMonitoring(api, { providerId: 'unsupported_monitor', logicalMonitoringKey: 'unsupported:main', operations: ['monitoring.status'] });
|
||||
const unsupported = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: unsupportedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const incompatible = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: monitoring.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'stereo' } });
|
||||
const badVersion = await api.dispatch({ capability: 'audio-monitoring', command: 'register-provider', source: 'bad', payload: { providerId: 'bad-monitor', version: 2 } });
|
||||
const missingStop = await api.dispatch({ capability: 'audio-monitoring', command: 'stop', source: 'support', payload: { monitoringId: 'missing-monitor' } });
|
||||
const timeoutProvider = await installMonitoring(api, { providerId: 'timeout_monitor', logicalMonitoringKey: 'timeout:main', operationHandlers: { 'monitoring.start': () => new Promise(resolve => setTimeout(() => resolve({ outcome: 'handled' }), 2100)) } });
|
||||
const timedOut = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: timeoutProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
const malformedProvider = await installMonitoring(api, { providerId: 'malformed_monitor', logicalMonitoringKey: 'malformed:main', operationHandlers: { 'monitoring.start': () => 'not-object' } });
|
||||
const malformed = await api.dispatch({ capability: 'audio-monitoring', command: 'start', source: 'user', payload: { providerId: malformedProvider.provider.providerId, requesterId: 'user', authorization: 'user-action', requiredChannelShape: 'mono' } });
|
||||
|
||||
assert.equal(inspect.outcome, 'handled');
|
||||
assert.equal(unsupportedCommand.outcome, 'unsupported-command');
|
||||
assert.equal(unsupported.outcome, 'unsupported-command');
|
||||
assert.equal(incompatible.outcome, 'incompatible');
|
||||
assert.equal(badVersion.outcome, 'incompatible-version');
|
||||
assert.equal(missingStop.outcome, 'no-handler');
|
||||
assert.equal(timedOut.outcome, 'failed');
|
||||
assert.match(timedOut.reason, /timed out/i);
|
||||
assert.equal(malformed.outcome, 'failed');
|
||||
assert.deepEqual(input.calls.map(call => call[0]), ['source.open']);
|
||||
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');
|
||||
for (const outcome of ['handled', 'unsupported-command', 'incompatible', 'incompatible-version', 'no-handler']) {
|
||||
assert.equal(outcomes.some(entry => entry.outcome === outcome), true, `missing outcome ${outcome}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('audio-session diagnostics remain bounded during frequent input monitoring updates', async () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.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 encoded = JSON.stringify(snapshot);
|
||||
assert.equal(snapshot.recentOutcomes.length <= 100, true);
|
||||
assert.equal(encoded.length < 96 * 1024, true);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
|
||||
|
||||
test('audio session records route transitions without blocking callers', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
const html5 = audioSession.setRoute({ routeKind: 'html5', availability: 'available', selectedByUser: true });
|
||||
const stems = audioSession.setRoute({ routeKind: 'stems', availability: 'available', selectedByUser: true });
|
||||
const juce = audioSession.setRoute({ routeKind: 'juce', availability: 'degraded', fallbackReason: 'native route unavailable' });
|
||||
const snapshot = audioSession.snapshot();
|
||||
|
||||
assert.equal(html5.routeKind, 'html5');
|
||||
assert.equal(stems.routeKind, 'stems');
|
||||
assert.equal(juce.availability, 'degraded');
|
||||
assert.equal(snapshot.domains['audio-mix'].route.routeKind, 'juce');
|
||||
assert.equal(snapshot.recentOutcomes.at(-1).outcome, 'degraded');
|
||||
});
|
||||
|
||||
test('legacy song fader registration is bridged into audio-mix participants and route diagnostics', async () => {
|
||||
const window = loadAudioSession();
|
||||
const { audio } = installMixerDom(window);
|
||||
window.localStorage.setItem('volume', '65');
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
assert.equal(typeof window.slopsmith.audio.applySongVolume, 'function');
|
||||
|
||||
await window.slopsmith.audio.applySongVolume(72);
|
||||
const snapshot = window.slopsmith.audioSession.snapshot();
|
||||
const songParticipant = snapshot.domains['audio-mix'].participants.find(p => p.participantId === 'core.song');
|
||||
|
||||
assert.equal(audio.volume, 0.72);
|
||||
assert.equal(songParticipant.label, 'Song');
|
||||
assert.equal(songParticipant.fader.currentValue, 72);
|
||||
assert.equal(snapshot.domains['audio-mix'].route.routeKind, 'html5');
|
||||
assert.equal(snapshot.domains['audio-mix'].bridges.some(b => b.bridgeId === 'audio-mix.song-volume'), true);
|
||||
});
|
||||
|
||||
test('song volume persists through html5 stems and desktop routes', async () => {
|
||||
const window = loadAudioSession();
|
||||
const { audio } = installMixerDom(window);
|
||||
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(); } } };
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
assert.equal(window.slopsmith.audio.readSongVolume(), 41);
|
||||
|
||||
await window.slopsmith.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');
|
||||
|
||||
window._juceMode = true;
|
||||
delete window.slopsmith.stems;
|
||||
await window.slopsmith.audio.applySongVolume(66);
|
||||
assert.deepEqual(desktopCalls.at(-1), ['backing', 0.66]);
|
||||
assert.equal(window.slopsmith.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;
|
||||
audioSession.startSession({ sessionId: 'main:stems-song' });
|
||||
audioSession.registerStemOwner({ ownerId: 'stems_plugin', stemIds: ['guitar', 'bass'], availability: 'available' });
|
||||
audioSession.registerMixParticipant({
|
||||
participantId: 'stems.master',
|
||||
ownerPluginId: 'stems_plugin',
|
||||
label: 'Stems',
|
||||
kind: 'stem',
|
||||
sourceMode: 'native',
|
||||
fader: { id: 'master', label: 'Stems', min: 0, max: 1, step: 0.1, defaultValue: 1, currentValue: 1 },
|
||||
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' });
|
||||
|
||||
assert.equal(stemsInspect.payload.owner.ownerId, 'stems_plugin');
|
||||
assert.equal(mixInspect.payload.faders.some(fader => fader.kind === 'stem' && fader.ownerPluginId === 'stems_plugin'), true);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
audioSession.startSession({ sessionId: 'main:first-song', songKey: 'first-song.sloppak', songFormat: 'sloppak' });
|
||||
await api.dispatch({
|
||||
capability: 'audio-input',
|
||||
command: 'register-source',
|
||||
source: 'note_detect',
|
||||
payload: {
|
||||
sourceId: 'switch-source',
|
||||
logicalSourceKey: 'switch:instrument:primary',
|
||||
providerId: 'note_detect',
|
||||
kind: 'instrument',
|
||||
safeLabel: 'Switch Input',
|
||||
channelSummary: { channelCount: 1, channelShape: 'mono', supports: ['mono'] },
|
||||
operations: ['source.open', 'source.close'],
|
||||
operationHandlers: {
|
||||
'source.open': () => ({ outcome: 'handled' }),
|
||||
'source.close': () => ({ outcome: 'handled' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'switch:instrument:primary' } });
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.psarc', songFormat: 'psarc' });
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
|
||||
|
||||
assert.equal(next.session.songFormat, 'psarc');
|
||||
assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary');
|
||||
assert.equal(next.domains['audio-input'].totalOpenSessions, 0);
|
||||
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
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 noOwner = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', payload: { stemIds: ['guitar'] } });
|
||||
assert.equal(noOwner.outcome, 'no-owner');
|
||||
|
||||
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar', 'bass'], stemStates: { guitar: { muted: false } } });
|
||||
const muted = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', payload: { claimId: 'nam.amp-active', stemIds: ['guitar'] } });
|
||||
assert.equal(muted.status, 'applied');
|
||||
assert.equal(muted.payload.state, 'active');
|
||||
assert.equal(api.snapshotDiagnostics().activeClaims.some(claim => claim.claimId === 'nam.amp-active'), true);
|
||||
|
||||
const override = audioSession.recordStemManualOverride({ stemIds: ['guitar'], requester: 'user' });
|
||||
assert.equal(override.overriddenClaims.length, 1);
|
||||
assert.equal(audioSession.snapshot().domains.stems.claims[0].state, 'overridden');
|
||||
|
||||
const restored = await api.dispatch({ capability: 'stems', command: 'restore', source: 'nam_tone', payload: { claimId: 'nam.amp-active' } });
|
||||
assert.equal(restored.outcome, 'overridden');
|
||||
});
|
||||
|
||||
test('audio session coordinates stems without replacing the active stems owner', () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
let stemsPipeline = api.inspect('stems');
|
||||
let coordinator = stemsPipeline.participants.find(entry => entry.pluginId === 'core.audio.session');
|
||||
assert.equal(coordinator.roles.includes('coordinator'), true);
|
||||
assert.equal(coordinator.roles.includes('owner'), false);
|
||||
|
||||
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar'] });
|
||||
stemsPipeline = api.inspect('stems');
|
||||
coordinator = stemsPipeline.participants.find(entry => entry.pluginId === 'core.audio.session');
|
||||
const provider = stemsPipeline.participants.find(entry => entry.pluginId === 'stems.plugin');
|
||||
|
||||
assert.equal(coordinator.roles.includes('owner'), false);
|
||||
assert.equal(provider.roles.includes('provider'), true);
|
||||
assert.equal(audioSession.snapshot().domains.stems.owner.ownerId, 'stems.plugin');
|
||||
});
|
||||
|
||||
test('stem automation claims become orphaned when owner disappears', () => {
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
const unavailableEvents = [];
|
||||
api.subscribe('stems:owner-unavailable', detail => unavailableEvents.push(detail));
|
||||
|
||||
audioSession.registerStemOwner({ ownerId: 'stems.plugin', stemIds: ['guitar'] });
|
||||
audioSession.muteStems({ claimId: 'claim-one', requester: 'nam_tone', stemIds: ['guitar'] });
|
||||
audioSession.registerStemOwner({ ownerId: 'stems.plugin', availability: 'disabled', stemIds: ['guitar'] });
|
||||
|
||||
const claim = audioSession.snapshot().domains.stems.claims.find(entry => entry.claimId === 'claim-one');
|
||||
assert.equal(claim.state, 'orphaned');
|
||||
assert.equal(unavailableEvents.length, 1);
|
||||
assert.equal(unavailableEvents[0].payload.availability, 'disabled');
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadAudioSession, runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
|
||||
|
||||
test('Stems master-volume compatibility bridge hit is attributed through audio session', async () => {
|
||||
const window = loadAudioSession();
|
||||
const calls = [];
|
||||
installMixerDom(window);
|
||||
window.slopsmith.stems = { setMasterVolume(value) { calls.push(value); } };
|
||||
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
await window.slopsmith.audio.applySongVolume(50);
|
||||
|
||||
const snapshot = window.slopsmith.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);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const AUDIO_SESSION_JS = path.join(ROOT, 'static', 'capabilities', 'audio-session.js');
|
||||
const AUDIO_MIXER_JS = path.join(ROOT, 'static', 'audio-mixer.js');
|
||||
|
||||
function loadAudioSession(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(AUDIO_SESSION_JS, 'utf8'), context, { filename: AUDIO_SESSION_JS });
|
||||
window.__vmContext = context;
|
||||
return window;
|
||||
}
|
||||
|
||||
function runBrowserScript(window, relativePath) {
|
||||
const filePath = path.join(ROOT, relativePath);
|
||||
vm.runInContext(fs.readFileSync(filePath, 'utf8'), window.__vmContext, { filename: filePath });
|
||||
}
|
||||
|
||||
function captureEvents(window, eventName) {
|
||||
const events = [];
|
||||
window.slopsmith.on(eventName, event => events.push(event.detail));
|
||||
return events;
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window) {
|
||||
return window.slopsmith.audioSession.snapshot();
|
||||
}
|
||||
|
||||
function storageEntries(window) {
|
||||
return Object.fromEntries(window.__storage || new Map());
|
||||
}
|
||||
|
||||
function makeInputProvider(overrides = {}) {
|
||||
const calls = [];
|
||||
const sources = overrides.sources || [];
|
||||
return {
|
||||
calls,
|
||||
source: {
|
||||
sourceId: overrides.sourceId || 'provider-source-1',
|
||||
logicalSourceKey: overrides.logicalSourceKey || 'provider:input:primary',
|
||||
providerId: overrides.providerId || 'provider',
|
||||
ownerPluginId: overrides.ownerPluginId || overrides.providerId || 'provider',
|
||||
kind: overrides.kind || 'instrument',
|
||||
safeLabel: overrides.safeLabel || 'Input 1',
|
||||
availability: overrides.availability || 'available',
|
||||
channelSummary: overrides.channelSummary || { channelCount: 2, channelShape: 'stereo', supports: ['mono', 'stereo'] },
|
||||
sourceMode: overrides.sourceMode || 'native',
|
||||
operations: overrides.operations || ['source.enumerate', 'source.open', 'source.close'],
|
||||
operationHandlers: {
|
||||
'source.enumerate': request => { calls.push(['source.enumerate', request]); return { sources }; },
|
||||
'source.open': request => { calls.push(['source.open', request]); return overrides.openResult || { outcome: 'handled', status: 'open' }; },
|
||||
'source.close': request => { calls.push(['source.close', request]); return overrides.closeResult || { outcome: 'handled', status: 'closed' }; },
|
||||
...(overrides.operationHandlers || {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMonitoringProvider(overrides = {}) {
|
||||
const calls = [];
|
||||
const providerId = overrides.providerId || 'monitoring-provider';
|
||||
const logicalMonitoringKey = overrides.logicalMonitoringKey || `${providerId}:main`;
|
||||
const operations = overrides.operations || ['monitoring.start', 'monitoring.stop', 'monitoring.status', 'monitoring.set-direct-monitor'];
|
||||
const defaultHandlers = {
|
||||
'monitoring.start': request => {
|
||||
calls.push(['monitoring.start', request]);
|
||||
return overrides.startResult || { outcome: 'handled', status: 'active', summary: { directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true }, latencySummary: overrides.latencySummary || { bucket: 'low' } } };
|
||||
},
|
||||
'monitoring.stop': request => {
|
||||
calls.push(['monitoring.stop', request]);
|
||||
return overrides.stopResult || { outcome: 'handled', status: 'stopped' };
|
||||
},
|
||||
'monitoring.status': request => {
|
||||
calls.push(['monitoring.status', request]);
|
||||
return overrides.statusResult || { outcome: 'handled', status: 'active', summary: { availability: 'available', directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true }, latencySummary: overrides.latencySummary || { bucket: 'low' } } };
|
||||
},
|
||||
'monitoring.set-direct-monitor': request => {
|
||||
calls.push(['monitoring.set-direct-monitor', request]);
|
||||
return overrides.directMonitorResult || { outcome: 'handled', status: 'active', summary: { directMonitor: { state: request.state, control: 'supported', preference: request.state, applied: true } } };
|
||||
},
|
||||
};
|
||||
const operationHandlers = {};
|
||||
for (const operation of operations) {
|
||||
if (defaultHandlers[operation]) operationHandlers[operation] = defaultHandlers[operation];
|
||||
}
|
||||
Object.assign(operationHandlers, overrides.operationHandlers || {});
|
||||
return {
|
||||
calls,
|
||||
provider: {
|
||||
providerId,
|
||||
ownerPluginId: overrides.ownerPluginId || providerId,
|
||||
logicalMonitoringKey,
|
||||
safeLabel: overrides.safeLabel || 'Monitoring Provider',
|
||||
availability: overrides.availability || 'available',
|
||||
sourceMode: overrides.sourceMode || 'native',
|
||||
compatibilitySource: overrides.compatibilitySource || '',
|
||||
operations,
|
||||
directMonitor: overrides.directMonitor || { state: 'muted', control: 'supported', preference: 'muted', applied: true },
|
||||
latencySummary: overrides.latencySummary || { bucket: 'low' },
|
||||
operationHandlers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installDeterministicTimers(window) {
|
||||
const timers = [];
|
||||
let nextId = 1;
|
||||
window.setTimeout = (callback, delay = 0) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
timers.push({ id, callback, delay, cleared: false });
|
||||
return id;
|
||||
};
|
||||
window.clearTimeout = id => {
|
||||
const timer = timers.find(item => item.id === id);
|
||||
if (timer) timer.cleared = true;
|
||||
};
|
||||
window.__runTimers = (minimumDelay = 0) => {
|
||||
for (const timer of timers.slice()) {
|
||||
if (timer.cleared || timer.delay < minimumDelay) continue;
|
||||
timer.cleared = true;
|
||||
timer.callback();
|
||||
}
|
||||
};
|
||||
return timers;
|
||||
}
|
||||
|
||||
function makeElement(tagName) {
|
||||
return {
|
||||
tagName,
|
||||
className: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
innerHTML: '',
|
||||
disabled: false,
|
||||
title: '',
|
||||
style: {},
|
||||
children: [],
|
||||
listeners: {},
|
||||
classList: {
|
||||
values: new Set(),
|
||||
add(name) { this.values.add(name); },
|
||||
remove(name) { this.values.delete(name); },
|
||||
contains(name) { return this.values.has(name); },
|
||||
},
|
||||
setAttribute(name, value) { this[name] = String(value); },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener(type, handler) { this.listeners[type] = handler; },
|
||||
contains() { return false; },
|
||||
focus() {},
|
||||
};
|
||||
}
|
||||
|
||||
function installMixerDom(window) {
|
||||
const elements = new Map();
|
||||
const audio = { volume: 0, src: '', load() {} };
|
||||
const button = makeElement('button');
|
||||
const popover = makeElement('div');
|
||||
elements.set('audio', audio);
|
||||
elements.set('btn-mixer', button);
|
||||
elements.set('mixer-popover', popover);
|
||||
window.Event = class Event { constructor(type) { this.type = type; } };
|
||||
window.document.readyState = 'complete';
|
||||
window.document.getElementById = id => elements.get(id) || null;
|
||||
window.document.addEventListener = () => {};
|
||||
window.document.removeEventListener = () => {};
|
||||
window.document.createElement = makeElement;
|
||||
return { elements, audio, button, popover };
|
||||
}
|
||||
|
||||
function loadAudioMixer(window) {
|
||||
runBrowserScript(window, path.relative(ROOT, AUDIO_MIXER_JS));
|
||||
return window.slopsmith.audio;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadAudioSession,
|
||||
runBrowserScript,
|
||||
captureEvents,
|
||||
diagnosticsSnapshot,
|
||||
storageEntries,
|
||||
makeInputProvider,
|
||||
makeMonitoringProvider,
|
||||
installDeterministicTimers,
|
||||
installMixerDom,
|
||||
loadAudioMixer,
|
||||
ROOT,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Verify static/highway.js emits `beats:loaded` exactly once when the
|
||||
// WebSocket delivers the song's beats array, with `{ count }` payload.
|
||||
// Plugins that need to know when beats are available (metronome, beat-
|
||||
// snapping editors, sync visualizers) consume this contract.
|
||||
//
|
||||
// Same isolation strategy as the other tests/js/ files — extract just
|
||||
// the relevant case-block source by string matching and exercise 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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
// Extract a single switch case body so assertions can match against just
|
||||
// that block rather than a fixed-length slice (more robust to harmless
|
||||
// edits adjacent to the case).
|
||||
function getCaseBlock(src, label) {
|
||||
const start = src.indexOf(`case '${label}'`);
|
||||
assert.ok(start !== -1, `case '${label}' not found in highway.js`);
|
||||
const tail = src.slice(start);
|
||||
const nextCase = tail.search(/\n\s*case\s+['"]/);
|
||||
const nextDefault = tail.search(/\n\s*default\s*:/);
|
||||
let end = tail.length;
|
||||
if (nextCase > 0) end = Math.min(end, nextCase);
|
||||
if (nextDefault > 0) end = Math.min(end, nextDefault);
|
||||
return tail.slice(0, end);
|
||||
}
|
||||
|
||||
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
|
||||
// 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['"]/,
|
||||
'beats case must emit beats:loaded',
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/count:\s*beats\.length/,
|
||||
'beats:loaded payload must include count = beats.length',
|
||||
);
|
||||
});
|
||||
|
||||
test('beats:loaded emit is guarded against missing window.slopsmith', () => {
|
||||
// The WS handler can fire before the slopsmith 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
|
||||
// (including typeof checks and combined conditions) rather than
|
||||
// mandating the exact `if (window.slopsmith)` 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',
|
||||
);
|
||||
});
|
||||
|
||||
test('beats:loaded guard verifies emit is callable (typeof check)', () => {
|
||||
// A partially-attached namespace (window.slopsmith exists but emit
|
||||
// isn't a function yet during early boot) would throw without this
|
||||
// extra check. A truthy check (`window.slopsmith.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['"]/,
|
||||
'guard must use typeof === \'function\' (not just truthy) to confirm emit is callable',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
function registerStemsOwner(api, calls) {
|
||||
api.registerParticipant('stems', {
|
||||
stems: {
|
||||
roles: ['owner', 'provider'],
|
||||
commands: ['mute', 'restore'],
|
||||
runtime: true,
|
||||
handlers: {
|
||||
mute: (ctx) => {
|
||||
calls.push(['mute', ctx.payload]);
|
||||
return { outcome: 'handled', payload: { muted: true, restoreSnapshotRef: 'snap-1' } };
|
||||
},
|
||||
restore: (ctx) => {
|
||||
calls.push(['restore', ctx.payload]);
|
||||
return { outcome: 'handled', payload: { restored: true } };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('claim dispatch release records lifecycle and removes active claim', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
registerStemsOwner(api, calls);
|
||||
|
||||
const cleanup = api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone', target: { kind: 'guitar' } });
|
||||
let snapshot = api.snapshotDiagnostics();
|
||||
assert.equal(snapshot.activeClaims.find(c => c.claimId === 'nam.amp-active').owner, 'stems');
|
||||
|
||||
const result = await api.dispatch({
|
||||
capability: 'stems', command: 'mute', source: 'nam_tone',
|
||||
claim: { claimId: 'nam.amp-active' }, args: { target: { kind: 'guitar' } },
|
||||
});
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
cleanup();
|
||||
snapshot = api.snapshotDiagnostics();
|
||||
assert.equal(snapshot.activeClaims.some(c => c.claimId === 'nam.amp-active'), false);
|
||||
assert.equal(snapshot.claimLifecycle.some(c => c.claimId === 'nam.amp-active' && c.state === 'released'), true);
|
||||
assert.equal(snapshot.claimLifecycle.find(c => c.claimId === 'nam.amp-active').restoreSnapshotRef, null);
|
||||
});
|
||||
|
||||
test('manual override is terminal for matching active claim target', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
registerStemsOwner(api, calls);
|
||||
api.claim({ capability: 'stems', claimId: 'nam.amp-active', requester: 'nam_tone', target: { kind: 'guitar' } });
|
||||
api.recordUserOverride({ capability: 'stems', source: 'user', target: { kind: 'guitar' }, reason: 'Player unmuted guitar' });
|
||||
|
||||
const result = await api.dispatch({
|
||||
capability: 'stems', command: 'mute', source: 'nam_tone',
|
||||
claim: { claimId: 'nam.amp-active' }, args: { target: { kind: 'guitar' } },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'overridden');
|
||||
assert.equal(calls.length, 0);
|
||||
assert.match(result.reason, /user override/i);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
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;
|
||||
api.registerParticipant('owner', {
|
||||
stems: {
|
||||
roles: ['owner'],
|
||||
commands: ['mute'],
|
||||
runtime: true,
|
||||
handlers: { mute: () => ({ outcome: 'failed', reason: 'token=abc123 path /Users/example/secret/file.txt ' + 'x'.repeat(2000) }) },
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
await api.dispatch({ capability: 'stems', command: 'mute', source: 'diag-test', args: { target: { id: `target-${i}` } } });
|
||||
}
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
assert.ok(encoded.length <= 64 * 1024);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
assert.equal(encoded.includes('abc123'), false);
|
||||
assert.equal(snapshot.snapshotBytes <= 64 * 1024, true);
|
||||
});
|
||||
|
||||
test('compatibility shim hit counts and attribution are exported', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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 });
|
||||
const shim = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === 'stems:legacy-window');
|
||||
assert.equal(shim.source, 'stems');
|
||||
assert.equal(shim.capability, 'stems');
|
||||
assert.equal(shim.hitCount, 2);
|
||||
assert.ok(shim.lastHitAt);
|
||||
});
|
||||
|
||||
test('diagnostics export expected compatibility shim surfaces', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const expected = api.snapshotDiagnostics().expectedCompatibilityShims;
|
||||
|
||||
assert.ok(Array.isArray(expected));
|
||||
assert.equal(expected.some(entry => entry.capability === 'library'), false);
|
||||
assert.equal(expected.some(entry => entry.capability === 'backend.routes'), false);
|
||||
assert.equal(expected.some(entry => entry.capability === 'playback'), false);
|
||||
assert.equal(expected.some(entry => entry.capability === 'visualization'), false);
|
||||
assert.equal(expected.some(entry => entry.capability === 'jobs'), false);
|
||||
});
|
||||
|
||||
test('diagnostics include active playback but exclude deferred and documentation-only future core domains', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
api.registerParticipant('playback_probe', {
|
||||
playback: {
|
||||
roles: ['provider'],
|
||||
commands: ['snapshot'],
|
||||
runtime: true,
|
||||
},
|
||||
});
|
||||
api.registerParticipant('future_plugin', {
|
||||
'ui.player-panels': {
|
||||
roles: ['provider'],
|
||||
commands: ['register-contribution'],
|
||||
runtime: true,
|
||||
},
|
||||
});
|
||||
api.registerParticipants([{ id: 'future_manifest', capabilities: { jobs: { roles: ['provider'], commands: ['register'] } } }]);
|
||||
api.registerCompatibilityShim({ shimId: 'deferred:viz', source: 'highway_3d', capability: 'visualization', legacySurface: 'highway.setRenderer', status: 'used', hit: true });
|
||||
api.registerCompatibilityShim({ shimId: 'deferred:routes', source: 'legacy_routes_plugin', capability: 'backend.routes', legacySurface: 'routes', status: 'used', hit: true });
|
||||
const pipelines = api.snapshotDiagnostics().pipelines;
|
||||
const playback = pipelines.find(entry => entry.name === 'playback');
|
||||
assert.ok(playback, 'playback should be part of the active runtime graph when participants register');
|
||||
assert.equal(playback.review.lifecycle, 'active');
|
||||
|
||||
// `visualization` (cap:6 slice) and `note-detection` (spec 009 slice)
|
||||
// left this list when their domains were promoted.
|
||||
const futureDomains = [
|
||||
'ui.navigation', 'ui.plugin-screens', 'settings',
|
||||
'backend.routes', 'ui.player-controls',
|
||||
'ui.player-panels', 'ui.player-overlays', 'plugins', 'jobs', 'midi-control',
|
||||
'tempo-clock',
|
||||
];
|
||||
|
||||
for (const domain of futureDomains) {
|
||||
assert.equal(pipelines.some(entry => entry.name === domain), false, `${domain} should stay out of the runtime graph`);
|
||||
}
|
||||
// visualization is active now, so its shim entries are recorded rather
|
||||
// than dropped; backend.routes stays reserved and dropped.
|
||||
assert.equal(api.snapshotDiagnostics().compatibilityShims.some(entry => entry.capability === 'visualization'), true);
|
||||
assert.equal(api.snapshotDiagnostics().compatibilityShims.some(entry => entry.capability === 'backend.routes'), false);
|
||||
});
|
||||
|
||||
test('server-reported shim hit counts are not inflated by refresh registration', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const shim = {
|
||||
shimId: 'stems:legacy-window',
|
||||
source: 'stems',
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
status: 'used',
|
||||
hitCount: 1,
|
||||
};
|
||||
api.registerCompatibilityShim(shim);
|
||||
api.registerCompatibilityShim(shim);
|
||||
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === shim.shimId);
|
||||
assert.equal(exported.hitCount, 1);
|
||||
assert.equal(exported.source, 'stems');
|
||||
assert.ok(exported.lastHitAt);
|
||||
});
|
||||
|
||||
test('recordLegacyHit preserves non-library shim attribution', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const first = api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
source: 'legacy-stems',
|
||||
});
|
||||
api.registerCompatibilityShim({
|
||||
shimId: first.shimId,
|
||||
source: 'legacy-stems',
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === first.shimId);
|
||||
assert.equal(exported.status, 'used');
|
||||
assert.equal(exported.hitCount, 1);
|
||||
assert.equal(exported.source, 'legacy-stems');
|
||||
});
|
||||
|
||||
test('recordLegacyHit counts runtime use and preserves used status across active refreshes', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const first = api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
source: 'legacy-event-bus',
|
||||
reason: 'legacy event emitted',
|
||||
});
|
||||
api.registerCompatibilityShim({
|
||||
shimId: first.shimId,
|
||||
source: 'legacy-event-bus',
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
status: 'active',
|
||||
reason: 'static metadata refresh',
|
||||
});
|
||||
api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
source: 'legacy-event-bus',
|
||||
reason: 'legacy event emitted again',
|
||||
});
|
||||
|
||||
const exported = api.snapshotDiagnostics().compatibilityShims.find(entry => entry.shimId === first.shimId);
|
||||
assert.equal(exported.status, 'used');
|
||||
assert.equal(exported.hitCount, 2);
|
||||
assert.equal(exported.capability, 'stems');
|
||||
assert.equal(exported.legacySurface, 'window._stemsState');
|
||||
assert.ok(exported.lastHitAt);
|
||||
});
|
||||
|
||||
test('capability diagnostics emit a changed event after runtime updates', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
let changes = 0;
|
||||
window.addEventListener('slopsmith:capabilities:changed', () => { changes += 1; });
|
||||
|
||||
api.recordLegacyHit({
|
||||
capability: 'stems',
|
||||
legacySurface: 'window._stemsState',
|
||||
source: 'legacy-event-listener',
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
assert.ok(changes >= 1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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', () => {
|
||||
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.slopsmith.emit('screen:changed', { id: 'home' });
|
||||
window.slopsmith.emit('song:ready', { title: 'First' });
|
||||
window.slopsmith.emit('song:ready', { title: 'Second' });
|
||||
|
||||
assert.deepEqual(events, [{ id: 'home' }]);
|
||||
assert.deepEqual(onceEvents, [{ title: 'First' }]);
|
||||
assert.equal(typeof window.slopsmith.off, 'function');
|
||||
});
|
||||
|
||||
test('unregistering requester releases its claims', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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' });
|
||||
|
||||
api.unregisterParticipant('nam_tone');
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
assert.equal(snapshot.activeClaims.some(c => c.claimId === 'nam.amp-active'), false);
|
||||
assert.equal(snapshot.claimLifecycle.some(c => c.claimId === 'nam.amp-active' && c.state === 'released'), true);
|
||||
});
|
||||
|
||||
test('unregistering owner or handler orphans claim and prevents dispatch', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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');
|
||||
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
const claim = snapshot.activeClaims.find(c => c.claimId === 'nam.amp-active');
|
||||
assert.equal(claim.state, 'orphaned');
|
||||
assert.equal(claim.nonDispatchable, true);
|
||||
|
||||
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'nam_tone', claim: { claimId: 'nam.amp-active' } });
|
||||
assert.equal(result.status, 'no-owner');
|
||||
assert.equal(result.outcome, 'no-owner');
|
||||
});
|
||||
|
||||
test('runtime enable disable is lifecycle state rather than user override', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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' });
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
|
||||
assert.equal(disabled.ok, true);
|
||||
assert.equal(enabled.ok, true);
|
||||
assert.equal(snapshot.userOverrides.length, 0);
|
||||
assert.equal(snapshot.participants.find(p => p.pluginId === 'plugin_a').runtimeOverride.enabled, true);
|
||||
});
|
||||
|
||||
test('failed no-op registrations do not block reload and rehydrate replacement', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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');
|
||||
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], handlers: { mute: () => ({ outcome: 'handled', payload: { generation: 2 } }) }, runtime: true } });
|
||||
|
||||
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
|
||||
const participants = api.inspect('stems').participants.filter(p => p.pluginId === 'stems');
|
||||
|
||||
assert.equal(participants.length, 1);
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.equal(result.payload.generation, 2);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { loadCapabilities, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'plugin_capabilities');
|
||||
|
||||
function fixture(name) {
|
||||
return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8'));
|
||||
}
|
||||
|
||||
test('manifest participants are visible before runtime handlers register', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
api.registerParticipants([fixture('valid_owner_provider.json'), fixture('valid_requester_observer.json')]);
|
||||
|
||||
const stems = api.inspect('stems');
|
||||
assert.equal(stems.participants.length, 2);
|
||||
const owner = stems.participants.find(p => p.pluginId === 'stems');
|
||||
assert.equal(owner.runtime, false);
|
||||
assert.equal(JSON.stringify(owner.commands.slice().sort()), JSON.stringify(['inspect', 'mute', 'restore']));
|
||||
assert.equal(owner.description, 'Owns stem mute and restore coordination for integrated audio plugins.');
|
||||
assert.equal(owner.ownership, 'exclusive-owner');
|
||||
assert.equal(owner.safety, 'safe');
|
||||
});
|
||||
|
||||
test('runtime registration refreshes an existing manifest participant without duplicating it', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
api.registerParticipants([fixture('valid_owner_provider.json')]);
|
||||
api.registerParticipant('stems', {
|
||||
capabilities: {
|
||||
stems: {
|
||||
roles: ['owner', 'provider'],
|
||||
commands: ['mute'],
|
||||
runtime: true,
|
||||
handlers: { mute: () => ({ outcome: 'handled', payload: { muted: true } }) },
|
||||
},
|
||||
},
|
||||
});
|
||||
api.registerParticipant('stems', {
|
||||
stems: {
|
||||
roles: ['owner', 'provider'],
|
||||
commands: ['mute'],
|
||||
runtime: true,
|
||||
handlers: { mute: () => ({ outcome: 'handled', payload: { muted: 'refreshed' } }) },
|
||||
},
|
||||
});
|
||||
|
||||
const stems = api.inspect('stems');
|
||||
assert.equal(stems.participants.filter(p => p.pluginId === 'stems').length, 1);
|
||||
assert.equal(stems.participants[0].runtime, true);
|
||||
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.deepEqual(result.payload, { muted: 'refreshed' });
|
||||
});
|
||||
|
||||
test('native library provider capability coordinates providers', async () => {
|
||||
const providers = [
|
||||
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
|
||||
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
|
||||
];
|
||||
const synced = [];
|
||||
const window = loadCapabilities({ library: true });
|
||||
window.fetch = async (url, options = {}) => {
|
||||
const text = String(url);
|
||||
if (text === '/api/library/providers') {
|
||||
return { ok: true, json: async () => ({ providers }) };
|
||||
}
|
||||
if (text.includes('/sync') && options.method === 'POST') {
|
||||
synced.push(text);
|
||||
return { ok: true, json: async () => ({ ok: true, filename: 'remote:frodo:song-1' }) };
|
||||
}
|
||||
throw new Error(`unexpected fetch ${text}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
const events = [];
|
||||
api.subscribe('library:source-changed', event => events.push(event));
|
||||
|
||||
await api.command('library', 'refresh-providers', { requester: 'test', payload: { restoreSaved: true } });
|
||||
const listed = await api.command('library', 'list-providers', { requester: 'test' });
|
||||
assert.equal(listed.outcome, 'handled');
|
||||
assert.equal(listed.payload.providers[1].owner_plugin_id, 'frodo_library');
|
||||
|
||||
const libraryPipeline = api.inspect('library');
|
||||
const providerParticipants = libraryPipeline.participants.filter(participant => participant.roles.includes('provider'));
|
||||
const localProvider = providerParticipants.find(participant => participant.pluginId === 'core.library.local');
|
||||
const remoteProvider = providerParticipants.find(participant => participant.pluginId === 'frodo_library');
|
||||
assert.ok(localProvider);
|
||||
assert.equal(localProvider.providerPolicy.providerId, 'local');
|
||||
assert.ok(localProvider.operations.includes('query-page'));
|
||||
assert.ok(remoteProvider);
|
||||
assert.equal(remoteProvider.providerPolicy.providerId, 'remote:frodo');
|
||||
assert.ok(remoteProvider.operations.includes('sync-song'));
|
||||
|
||||
const selected = await api.command('library', 'select-provider', {
|
||||
requester: 'test',
|
||||
target: { providerId: 'remote:frodo' },
|
||||
});
|
||||
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');
|
||||
|
||||
const syncResult = await api.command('library', 'sync-song', {
|
||||
requester: 'test',
|
||||
target: { providerId: 'remote:frodo', songId: 'song-1' },
|
||||
});
|
||||
assert.equal(syncResult.outcome, 'handled');
|
||||
assert.equal(syncResult.payload.result.filename, 'remote:frodo:song-1');
|
||||
assert.equal(synced.length, 1);
|
||||
});
|
||||
|
||||
test('removed library providers are unregistered as library participants on refresh', async () => {
|
||||
let providerSet = [
|
||||
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
|
||||
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
|
||||
{ id: 'remote:sam', label: 'Sam', capabilities: ['library.read'], owner_plugin_id: 'sam_library' },
|
||||
];
|
||||
const window = loadCapabilities({ library: true });
|
||||
window.fetch = async (url) => {
|
||||
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
let ids = api.inspect('library').participants.map(p => p.pluginId);
|
||||
assert.ok(ids.includes('frodo_library'));
|
||||
assert.ok(ids.includes('sam_library'));
|
||||
|
||||
// Sam is removed and the endpoint now omits it; the stale participant must
|
||||
// not linger in the capability registry / Inspector snapshot.
|
||||
providerSet = [
|
||||
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
|
||||
{ id: 'remote:frodo', label: 'Frodo', capabilities: ['library.read', 'song.sync'], owner_plugin_id: 'frodo_library' },
|
||||
];
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
ids = api.inspect('library').participants.map(p => p.pluginId);
|
||||
assert.ok(ids.includes('frodo_library'));
|
||||
assert.equal(ids.includes('sam_library'), false);
|
||||
assert.ok(ids.includes('core.library.local'));
|
||||
|
||||
// A fetch failure falls back to local-only — all remote providers drop.
|
||||
window.fetch = async () => { throw new Error('network down'); };
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
ids = api.inspect('library').participants.map(p => p.pluginId);
|
||||
assert.equal(ids.includes('frodo_library'), false);
|
||||
assert.ok(ids.includes('core.library.local'));
|
||||
});
|
||||
|
||||
test('a plugin with non-provider library roles is not wiped when its provider disappears', async () => {
|
||||
let providerSet = [
|
||||
{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true },
|
||||
{ id: 'remote:sam', label: 'Sam', capabilities: ['library.read'], owner_plugin_id: 'sam_library' },
|
||||
];
|
||||
const window = loadCapabilities({ library: true });
|
||||
window.fetch = async (url) => {
|
||||
if (String(url) === '/api/library/providers') return { ok: true, json: async () => ({ providers: providerSet }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
// The same plugin also declares an observer library role via its manifest —
|
||||
// legitimate participation the provider-cleanup path must not delete.
|
||||
api.registerParticipants([{
|
||||
id: 'sam_library',
|
||||
name: 'Sam Library',
|
||||
runtime_domains: { library: { role: 'observer', observes: ['source-changed'] } },
|
||||
}]);
|
||||
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
let sam = api.inspect('library').participants.find(p => p.pluginId === 'sam_library');
|
||||
assert.ok(sam);
|
||||
assert.ok(sam.roles.includes('provider'));
|
||||
assert.ok(sam.roles.includes('observer'));
|
||||
|
||||
// Sam's provider is removed from the backend list. The participant stays
|
||||
// because it still carries the manifest-declared observer role.
|
||||
providerSet = [{ id: 'local', label: 'My Library', capabilities: ['library.read'], default: true }];
|
||||
await api.command('library', 'refresh-providers', { requester: 'test' });
|
||||
sam = api.inspect('library').participants.find(p => p.pluginId === 'sam_library');
|
||||
assert.ok(sam, 'plugin with a manifest library role must survive provider removal');
|
||||
assert.ok(sam.roles.includes('observer'));
|
||||
});
|
||||
|
||||
test('runtime domain library declarations appear as library participants', () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
const touched = api.registerParticipants([{
|
||||
id: 'remote-library-client',
|
||||
name: 'Remote Library Client',
|
||||
runtime_domains: {
|
||||
library: {
|
||||
role: 'provider',
|
||||
operations: ['query-page'],
|
||||
description: 'Adds a remote source to the library provider list.',
|
||||
},
|
||||
},
|
||||
}]);
|
||||
|
||||
assert.deepEqual(Array.from(touched), ['library']);
|
||||
const library = api.inspect('library');
|
||||
const participant = library.participants.find(item => item.pluginId === 'remote-library-client');
|
||||
assert.ok(participant);
|
||||
assert.deepEqual(Array.from(participant.roles), ['provider']);
|
||||
assert.ok(participant.operations.includes('query-page'));
|
||||
assert.equal(participant.description, 'Adds a remote source to the library provider list.');
|
||||
assert.equal(participant.ownership, 'exclusive-owner');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
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 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 } });
|
||||
|
||||
const inspection = api.inspect('shared-viz');
|
||||
const result = await api.dispatch({ capability: 'shared-viz', command: 'register-provider', source: 'test' });
|
||||
|
||||
assert.equal(inspection.conflicts.some(c => c.type === 'duplicate-owner'), false);
|
||||
assert.equal(JSON.stringify(inspection.order.slice(-2)), JSON.stringify(['provider_a', 'provider_b']));
|
||||
assert.equal(result.status, 'applied');
|
||||
assert.equal(calls[0], 'a');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('exclusive duplicate owners report conflict and degrade dispatch', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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 } });
|
||||
|
||||
const inspection = api.inspect('stems');
|
||||
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
|
||||
|
||||
assert.equal(inspection.conflicts.some(c => c.type === 'duplicate-owner'), true);
|
||||
assert.equal(result.status, 'no-handler');
|
||||
assert.match(result.reason, /multiple owners|duplicate-owner|degraded/i);
|
||||
});
|
||||
|
||||
test('no-owner no-handler and unsupported-command outcomes are explicit', async () => {
|
||||
const window = loadCapabilities();
|
||||
const api = window.slopsmith.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');
|
||||
|
||||
api.registerParticipant('stems', { stems: { roles: ['owner'], commands: ['mute'], runtime: true } });
|
||||
result = await api.dispatch({ capability: 'stems', command: 'unknown', source: 'test' });
|
||||
assert.equal(result.status, 'unsupported-command');
|
||||
assert.equal(result.outcome, 'unsupported-command');
|
||||
|
||||
result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
|
||||
assert.equal(result.status, 'no-handler');
|
||||
assert.equal(result.outcome, 'no-handler');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const LIBRARY_CAPABILITY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function createWindow(options = {}) {
|
||||
class CustomEvent {
|
||||
constructor(type, init = {}) {
|
||||
this.type = type;
|
||||
this.detail = init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Map();
|
||||
const storage = new Map();
|
||||
const elements = new Map();
|
||||
const diagnosticsContributions = new Map();
|
||||
const window = {
|
||||
console,
|
||||
CustomEvent,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
performance: { now: () => Date.now() },
|
||||
addEventListener(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
list.push(handler);
|
||||
listeners.set(type, list);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
listeners.set(type, list.filter(item => item !== handler));
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
for (const handler of (listeners.get(event.type) || []).slice()) handler(event);
|
||||
return true;
|
||||
},
|
||||
localStorage: {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(String(key), String(value)); },
|
||||
removeItem(key) { storage.delete(String(key)); },
|
||||
},
|
||||
document: {
|
||||
getElementById(id) { return elements.get(id) || null; },
|
||||
},
|
||||
slopsmith: {
|
||||
emit(type, detail) {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
},
|
||||
diagnostics: options.diagnostics === false ? undefined : {
|
||||
contribute(id, payload) { diagnosticsContributions.set(id, payload); },
|
||||
snapshotContributions() { return Object.fromEntries(diagnosticsContributions); },
|
||||
},
|
||||
},
|
||||
__listeners: listeners,
|
||||
__storage: storage,
|
||||
__elements: elements,
|
||||
__diagnosticsContributions: diagnosticsContributions,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
return window;
|
||||
}
|
||||
|
||||
function loadCapabilities(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
const source = fs.readFileSync(CAPABILITIES_JS, 'utf8');
|
||||
vm.runInContext(source, context, { filename: CAPABILITIES_JS });
|
||||
if (options.library) {
|
||||
const librarySource = fs.readFileSync(LIBRARY_CAPABILITY_JS, 'utf8');
|
||||
vm.runInContext(librarySource, context, { filename: LIBRARY_CAPABILITY_JS });
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
module.exports = { loadCapabilities, createWindow, ROOT };
|
||||
@@ -0,0 +1,24 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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 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' }; } };
|
||||
fixture.capabilities.stems.runtime = true;
|
||||
|
||||
api.registerParticipants([fixture]);
|
||||
const result = await api.dispatch({ capability: 'stems', command: 'mute', source: 'test' });
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
|
||||
assert.equal(invoked, false);
|
||||
assert.equal(result.status, 'incompatible-version');
|
||||
assert.equal(result.outcome, 'incompatible-version');
|
||||
assert.equal(snapshot.unsupportedVersions.some(entry => entry.pluginId === 'future_capabilities'), true);
|
||||
assert.equal(snapshot.participants.find(p => p.pluginId === 'future_capabilities').availability, 'incompatible');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
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 APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
||||
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
||||
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1800) {
|
||||
const start = src.indexOf(needle);
|
||||
assert.ok(start !== -1, `missing source needle: ${needle}`);
|
||||
return src.slice(start, start + length);
|
||||
}
|
||||
|
||||
test('capability inspector manifest ships settings but no default nav entry', () => {
|
||||
const manifest = JSON.parse(source(MANIFEST));
|
||||
|
||||
assert.equal(manifest.id, 'capability_inspector');
|
||||
assert.equal(manifest.nav, undefined);
|
||||
assert.equal(manifest.settings.html, 'settings.html');
|
||||
});
|
||||
|
||||
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
||||
const src = source(APP_JS);
|
||||
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
||||
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
||||
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
||||
|
||||
assert.match(helper, /localStorage\.getItem\(CAPABILITY_INSPECTOR_NAV_SETTING\)\s*===\s*['"]1['"]/);
|
||||
assert.match(helper, /if \(plugin\.id === ['"]capability_inspector['"]\)/);
|
||||
assert.match(helper, /return null/);
|
||||
assert.match(helper, /label:\s*['"]Capabilities['"]/);
|
||||
assert.match(menu, /_pluginNav\(plugin\)/);
|
||||
assert.match(contributions, /const nav = _pluginNav\(plugin\)/);
|
||||
assert.doesNotMatch(contributions, /if \(plugin\.nav\)/);
|
||||
});
|
||||
|
||||
test('capability inspector settings toggles the plugins menu setting', () => {
|
||||
const html = source(SETTINGS_HTML);
|
||||
|
||||
assert.match(html, /id="capability-inspector-show-nav"/);
|
||||
assert.match(html, /capability_inspector\.showInPluginsMenu/);
|
||||
assert.match(html, /localStorage\.setItem\(key, '1'\)/);
|
||||
assert.match(html, /localStorage\.removeItem\(key\)/);
|
||||
assert.match(html, /window\.loadPlugins\(\)/);
|
||||
assert.match(html, /window\.showScreen\('plugin-capability_inspector'\)/);
|
||||
});
|
||||
|
||||
test('capability inspector screen ships scoped graph lane CSS', () => {
|
||||
const html = source(SCREEN_HTML);
|
||||
|
||||
assert.match(html, /<style>/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-graph-fallback\]/);
|
||||
assert.match(html, /display: flex !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-provider-card\]/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-participant-lane\]/);
|
||||
assert.match(html, /flex: 0 0 24rem/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-graph-cy\]/);
|
||||
assert.match(html, /display: block !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-endpoint-icon="command"\]/);
|
||||
assert.match(html, /background: #fb923c !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-endpoint-icon="operation"\]/);
|
||||
assert.match(html, /background: #c084fc !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-endpoint-flow="provider-operation"\]/);
|
||||
assert.match(html, /background: #d8b4fe !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-toggle-graph-group\]/);
|
||||
assert.match(html, /gap: 0\.625rem !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-graph-provider-endpoint-row\]/);
|
||||
assert.match(html, /\.capability-inspector \[data-graph-participant-endpoint-row\]/);
|
||||
assert.match(html, /\.capability-inspector \[data-role-icon\]/);
|
||||
assert.match(html, /\.capability-inspector \[data-origin-icon\]/);
|
||||
assert.match(html, /\.capability-inspector \[data-availability-icon\]/);
|
||||
assert.match(html, /width: 2\.125rem !important/);
|
||||
assert.match(html, /margin-left: 0\.5rem !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-owner-footer\]/);
|
||||
assert.match(html, /padding-top: 1rem !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-owner-description\]/);
|
||||
assert.match(html, /line-height: 1\.45 !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-graph-filter\]/);
|
||||
assert.match(html, /background: rgba\(31, 41, 55, 0\.72\) !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-domain-graph-filter\]\[aria-pressed="true"\]/);
|
||||
assert.match(html, /background: rgba\(126, 34, 206, 0\.55\) !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-legend-icon="command"\]/);
|
||||
assert.match(html, /background: #fb923c !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-legend-line="shimmed"\]/);
|
||||
assert.match(html, /border-top: 2px dashed #9ca3af !important/);
|
||||
assert.match(html, /<div class="max-w-7xl mx-auto px-6 pt-24 pb-16 capability-inspector">/);
|
||||
assert.match(html, /data-inspector-header/);
|
||||
assert.match(html, /\.capability-inspector \[data-summary-dashboard\]/);
|
||||
assert.match(html, /width: 100% !important/);
|
||||
assert.match(html, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-summary-card\]\[data-tone="clean"\]/);
|
||||
assert.match(html, /border-color: rgba\(52, 211, 153, 0\.55\) !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-summary-status-value\]/);
|
||||
assert.match(html, /color: #34d399 !important/);
|
||||
assert.match(html, /\.capability-inspector \[data-graph-capability-port\]/);
|
||||
assert.match(html, /right: -1\.75rem/);
|
||||
assert.match(html, /\.capability-inspector \[data-graph-participant-port\]/);
|
||||
assert.match(html, /left: -1\.75rem/);
|
||||
});
|
||||
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
||||
const src = source(APP_JS);
|
||||
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
||||
assert.ok(m, 'could not extract _navLabel from app.js');
|
||||
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
||||
// String nav (manifest "nav": "Declared") must win over the plugin name.
|
||||
assert.equal(_navLabel('Declared', { name: 'Fallback', id: 'x' }), 'Declared');
|
||||
// Object nav with a label.
|
||||
assert.equal(_navLabel({ label: 'Capabilities' }, { name: 'Fallback', id: 'x' }), 'Capabilities');
|
||||
// Object nav without a label falls back to name then id.
|
||||
assert.equal(_navLabel({}, { name: 'My Plugin', id: 'x' }), 'My Plugin');
|
||||
assert.equal(_navLabel({}, { id: 'x' }), 'x');
|
||||
// Null / whitespace-only nav falls back too.
|
||||
assert.equal(_navLabel(null, { name: 'My Plugin' }), 'My Plugin');
|
||||
assert.equal(_navLabel(' ', { name: 'My Plugin' }), 'My Plugin');
|
||||
});
|
||||
|
||||
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
||||
const src = source(APP_JS);
|
||||
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
||||
// label must derive from the loop's computed nav via _navLabel, not from
|
||||
// plugin.nav?.label (which drops string and synthesized labels).
|
||||
assert.match(src, /const label = _navLabel\(nav, plugin\);/);
|
||||
assert.doesNotMatch(src, /const label = plugin\.nav\?\.label/);
|
||||
});
|
||||
@@ -0,0 +1,738 @@
|
||||
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 = path.join(__dirname, '..', '..');
|
||||
const INSPECTOR_JS = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.js');
|
||||
|
||||
function makeElement(id) {
|
||||
const element = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
dataset: {},
|
||||
children: [],
|
||||
listeners: {},
|
||||
classList: {
|
||||
values: new Set(),
|
||||
add(name) { this.values.add(name); },
|
||||
remove(name) { this.values.delete(name); },
|
||||
contains(name) { return this.values.has(name); },
|
||||
},
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener(type, handler) { this.listeners[type] = handler; },
|
||||
};
|
||||
return element;
|
||||
}
|
||||
|
||||
function loadInspector(snapshot, options = {}) {
|
||||
class CustomEvent {
|
||||
constructor(type, init = {}) {
|
||||
this.type = type;
|
||||
this.detail = init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Map();
|
||||
const elements = new Map([
|
||||
['capability-inspector-filter', makeElement('capability-inspector-filter')],
|
||||
['capability-inspector-content', makeElement('capability-inspector-content')],
|
||||
['capability-inspector-empty', makeElement('capability-inspector-empty')],
|
||||
['capability-inspector-summary', makeElement('capability-inspector-summary')],
|
||||
['capability-inspector-refresh', makeElement('capability-inspector-refresh')],
|
||||
]);
|
||||
const window = {
|
||||
console,
|
||||
CustomEvent,
|
||||
setTimeout(callback) { callback(); return 1; },
|
||||
clearTimeout() {},
|
||||
addEventListener(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
list.push(handler);
|
||||
listeners.set(type, list);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
for (const handler of (listeners.get(event.type) || []).slice()) handler(event);
|
||||
return true;
|
||||
},
|
||||
slopsmith: {
|
||||
capabilities: {
|
||||
snapshotDiagnostics: () => (typeof snapshot === 'function' ? snapshot() : snapshot),
|
||||
},
|
||||
playback: options.playbackSnapshot ? {
|
||||
snapshot: () => options.playbackSnapshot,
|
||||
} : undefined,
|
||||
},
|
||||
navigator: { clipboard: { writeText: async () => {} } },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
getElementById(id) { return elements.get(id) || null; },
|
||||
createElement(tagName) { return makeElement(tagName); },
|
||||
addEventListener() {},
|
||||
},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
window.__listeners = listeners;
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(INSPECTOR_JS, 'utf8'), context, { filename: INSPECTOR_JS });
|
||||
return { window, elements };
|
||||
}
|
||||
|
||||
test('capability inspector renders playback session route loop bridges and outcomes', () => {
|
||||
const snapshot = {
|
||||
pipelines: [{ name: 'playback', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core playback facade.' }, participants: [{ pluginId: 'core', roles: ['owner'], commands: ['inspect'], events: ['ready'], runtime: true, availability: 'available' }], conflicts: [] }],
|
||||
participants: [{ pluginId: 'core' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const playbackSnapshot = {
|
||||
schema: 'slopsmith.playback.diagnostics.v1',
|
||||
state: {
|
||||
sessionId: 'playback-1',
|
||||
state: 'playing',
|
||||
target: { targetId: 'target-abc', localDisplay: { title: 'Song', artist: 'Artist', arrangement: 'Lead' } },
|
||||
media: { currentTime: 12.5, duration: 90, route: { routeKind: 'browser-media', state: 'active' }, loop: { enabled: true, startTime: 10, endTime: 20, state: 'active' } },
|
||||
route: { routeKind: 'browser-media', state: 'active', safeReason: 'browser media route active' },
|
||||
loop: { enabled: true, startTime: 10, endTime: 20, state: 'active' },
|
||||
},
|
||||
participants: [{ requesterId: 'plugin.practice' }, { observerId: 'plugin.hud' }],
|
||||
bridges: [{ bridgeId: 'playback.window-play-song', hitCount: 2 }],
|
||||
history: { current: { recentOutcomes: [{ operation: 'seek', status: 'completed' }], lifecycleEvents: [{ event: 'playback:seeked', state: 'playing' }] } },
|
||||
};
|
||||
const { elements } = loadInspector(snapshot, { playbackSnapshot });
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /data-playback-support/);
|
||||
assert.match(content, /Session: playback-1/);
|
||||
assert.match(content, /Target: Song - Artist/);
|
||||
assert.match(content, /Route: browser-media \(active\)/);
|
||||
assert.match(content, /playback\.window-play-song:2/);
|
||||
assert.match(content, /seek:completed/);
|
||||
});
|
||||
|
||||
test('capability inspector renders shims inside their capability domain', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Current library surface.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner'], commands: ['list-providers', 'refresh-providers', 'get-current', 'select-provider', 'sync-song', 'inspect'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art', 'sync-song'], events: ['providers-refreshed', 'source-changed', 'song-sync-started', 'song-sync-succeeded', 'song-sync-failed'], description: 'Owns the library provider registry and dispatches source selection, browsing, and song sync commands.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
{ pluginId: 'local', roles: ['provider'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art'], events: ['providers-refreshed', 'source-changed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe', providerPolicy: { providerId: 'local', kind: 'local', default: true } },
|
||||
{ pluginId: 'remote_library_client', roles: ['provider'], operations: ['query-page', 'query-artists', 'query-stats', 'tuning-names', 'get-art', 'sync-song'], events: ['providers-refreshed', 'source-changed', 'song-sync-started', 'song-sync-succeeded', 'song-sync-failed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe', providerPolicy: { providerId: 'remote:client', kind: 'remote', ownerPluginId: 'remote_library_client' } },
|
||||
{ pluginId: 'remote_library_server', roles: ['requester', 'observer'], commands: ['list-providers', 'get-current', 'inspect'], events: ['providers-refreshed', 'source-changed'], description: 'Wraps the local library source for direct remote-library clients.', runtime: false, availability: 'available', ownership: 'requester-only', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
{ name: 'custom.practice', review: { lifecycle: 'plugin-defined', label: 'Plugin-defined', tone: 'info', summary: 'Plugin-specific practice surface.' }, participants: [{ pluginId: 'practice_hud', roles: ['observer'], commands: [], runtime: true, availability: 'available', ownership: 'observer-only', safety: 'safe' }], conflicts: [] },
|
||||
{ name: 'backend.routes', review: { lifecycle: 'future-expansion', label: 'Future expansion', tone: 'warning', summary: 'Backend route bridge.' }, participants: [{ pluginId: 'core', roles: ['owner'], commands: ['inspect'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'privileged' }], conflicts: [] },
|
||||
{ name: 'playback', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core playback facade.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['play', 'pause', 'seek', 'snapshot'], events: ['song:ready', 'song:seek', 'beats:loaded', 'arrangement:changed'], description: 'Owns player transport commands and lifecycle events.', runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
|
||||
{ pluginId: 'plugin_1', roles: ['observer'], commands: [], events: ['song:ready', 'beats:loaded'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
|
||||
{ pluginId: 'plugin_2', roles: ['requester'], commands: ['play'], events: ['song:ready', 'arrangement:changed'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
|
||||
{ pluginId: 'plugin_3', roles: ['participant'], commands: ['pause', 'seek'], events: ['song:seek'], runtime: true, availability: 'available', ownership: 'exclusive-owner', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
{ name: 'audio-effects', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core audio-effects facade.' }, participants: [
|
||||
{ pluginId: 'core.audio.effects', roles: ['owner'], commands: ['register-provider', 'register-executor', 'resolve-plan', 'load-plan', 'inspect'], events: ['provider-registered', 'executor-registered'], description: 'Owns the provider and executor registry.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
|
||||
{ pluginId: 'nam-tone', roles: ['provider', 'executor'], operations: ['chain.resolve', 'executor.load-chain-plan'], events: ['plan-resolved'], description: 'Provides NAM fallback plans and browser execution.', runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
|
||||
], conflicts: [] },
|
||||
{ name: 'diagnostics', review: { lifecycle: 'diagnostic', label: 'Snapshot surface', tone: 'info', summary: 'Read-only diagnostics snapshot/export facade for support bundles and the Capability Inspector.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['snapshot'], events: [], description: 'Provides read-only capability snapshots for support bundles and the Capability Inspector.', runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
|
||||
{ pluginId: 'capability_inspector', roles: ['requester'], commands: ['snapshot'], events: [], runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
|
||||
], conflicts: [] },
|
||||
{ name: 'pipeline', review: { lifecycle: 'diagnostic', label: 'Graph controls', tone: 'info', summary: 'Capability graph operations: resolve, inspect, validate, and enable or disable participants.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner', 'provider'], commands: ['resolve', 'inspect', 'validate', 'participant.set-enabled'], events: ['resolved', 'runtime.validated', 'participant.state-changed'], description: 'Owns capability graph inspection, validation, resolution, and participant enablement commands.', runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
|
||||
{ pluginId: 'capability_inspector', roles: ['requester', 'observer'], commands: ['inspect', 'validate'], events: ['runtime.validated', 'participant.state-changed'], runtime: true, availability: 'available', ownership: 'diagnostic-only', safety: 'diagnostic-only' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core' }, { pluginId: 'remote_library_client' }, { pluginId: 'remote_library_server' }],
|
||||
compatibilityShims: [
|
||||
{
|
||||
shimId: 'remote_library_client:register_library_provider:library:remote:client',
|
||||
source: 'remote_library_client',
|
||||
capability: 'library',
|
||||
legacySurface: 'register_library_provider',
|
||||
status: 'used',
|
||||
hitCount: 3,
|
||||
providerId: 'remote:client',
|
||||
reason: 'legacy backend register_library_provider() registered provider',
|
||||
lastHitAt: '2026-05-24T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
shimId: 'runtime:library:refresh:window.slopsmith.libraryProviders.refresh',
|
||||
source: 'window.slopsmith.libraryProviders.refresh',
|
||||
capability: 'library',
|
||||
legacySurface: 'refresh',
|
||||
status: 'used',
|
||||
hitCount: 1,
|
||||
reason: 'Legacy library provider refresh invoked',
|
||||
lastHitAt: '2026-05-24T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
expectedCompatibilityShims: [
|
||||
{
|
||||
capability: 'library',
|
||||
legacySurface: 'register_library_provider',
|
||||
reason: 'legacy backend provider registration becomes a library participant',
|
||||
},
|
||||
{
|
||||
capability: 'library',
|
||||
legacySurface: 'refresh',
|
||||
reason: 'legacy library provider client refresh calls are counted as library.refresh command use',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
const summary = elements.get('capability-inspector-summary').innerHTML;
|
||||
const filterElement = elements.get('capability-inspector-filter');
|
||||
const filter = filterElement.innerHTML;
|
||||
|
||||
assert.match(summary, /Domains/);
|
||||
assert.match(summary, /Participants/);
|
||||
assert.match(summary, /data-summary-dashboard/);
|
||||
assert.match(summary, /<span class="text-\[11px\] uppercase tracking-wide text-gray-500" data-summary-label>Shim hit<\/span>\s*<span class="text-lg font-semibold text-white" data-summary-value>1<\/span>/);
|
||||
assert.doesNotMatch(summary, />Shim hits<\/span>\s*<span class="text-lg font-semibold text-white" data-summary-value>4<\/span>/);
|
||||
assert.doesNotMatch(summary, /Legacy listeners/);
|
||||
assert.match(summary, /data-summary-card="status" data-tone="clean"/);
|
||||
assert.match(summary, /<span data-summary-status-value data-tone="clean">Clean<\/span>/);
|
||||
assert.match(filter, /<optgroup label="Application and Library">/);
|
||||
assert.match(filter, /<optgroup label="Player and Audio Runtime">/);
|
||||
assert.match(filter, /<optgroup label="Plugin-defined Domains">/);
|
||||
assert.match(filter, /<optgroup label="Capability Runtime">/);
|
||||
assert.ok(filter.indexOf('ui.navigation') === -1);
|
||||
assert.ok(filter.indexOf('library') < filter.indexOf('playback'));
|
||||
assert.ok(filter.indexOf('playback') < filter.indexOf('audio-effects'));
|
||||
assert.ok(filter.indexOf('audio-effects') < filter.indexOf('custom.practice'));
|
||||
assert.ok(filter.indexOf('playback') < filter.indexOf('custom.practice'));
|
||||
assert.ok(filter.indexOf('custom.practice') < filter.indexOf('diagnostics'));
|
||||
assert.ok(filter.indexOf('backend.routes') === -1);
|
||||
assert.doesNotMatch(content, /Domain group/);
|
||||
assert.match(content, /<div class="h-4" aria-hidden="true"><\/div>/);
|
||||
assert.doesNotMatch(content, /Capability relationship map/);
|
||||
assert.doesNotMatch(content, /data-capability-link=/);
|
||||
assert.match(content, /data-domain-graph="library" data-domain-graph-expanded="false"/);
|
||||
assert.match(content, /title="library domain" aria-label="library domain" role="img">[\s\S]*?<path d="M12 7v14"/);
|
||||
assert.match(content, /<button type="button" data-toggle-domain="library"[^>]*title="Expand library domain graph"[^>]*>[^]*?<span class="min-w-0 break-all font-bold">library<\/span>/);
|
||||
assert.doesNotMatch(content, /Domain: <span/);
|
||||
assert.match(content, /title="4 participants" aria-label="4 participants" role="img"/);
|
||||
assert.doesNotMatch(content, /2 Participants/);
|
||||
assert.doesNotMatch(content, /1 Capability/);
|
||||
assert.doesNotMatch(content, /Observed links/);
|
||||
assert.doesNotMatch(content, /Shimmed link/);
|
||||
assert.match(content, /title="Domain status: Clean" aria-label="Domain status: Clean" role="img"/);
|
||||
assert.match(content, /Application and Library/);
|
||||
assert.match(content, /Player and Audio Runtime/);
|
||||
assert.match(content, /Plugin-defined Domains/);
|
||||
assert.match(content, /Capability Runtime/);
|
||||
assert.match(content, /title="pipeline domain" aria-label="pipeline domain" role="img">[\s\S]*?<path d="M22 12h-4l-3 7L9 5l-3 7H2"/);
|
||||
assert.doesNotMatch(content, /title="pipeline domain" aria-label="pipeline domain" role="img">[\s\S]*?<circle cx="6" cy="6" r="3"/);
|
||||
assert.ok(content.indexOf('library') < content.indexOf('playback'));
|
||||
assert.ok(content.indexOf('playback') < content.indexOf('audio-effects'));
|
||||
assert.ok(content.indexOf('audio-effects') < content.indexOf('custom.practice'));
|
||||
assert.ok(content.indexOf('playback') < content.indexOf('custom.practice'));
|
||||
assert.match(content, /data-domain-graph="audio-effects" data-domain-graph-expanded="false"/);
|
||||
assert.ok(content.indexOf('custom.practice') < content.indexOf('diagnostics'));
|
||||
assert.doesNotMatch(content, /backend\.routes/);
|
||||
assert.doesNotMatch(content, /Review scope/);
|
||||
assert.doesNotMatch(content, /Snapshot surface/);
|
||||
assert.doesNotMatch(content, /Graph controls/);
|
||||
assert.match(content, /library/);
|
||||
assert.match(content, /data-toggle-domain="library"/);
|
||||
assert.match(content, /title="Expand library domain graph"/);
|
||||
assert.match(content, /aria-expanded="false"/);
|
||||
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();
|
||||
const expandedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(expandedContent, /aria-expanded="true"/);
|
||||
assert.match(expandedContent, /data-domain-graph="library" data-domain-graph-expanded="true"/);
|
||||
assert.match(expandedContent, /title="Collapse library domain graph"/);
|
||||
assert.match(expandedContent, /data-domain-graph="library"[\s\S]*title="1 shimmed link"/);
|
||||
assert.doesNotMatch(expandedContent, /Domain summary/);
|
||||
assert.match(expandedContent, /title="Domain status: Clean"/);
|
||||
assert.match(expandedContent, /id="capability-domain-library-graph-frame"/);
|
||||
assert.match(expandedContent, /id="capability-domain-library-graph-cy"/);
|
||||
assert.match(expandedContent, /id="capability-domain-library-graph-fallback"/);
|
||||
assert.match(expandedContent, /data-domain-provider-card="true"/);
|
||||
assert.doesNotMatch(expandedContent, /Review scope/);
|
||||
assert.match(expandedContent, /data-domain-owner-name="core"/);
|
||||
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"/);
|
||||
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*<path d="M12 2\.8 21 8\.1v8\.8L12 22l-9-5\.1V8\.1L12 2\.8z"/);
|
||||
assert.match(expandedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*<circle cx="12" cy="12\.2" r="3\.4"/);
|
||||
assert.match(expandedContent, /data-domain-provider-card="true"[\s\S]*data-domain-owner-name="core"[\s\S]*data-graph-provider-group="operation"[\s\S]*data-graph-provider-group="command"[\s\S]*data-graph-provider-group="event"[\s\S]*data-domain-owner-description="core"[\s\S]*Owns the library provider registry and dispatches source selection, browsing, and song sync commands\./);
|
||||
assert.match(expandedContent, /data-domain-graph="library"[\s\S]*data-domain-owner-name="core"[\s\S]*<div class="min-w-0 truncate text-lg font-semibold text-white">core<\/div>[\s\S]*<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*data-role-icon="owner"[^>]*title="Owner"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*data-role-icon="provider-coordinator"[^>]*title="Provider coordinator"/);
|
||||
assert.doesNotMatch(expandedContent, /data-domain-owner-name="local"/);
|
||||
assert.doesNotMatch(expandedContent, /data-domain-owner-name="remote_library_client"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"/);
|
||||
assert.match(expandedContent, /title="Provider: local" aria-label="Provider: local" role="img"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"[\s\S]*<path d="M12 2\.8 21 8\.1v8\.8L12 22l-9-5\.1V8\.1L12 2\.8z"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-origin-icon="core"[^>]*title="Core provider"[\s\S]*<circle cx="12" cy="12\.2" r="3\.4"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"[^>]*title="Plugin: remote_library_client \| Roles: provider \| Provider: remote:client \| Registration: runtime \| Availability: available \| Safety: safe"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="remote_library_client"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core provider"/);
|
||||
assert.match(expandedContent, /title="Provider: remote:client" aria-label="Provider: remote:client" role="img"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="remote_library_server"[^>]*title="Plugin: remote_library_server \| Roles: requester, observer \| Registration: manifest \| Availability: available \| Safety: safe"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="remote_library_server"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
|
||||
assert.doesNotMatch(expandedContent, /data-domain-participant-card="remote_library_server"[\s\S]*Provider: remote_library_server/);
|
||||
assert.match(expandedContent, /data-link-kind="observed"[^>]*>query-page<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="local">query-page<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-event" data-link-participant="local">providers-refreshed<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="remote_library_client">sync-song<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="provider-event" data-link-participant="remote_library_client">song-sync-succeeded<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="command" data-link-participant="remote_library_server">list-providers<\/span>/);
|
||||
assert.match(expandedContent, /data-link-kind="observed" data-link-flow="event" data-link-participant="remote_library_server">source-changed<\/span>/);
|
||||
assert.doesNotMatch(expandedContent, /data-link-flow="provider-command" data-link-participant="remote_library_server"/);
|
||||
assert.doesNotMatch(expandedContent, /data-link-flow="provider-command" data-link-participant="local"/);
|
||||
assert.doesNotMatch(expandedContent, /data-link-flow="provider-event" data-link-participant="remote_library_server"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-endpoint-icon="operation" data-endpoint-flow="provider-operation" data-graph-participant-port="0:operation:query-page"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="local"[\s\S]*data-endpoint-icon="event" data-endpoint-flow="provider-event" data-graph-participant-port="0:event:providers-refreshed"/);
|
||||
assert.ok(expandedContent.indexOf('data-domain-participant-card="remote_library_server"') < expandedContent.indexOf('data-graph-participant-group="2:command"'));
|
||||
assert.ok(expandedContent.indexOf('data-graph-provider-group="operation"') < expandedContent.indexOf('data-graph-provider-group="command"'));
|
||||
assert.match(expandedContent, /Operation link/);
|
||||
assert.match(expandedContent, /Provider operation link/);
|
||||
assert.match(expandedContent, /Provider event link/);
|
||||
assert.match(expandedContent, /data-link-kind="shimmed"[^>]*>refresh<\/span>/);
|
||||
assert.doesNotMatch(expandedContent, /data-link-kind="shimmed"[^>]*>list<\/span>/);
|
||||
assert.doesNotMatch(expandedContent, /No command, operation, or event usage declared/);
|
||||
assert.doesNotMatch(expandedContent, /data-domain-participant-card="practice_hud"/);
|
||||
assert.match(expandedContent, /data-domain-graph="playback" data-domain-graph-expanded="true"/);
|
||||
assert.match(expandedContent, /data-domain-participant-card="plugin_3"/);
|
||||
assert.doesNotMatch(expandedContent, /Capability usage/);
|
||||
assert.doesNotMatch(expandedContent, /Compatibility shims/);
|
||||
assert.doesNotMatch(expandedContent, /Expected legacy surfaces/);
|
||||
assert.doesNotMatch(expandedContent, /data-copy-surface=/);
|
||||
|
||||
filterElement.value = 'playback';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
const selectedContent = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(selectedContent, /data-domain-graph="playback"/);
|
||||
assert.match(selectedContent, /title="playback domain" aria-label="playback domain" role="img">[\s\S]*?<circle cx="12" cy="12" r="10"/);
|
||||
assert.match(selectedContent, /id="capability-domain-playback-graph-cy"/);
|
||||
assert.match(selectedContent, /id="capability-domain-playback-graph-fallback"/);
|
||||
assert.match(selectedContent, /lg:flex lg:items-stretch lg:justify-between lg:gap-16 xl:gap-24/);
|
||||
assert.match(selectedContent, /lg:w-96 lg:max-w-96 lg:flex-none" data-domain-provider-card="true"/);
|
||||
assert.match(selectedContent, /lg:w-96 lg:max-w-96 lg:flex-none" data-domain-participant-lane="true"/);
|
||||
assert.match(selectedContent, /<h3 class="text-lg font-semibold text-white"><span class="font-bold">playback<\/span><\/h3>/);
|
||||
assert.doesNotMatch(selectedContent, /Domain: <span/);
|
||||
assert.doesNotMatch(selectedContent, /data-toggle-domain="playback"/);
|
||||
assert.match(selectedContent, /title="3 participants" aria-label="3 participants" role="img"/);
|
||||
assert.doesNotMatch(selectedContent, /3 Participants/);
|
||||
assert.doesNotMatch(selectedContent, /Owner \/ provider/);
|
||||
assert.match(selectedContent, /Commands/);
|
||||
assert.match(selectedContent, /Events/);
|
||||
assert.match(selectedContent, /play/);
|
||||
assert.match(selectedContent, /pause/);
|
||||
assert.match(selectedContent, /seek/);
|
||||
assert.match(selectedContent, /snapshot/);
|
||||
assert.match(selectedContent, /song:ready/);
|
||||
assert.match(selectedContent, /song:seek/);
|
||||
assert.match(selectedContent, /beats:loaded/);
|
||||
assert.match(selectedContent, /arrangement:changed/);
|
||||
assert.match(selectedContent, /plugin_1/);
|
||||
assert.match(selectedContent, /plugin_2/);
|
||||
assert.match(selectedContent, /plugin_3/);
|
||||
assert.match(selectedContent, /id="capability-domain-playback-graph-frame"/);
|
||||
assert.match(selectedContent, /data-graph-lanes="playback"/);
|
||||
assert.match(selectedContent, /data-graph-provider-group="command"/);
|
||||
assert.match(selectedContent, /data-graph-provider-group="event"/);
|
||||
assert.match(selectedContent, /data-graph-provider-group="command" data-graph-group-collapsed="false" data-graph-focus-kind="provider-group" data-graph-focus-type="command"/);
|
||||
assert.match(selectedContent, /data-graph-focus-kind="provider-group" data-graph-focus-type="command" data-graph-provider-focus="group"/);
|
||||
assert.match(selectedContent, /data-toggle-graph-group="playback\|provider\|all\|command"/);
|
||||
assert.match(selectedContent, /data-toggle-graph-group="playback\|provider\|all\|event"/);
|
||||
assert.match(selectedContent, /data-graph-capability-port="command:play"/);
|
||||
assert.match(selectedContent, /data-graph-capability-port="event:song:ready"/);
|
||||
assert.match(selectedContent, /data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint"/);
|
||||
assert.match(selectedContent, /relative flex min-h-5 min-w-0 items-center justify-end pr-0 text-right/);
|
||||
assert.doesNotMatch(selectedContent, /data-capability-node="command:play" data-graph-focus-kind/);
|
||||
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint">play<\/span><span class="absolute -right-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="command" data-graph-focus-label="play" data-graph-provider-focus="endpoint"><span class="inline-block[^\"]*" data-endpoint-icon="command" data-endpoint-flow="command" data-graph-capability-port="command:play"/);
|
||||
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="event" data-graph-focus-label="song:ready" data-graph-provider-focus="endpoint">song:ready<\/span><span class="absolute -right-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="provider-endpoint" data-graph-focus-type="event" data-graph-focus-label="song:ready" data-graph-provider-focus="endpoint"><span class="inline-block[^\"]*" data-endpoint-icon="event" data-endpoint-flow="event" data-graph-capability-port="event:song:ready"/);
|
||||
assert.doesNotMatch(selectedContent, /grid content-center gap-3 px-1/);
|
||||
assert.doesNotMatch(selectedContent, /start or resume playback|song is ready to play/);
|
||||
assert.match(selectedContent, /data-graph-hover-participant="2"/);
|
||||
assert.match(selectedContent, /title="Availability: available" aria-label="Availability: available" role="img"/);
|
||||
assert.doesNotMatch(selectedContent, /Review scope/);
|
||||
assert.match(selectedContent, /data-domain-owner-name="core"[\s\S]*<div class="min-w-0 truncate text-lg font-semibold text-white">core<\/div>[\s\S]*<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*data-role-icon="owner"[^>]*title="Owner"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"[\s\S]*data-role-icon="provider"[^>]*title="Capability provider"/);
|
||||
assert.match(selectedContent, /data-domain-owner-name="core"[\s\S]*data-origin-icon="core"[^>]*title="Core owner"/);
|
||||
assert.match(selectedContent, /data-domain-provider-card="true"[\s\S]*data-domain-owner-name="core"[\s\S]*data-graph-provider-group="command"[\s\S]*data-graph-provider-group="event"[\s\S]*data-domain-owner-description="core"[\s\S]*Owns player transport commands and lifecycle events\./);
|
||||
assert.doesNotMatch(selectedContent, /data-domain-participant-card="core"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="plugin_1"[^>]*title="Plugin: plugin_1 \| Roles: observer \| Registration: runtime \| Availability: available \| Safety: safe"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="plugin_1"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="plugin_2"[\s\S]*data-origin-icon="non-core"[^>]*title="Non-core participant"/);
|
||||
assert.doesNotMatch(selectedContent, /data-role-icon="observer"/);
|
||||
assert.doesNotMatch(selectedContent, /data-role-icon="requester"/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="plugin_3"[\s\S]*?<div class="flex items-center gap-3">[\s\S]*?<div class="min-w-0 flex-1 truncate text-lg font-semibold text-white">plugin_3<\/div>[\s\S]*?<div class="flex shrink-0 items-center justify-end gap-1">[\s\S]*?<span class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-emerald-500\/10 text-emerald-300 border-emerald-700\/60" title="Availability: available"/);
|
||||
assert.doesNotMatch(selectedContent, /title="Availability: available"[^>]*>[\s\S]*?<span>available<\/span><\/span>/);
|
||||
assert.match(selectedContent, /data-domain-participant-card="plugin_3"[\s\S]*?<div class="mt-3 grid gap-3 border-t border-gray-800\/70 pt-3">/);
|
||||
assert.doesNotMatch(selectedContent, /<span class="bg-purple-500\/15 text-purple-200 border border-purple-500\/30 px-2 py-1 rounded text-xs leading-none">participant<\/span>/);
|
||||
assert.match(selectedContent, /data-graph-participant-group="2:command"/);
|
||||
assert.match(selectedContent, /data-graph-participant-group="2:event"/);
|
||||
assert.match(selectedContent, /data-graph-participant-group="2:event" data-graph-group-collapsed="false" data-graph-focus-kind="participant-group" data-graph-focus-participant-index="2" data-graph-focus-type="event"/);
|
||||
assert.match(selectedContent, /data-graph-focus-kind="participant-group" data-graph-focus-participant-index="2" data-graph-focus-type="event"/);
|
||||
assert.match(selectedContent, /data-toggle-graph-group="playback\|participant\|2\|event"/);
|
||||
assert.match(selectedContent, /data-graph-participant-port="2:event:song:seek"/);
|
||||
assert.match(selectedContent, /data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek"/);
|
||||
assert.match(selectedContent, /relative flex min-h-5 min-w-0 items-center pl-0 text-sm/);
|
||||
assert.doesNotMatch(selectedContent, /relative flex min-h-5 min-w-0 items-center pl-0 text-sm text-gray-300" data-graph-focus-kind/);
|
||||
assert.match(selectedContent, /class="absolute -left-\[1\.4375rem\] top-1\/2 -translate-y-1\/2" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek"><span class="inline-block[^\"]*" data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="2:event:song:seek"/);
|
||||
assert.match(selectedContent, /<span class="min-w-0 truncate" data-graph-focus-kind="participant-endpoint" data-graph-focus-participant-index="2" data-graph-focus-type="event" data-graph-focus-label="song:seek">song:seek<\/span>/);
|
||||
assert.match(selectedContent, /data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="0:event:song:ready"/);
|
||||
assert.match(selectedContent, /data-endpoint-icon="event" data-endpoint-flow="event" data-graph-participant-port="2:event:song:seek"/);
|
||||
assert.match(selectedContent, /Command link/);
|
||||
assert.match(selectedContent, /Event link/);
|
||||
assert.match(selectedContent, /data-link-kind="observed"/);
|
||||
assert.doesNotMatch(selectedContent, /data-link-kind="shimmed"/);
|
||||
assert.doesNotMatch(selectedContent, /<svg class="pointer-events-none absolute inset-0 hidden h-full w-full lg:block"/);
|
||||
assert.match(selectedContent, /title="8 observed links" aria-label="8 observed links" role="img"/);
|
||||
assert.match(selectedContent, /title="0 shimmed links" aria-label="0 shimmed links" role="img"/);
|
||||
assert.match(selectedContent, /Show:/);
|
||||
assert.match(selectedContent, /class="flex flex-wrap items-center justify-end gap-2" data-domain-graph-filter-row="playback"/);
|
||||
assert.ok(selectedContent.indexOf('data-domain-graph-filter-row="playback"') < selectedContent.indexOf('>Legend</span>'));
|
||||
assert.match(selectedContent, /class="flex flex-wrap items-center gap-4 border-t border-gray-800 pt-2"/);
|
||||
assert.match(selectedContent, /data-domain-graph-filter="all"/);
|
||||
assert.match(selectedContent, />All<\/button>/);
|
||||
assert.match(selectedContent, />Operations<\/button>/);
|
||||
assert.ok(selectedContent.indexOf('>Operations<\/button>') < selectedContent.indexOf('>Commands<\/button>'));
|
||||
assert.ok(selectedContent.indexOf('data-legend-icon="operation"') < selectedContent.indexOf('data-legend-icon="command"'));
|
||||
assert.doesNotMatch(selectedContent, /Domain group/);
|
||||
|
||||
window.__slopsmithCapabilityInspector.graphCollapsedGroups = {
|
||||
'playback|provider|all|command': true,
|
||||
'playback|participant|2|event': true,
|
||||
};
|
||||
window.__slopsmithCapabilityInspector.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"/);
|
||||
assert.doesNotMatch(collapsedGroupContent, /data-graph-capability-port="command:play"/);
|
||||
assert.match(collapsedGroupContent, /data-graph-participant-port="2:group:event"/);
|
||||
assert.doesNotMatch(collapsedGroupContent, /data-graph-participant-port="2:event:song:seek"/);
|
||||
const collapsedParticipantButton = collapsedGroupContent.match(/<button type="button" data-toggle-graph-group="playback\|participant\|2\|event"[\s\S]*?<\/button>/)[0];
|
||||
assert.match(collapsedParticipantButton, /<span class="min-w-0 truncate">Events<\/span>/);
|
||||
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();
|
||||
|
||||
filterElement.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'operations';
|
||||
window.__slopsmithCapabilityInspector.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"/);
|
||||
assert.match(operationsOnlyContent, /data-link-kind="observed" data-link-flow="provider-operation" data-link-participant="local">query-page<\/span>/);
|
||||
assert.doesNotMatch(operationsOnlyContent, /data-capability-node="command:list-providers"/);
|
||||
assert.doesNotMatch(operationsOnlyContent, /data-capability-node="event:providers-refreshed"/);
|
||||
|
||||
filterElement.value = 'playback';
|
||||
window.__slopsmithCapabilityInspector.domainGraphFilter = 'events';
|
||||
window.__slopsmithCapabilityInspector.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();
|
||||
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();
|
||||
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"/);
|
||||
});
|
||||
|
||||
test('capability inspector drops stale future-domain filter options', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [], conflicts: [] },
|
||||
{ name: 'ui.player-panels', review: { lifecycle: 'future-expansion', label: 'Future expansion', tone: 'warning', summary: 'Stale future domain.' }, participants: [], conflicts: [] },
|
||||
],
|
||||
participants: [],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
const filter = elements.get('capability-inspector-filter');
|
||||
|
||||
filter.innerHTML += '<option value="ui.player-panels">ui.player-panels</option>';
|
||||
filter.value = 'ui.player-panels';
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
|
||||
assert.equal(filter.value, '');
|
||||
assert.doesNotMatch(filter.innerHTML, /ui\.player-panels/);
|
||||
assert.match(elements.get('capability-inspector-content').innerHTML, /library/);
|
||||
assert.doesNotMatch(elements.get('capability-inspector-content').innerHTML, /ui\.player-panels/);
|
||||
});
|
||||
|
||||
test('capability inspector refreshes collapsed counts after runtime capability changes', () => {
|
||||
let currentSnapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner'], commands: ['refresh', 'select'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(() => currentSnapshot);
|
||||
assert.match(elements.get('capability-inspector-content').innerHTML, /data-domain-graph="library" data-domain-graph-expanded="false"[\s\S]*?title="0 participants"/);
|
||||
|
||||
currentSnapshot = {
|
||||
...currentSnapshot,
|
||||
participants: [{ pluginId: 'core' }, { pluginId: 'window.slopsmith.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:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
|
||||
],
|
||||
expectedCompatibilityShims: [
|
||||
{ capability: 'library', legacySurface: 'refresh', reason: 'legacy library provider client refresh calls are counted as library.refresh command use' },
|
||||
{ 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'));
|
||||
|
||||
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"/);
|
||||
assert.match(refreshedContent, /title="2 shimmed links"/);
|
||||
});
|
||||
|
||||
test('capability inspector links library legacy command surfaces to canonical endpoints', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner'], commands: ['list', 'refresh', 'select', 'sync-song'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
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: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 },
|
||||
],
|
||||
expectedCompatibilityShims: [
|
||||
{ capability: 'library', legacySurface: 'refresh', reason: 'legacy library provider client refresh calls are counted as library.refresh command use' },
|
||||
{ capability: 'library', legacySurface: 'select', reason: 'legacy library provider selector calls are counted as library.select command use' },
|
||||
{ capability: 'library', legacySurface: 'sync-song', reason: 'legacy library provider sync calls are counted as library.sync-song command use' },
|
||||
],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
const filter = elements.get('capability-inspector-filter');
|
||||
|
||||
filter.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.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="remote_library_client"/);
|
||||
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>refresh<\/span>/);
|
||||
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>select<\/span>/);
|
||||
assert.match(libraryContent, /data-link-kind="shimmed"[^>]*>sync-song<\/span>/);
|
||||
assert.match(libraryContent, /title="2 participants"/);
|
||||
});
|
||||
|
||||
test('capability inspector clears provider hover without a graph hover target', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner'], commands: [], events: ['providers-refreshed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { elements, window } = loadInspector(snapshot);
|
||||
const focusElement = { dataset: { graphFocusKind: 'provider-group', graphFocusType: 'event' }, contains: () => false };
|
||||
focusElement.closest = selector => selector.includes('data-graph-focus-kind') ? focusElement : null;
|
||||
const target = { dataset: { graphProviderFocus: 'group' }, style: {} };
|
||||
window.document.querySelectorAll = selector => selector === '[data-domain-graph-frame]'
|
||||
? [{ querySelectorAll: frameSelector => (frameSelector === '[data-graph-provider-focus]' ? [target] : []) }]
|
||||
: [];
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
elements.get('capability-inspector-content').listeners.mouseout({ target: focusElement, relatedTarget: null });
|
||||
});
|
||||
});
|
||||
|
||||
test('selected domain participant count includes shim-only graph participants', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'library', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Library surface.' }, participants: [
|
||||
{ pluginId: 'core', roles: ['owner'], commands: ['refresh', 'select'], events: [], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
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:select:remote_library_client', source: 'remote_library_client', capability: 'library', legacySurface: 'select', status: 'used', hitCount: 1 },
|
||||
],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
const filter = elements.get('capability-inspector-filter');
|
||||
filter.value = 'library';
|
||||
window.__slopsmithCapabilityInspector.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="remote_library_client"/);
|
||||
});
|
||||
|
||||
test('capability inspector renders audio-mix fader diagnostics from audio-session snapshot', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'audio-mix', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio mix surface.' }, participants: [
|
||||
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-faders', 'set-fader-value'], operations: ['fader.get-value', 'fader.set-value'], events: ['fader-value-changed', 'fader-unavailable'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'safe' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core.audio.session' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.audio_session.diagnostics.v1',
|
||||
session: { route: { routeKind: 'desktop', availability: 'degraded' }, analyser: { source: 'plugin', availability: 'available' } },
|
||||
domains: {
|
||||
'audio-mix': {
|
||||
participants: [{ participantId: 'plugin.delay', label: 'Delay', sourceMode: 'native' }],
|
||||
faders: [
|
||||
{ participantId: 'plugin.delay', label: 'Delay Wet', faderId: 'wet', availability: 'available', sourceMode: 'native' },
|
||||
{ participantId: 'fader.legacy', label: 'Legacy Gain', faderId: 'gain', availability: 'disabled', sourceMode: 'compatibility', lastRejectedValue: 0.8 },
|
||||
],
|
||||
route: { routeKind: 'desktop', availability: 'degraded' },
|
||||
analyser: { source: 'plugin', availability: 'available' },
|
||||
bridges: [{ bridgeId: 'audio-mix.fader-registry', outcome: 'overridden', reason: 'overshadowed', hitCount: 1 }],
|
||||
},
|
||||
'audio-input': { sources: [], totalSources: 0 },
|
||||
'audio-monitoring': { sessions: [], totalSessions: 0 },
|
||||
stems: { owner: null, claims: [], bridges: [] },
|
||||
},
|
||||
recentOutcomes: [{ domain: 'audio-mix', operation: 'set-fader-value', participantId: 'fader.legacy', faderId: 'gain', outcome: 'failed', status: 'timeout' }],
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /data-audio-session-support/);
|
||||
assert.match(content, /Faders: Delay Wet:available:native, Legacy Gain:disabled:compatibility:failed/);
|
||||
assert.match(content, /Analyser: plugin \(available\)/);
|
||||
assert.match(content, /audio-mix\.fader-registry:overridden \(overshadowed\)/);
|
||||
assert.match(content, /Failures: audio-mix:set-fader-value:gain:timeout/);
|
||||
});
|
||||
|
||||
test('capability inspector renders audio-input sources selection sessions bridges and failures', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'audio-input', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio input surface.' }, participants: [
|
||||
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-sources', 'open-source', 'close-source'], operations: ['source.enumerate', 'source.open', 'source.close'], events: ['source-opened', 'source-closed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core.audio.session' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.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: [] },
|
||||
'audio-input': {
|
||||
sources: [
|
||||
{ sourceId: 'source-01', logicalSourceKey: 'native:instrument:primary', providerId: 'native', label: 'Input 1', availability: 'available', sourceMode: 'native', channelSummary: { channelShape: 'mono' } },
|
||||
{ sourceId: 'source-02', logicalSourceKey: 'legacy:instrument:primary', providerId: 'legacy', label: 'Legacy Input', availability: 'available', sourceMode: 'compatibility', channelSummary: { channelShape: 'stereo' }, supersededBy: 'source-01' },
|
||||
],
|
||||
selected: { logicalSourceKey: 'native:instrument:primary', availability: 'available', restoreStatus: 'available' },
|
||||
openSessions: [{ openSessionId: 'input-open-01', channelShape: 'mono', state: 'open', requesters: [{ requesterId: 'note_detect' }] }],
|
||||
totalSources: 2,
|
||||
totalOpenSessions: 1,
|
||||
bridges: [{ bridgeId: 'audio-input.legacy-source', status: 'overshadowed', outcome: 'overridden', hitCount: 1 }],
|
||||
},
|
||||
'audio-monitoring': { sessions: [], totalSessions: 0 },
|
||||
stems: { owner: null, claims: [], bridges: [] },
|
||||
},
|
||||
recentOutcomes: [{ domain: 'audio-input', operation: 'open-source', sourceId: 'source-03', outcome: 'denied', status: 'denied' }],
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /Input: Input 1:available:mono:native, Legacy Input:available:stereo:compatibility:superseded/);
|
||||
assert.match(content, /Selected input: native:instrument:primary:available:available/);
|
||||
assert.match(content, /Open input: input-open-01:mono:open:note_detect/);
|
||||
assert.match(content, /Input bridges: audio-input\.legacy-source:overshadowed/);
|
||||
assert.match(content, /Failures: audio-input:open-source:source-03:denied/);
|
||||
});
|
||||
|
||||
test('capability inspector renders audio-monitoring providers sessions direct monitor bridges and failures', () => {
|
||||
const snapshot = {
|
||||
pipelines: [
|
||||
{ name: 'audio-monitoring', review: { lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Audio monitoring surface.' }, participants: [
|
||||
{ pluginId: 'core.audio.session', roles: ['owner'], commands: ['list-providers', 'select-provider', 'start', 'stop', 'set-direct-monitor'], operations: ['monitoring.start', 'monitoring.stop', 'monitoring.status'], events: ['monitoring-started', 'direct-monitor-changed'], runtime: true, availability: 'available', ownership: 'multi-provider', safety: 'sensitive' },
|
||||
], conflicts: [] },
|
||||
],
|
||||
participants: [{ pluginId: 'core.audio.session' }],
|
||||
compatibilityShims: [],
|
||||
expectedCompatibilityShims: [],
|
||||
};
|
||||
const { window, elements } = loadInspector(snapshot);
|
||||
window.slopsmith.audioSession = {
|
||||
snapshot: () => ({
|
||||
schema: 'slopsmith.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: [] },
|
||||
'audio-input': { sources: [], totalSources: 0, openSessions: [], totalOpenSessions: 0, bridges: [] },
|
||||
'audio-monitoring': {
|
||||
providers: [
|
||||
{ providerId: 'native_monitor', logicalMonitoringKey: 'native:monitor:main', label: 'Native Monitor', availability: 'available', sourceMode: 'native' },
|
||||
{ providerId: 'legacy_monitor', logicalMonitoringKey: 'native:monitor:main', label: 'Legacy Monitor', availability: 'available', sourceMode: 'compatibility', supersededBy: 'native_monitor' },
|
||||
],
|
||||
selectedProvider: { providerId: 'native_monitor', logicalMonitoringKey: 'native:monitor:main', availability: 'available' },
|
||||
sessions: [
|
||||
{ monitoringId: 'monitoring-01', state: 'active', sourceRef: { logicalSourceKey: 'native:instrument:primary' }, requesters: [{ requesterId: 'user' }, { requesterId: 'note_detect' }], directMonitor: { preference: 'muted', control: 'supported', applied: true } },
|
||||
{ monitoringId: 'monitoring-02', state: 'failed', sourceRef: { logicalSourceKey: 'native:instrument:primary' }, requesters: [{ requesterId: 'practice_overlay' }], directMonitor: { preference: 'unmuted', control: 'unsupported', applied: false } },
|
||||
],
|
||||
totalProviders: 2,
|
||||
totalSessions: 2,
|
||||
directMonitor: { preference: 'muted', control: 'supported', applied: true },
|
||||
bridges: [{ bridgeId: 'audio-monitoring.audio-barrier', status: 'overshadowed', outcome: 'overridden', hitCount: 2 }],
|
||||
},
|
||||
stems: { owner: null, claims: [], bridges: [] },
|
||||
},
|
||||
recentOutcomes: [
|
||||
{ domain: 'audio-monitoring', operation: 'start', monitoringId: 'monitoring-02', requesterId: 'practice_overlay', outcome: 'failed', status: 'timeout' },
|
||||
{ domain: 'audio-monitoring', operation: 'start', requesterId: 'note_detect', outcome: 'provider-selection-required', status: 'provider-selection-required' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
window.__slopsmithCapabilityInspector.render();
|
||||
const content = elements.get('capability-inspector-content').innerHTML;
|
||||
|
||||
assert.match(content, /Monitoring providers: Native Monitor:available:native, Legacy Monitor:available:compatibility:superseded/);
|
||||
assert.match(content, /Selected monitoring: native:monitor:main:available:native_monitor/);
|
||||
assert.match(content, /Monitoring: monitoring-01:active:user\+note_detect:native:instrument:primary, monitoring-02:failed:practice_overlay:native:instrument:primary/);
|
||||
assert.match(content, /Direct monitor: muted:supported:applied/);
|
||||
assert.match(content, /Monitoring bridges: audio-monitoring\.audio-barrier:overshadowed/);
|
||||
assert.match(content, /Failures: audio-monitoring:start:monitoring-02:timeout, audio-monitoring:start:note_detect:provider-selection-required/);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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 { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
function loadDiagnostics() {
|
||||
const window = createWindow();
|
||||
// diagnostics.js short-circuits if window.slopsmith.diagnostics already
|
||||
// exists (idempotent guard); the harness stubs it, so clear it first.
|
||||
window.slopsmith.diagnostics = undefined;
|
||||
window.navigator = { userAgent: 'test' };
|
||||
const context = vm.createContext(window);
|
||||
const source = fs.readFileSync(path.join(ROOT, 'static', 'diagnostics.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'diagnostics.js' });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('summarizeRuntimeDomains counts actual UI contributions, not the {declared,legacy} wrapper keys', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
|
||||
const snapshot = {
|
||||
plugins: [
|
||||
{
|
||||
// Normalized backend shape: two declared regions (3 entries
|
||||
// total) + two legacy entries = 5, NOT 2 (the wrapper keys).
|
||||
ui_contributions: {
|
||||
declared: {
|
||||
'ui.navigation': [{ id: 'a' }, { id: 'b' }],
|
||||
'settings': [{ id: 'c' }],
|
||||
},
|
||||
legacy: [
|
||||
{ region: 'ui.navigation', legacy_source: 'nav' },
|
||||
{ region: 'settings', legacy_source: 'settings' },
|
||||
],
|
||||
},
|
||||
runtime_domains: { library: {}, playback: {} },
|
||||
},
|
||||
{
|
||||
ui_contributions: { declared: {}, legacy: [] },
|
||||
runtime_domains: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const summary = summarizeRuntimeDomains(snapshot);
|
||||
assert.equal(summary.plugin_count, 2);
|
||||
assert.equal(summary.ui_contribution_count, 5);
|
||||
assert.equal(summary.runtime_domain_count, 2);
|
||||
});
|
||||
|
||||
test('summarizeRuntimeDomains tolerates a flat region→contributions map', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.diagnostics;
|
||||
const summary = summarizeRuntimeDomains({
|
||||
plugins: [{ ui_contributions: { 'ui.navigation': [{ id: 'a' }, { id: 'b' }] } }],
|
||||
});
|
||||
assert.equal(summary.ui_contribution_count, 2);
|
||||
});
|
||||
|
||||
test('summarizeRuntimeDomains counts only array region values (malformed values are ignored)', () => {
|
||||
const { summarizeRuntimeDomains } = loadDiagnostics().slopsmith.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({
|
||||
plugins: [{ ui_contributions: { declared: { good: [{ id: 'a' }, { id: 'b' }], bad: { id: 'x' }, alsoBad: 'nope' }, legacy: [] } }],
|
||||
});
|
||||
assert.equal(declaredSummary.ui_contribution_count, 2);
|
||||
const flatSummary = summarizeRuntimeDomains({
|
||||
plugins: [{ ui_contributions: { good: [{ id: 'a' }], bad: { id: 'x' } } }],
|
||||
});
|
||||
assert.equal(flatSummary.ui_contribution_count, 1);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Verify static/highway.js handles the `handshapes` WebSocket message:
|
||||
// accumulates incoming chunks, time-sorts on `ready`, and exposes the
|
||||
// result on the renderer bundle. The 3D arpeggio frame / chord rails
|
||||
// pipeline consumes that bundle; if accumulation or sort drifts, those
|
||||
// visuals silently render wrong (out-of-order frames, missing hints).
|
||||
//
|
||||
// Same source-level guard strategy as the other tests/js/ files —
|
||||
// extract the relevant case body and assert the wiring.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
function getCaseBlock(src, label) {
|
||||
const start = src.indexOf(`case '${label}'`);
|
||||
assert.ok(start !== -1, `case '${label}' not found in highway.js`);
|
||||
const tail = src.slice(start);
|
||||
const nextCase = tail.search(/\n\s*case\s+['"]/);
|
||||
const nextDefault = tail.search(/\n\s*default\s*:/);
|
||||
let end = tail.length;
|
||||
if (nextCase > 0) end = Math.min(end, nextCase);
|
||||
if (nextDefault > 0) end = Math.min(end, nextDefault);
|
||||
return tail.slice(0, end);
|
||||
}
|
||||
|
||||
test('handshapes WS case accumulates incoming chunks into handShapes', () => {
|
||||
// Server streams handshapes in chunks; the case must concat rather
|
||||
// than replace so multi-chunk sources don't silently truncate.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const block = getCaseBlock(src, 'handshapes');
|
||||
assert.match(
|
||||
block,
|
||||
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
'handshapes case must concat msg.data into the handShapes accumulator',
|
||||
);
|
||||
});
|
||||
|
||||
test('ready case time-sorts handShapes before rendering', () => {
|
||||
// Out-of-order chunks would otherwise leave handShapes interleaved,
|
||||
// breaking the binary-search lookups the 3D renderer does when
|
||||
// probing arpeggio coverage for a chord at time t.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const block = getCaseBlock(src, 'ready');
|
||||
assert.match(
|
||||
block,
|
||||
/handShapes\.sort\(\s*\(\s*a\s*,\s*b\s*\)\s*=>\s*a\.start_time\s*-\s*b\.start_time\s*\)/,
|
||||
'ready case must sort handShapes by start_time so the renderer can rely on ordering',
|
||||
);
|
||||
});
|
||||
|
||||
test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
// Renderers (highway_3d in particular) read `bundle.handShapes`; if
|
||||
// the bundle key gets renamed or dropped, the 3D arp-frame pipeline
|
||||
// goes dark without a runtime error. The current shape is a ternary
|
||||
// that picks `_filteredHandShapes` when phrase data carries any and
|
||||
// falls back to the flat `handShapes` list otherwise (DLC pattern).
|
||||
// Pin both sides of the ternary so accidentally dropping the
|
||||
// fallback branch fails the test.
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Pins the arpeggio chord-gem deferral gating in plugins/highway_3d/screen.js
|
||||
// (slopsmith#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.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+chordShapeCoveredByStandaloneNotes\s*\(\s*ch\s*,\s*shape\s*,\s*notesArr\s*,\s*timeWin\s*\)/,
|
||||
'helper that scans the note stream for shape coverage must remain on screen.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('deferChordGems gates both synth and explicit+covered branches on note-stream coverage', () => {
|
||||
// Either branch firing without coverage produces the empty-lavender-frame
|
||||
// regression PR #262 fixed. Pin both predicates so a refactor that drops
|
||||
// one gate fails the test.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/,
|
||||
'deferChordGems must guard the h3dSynth and explicit+covered branches with the coverage check',
|
||||
);
|
||||
});
|
||||
|
||||
test('noteStreamCoversArpShape is computed lazily (called, not eagerly bound)', () => {
|
||||
// Eager allocation regressed perf on dense charts (Copilot review on PR
|
||||
// #262). The shape must be a callable so short-circuit evaluation skips
|
||||
// the note-stream scan when neither gating branch needs it.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/,
|
||||
'noteStreamCoversArpShape must be an arrow/function so the scan is lazy',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/,
|
||||
'noteStreamCoversArpShape must not eagerly invoke the coverage helper',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Pins the camera framing + lookahead behaviour in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Two independent pieces are covered:
|
||||
// 1. Zoom-dependent framing — the cam.position height/depth multipliers are
|
||||
// interpolated by zoom distance between a NEAR (tight, nut-position) and a
|
||||
// FAR (wide, whole-neck) view via the CAM_FRAME_* constants, instead of
|
||||
// being fixed literals.
|
||||
// 2. Measure-based lookahead — the camera lookahead window spans
|
||||
// CAM_LOOKAHEAD_MEASURES measures ahead (derived from the chart beats,
|
||||
// ignoring intra-measure measure === -1 beats) instead of a fixed number
|
||||
// of seconds.
|
||||
//
|
||||
// A refactor that re-hardcodes the framing multipliers, drops the measure
|
||||
// cache, or reverts the lookahead window to seconds would silently regress the
|
||||
// camera. Also guards that the temporary debug hook stayed removed.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
// ── Zoom-dependent framing ──────────────────────────────────────────────────
|
||||
|
||||
test('framing NEAR/FAR multiplier constants are defined', () => {
|
||||
for (const name of [
|
||||
'CAM_FRAME_DIST_NEAR', 'CAM_FRAME_DIST_FAR',
|
||||
'CAM_FRAME_H_NEAR', 'CAM_FRAME_H_FAR',
|
||||
'CAM_FRAME_D_NEAR', 'CAM_FRAME_D_FAR',
|
||||
]) {
|
||||
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
|
||||
`${name} must be declared as a framing constant`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cam.position uses interpolated framing multipliers, not literals', () => {
|
||||
// The height/depth multipliers are computed (_hMul / _dMul), not inlined.
|
||||
// The base position is assigned into _camX/_camY/_camZ so the opt-in
|
||||
// free-camera bridge (#771) can layer orbit/zoom/height on top before the
|
||||
// single cam.position.set; the multipliers must still feed _camY/_camZ.
|
||||
assert.match(
|
||||
src,
|
||||
/_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/,
|
||||
'the base camera position must use the interpolated _hMul / _dMul multipliers',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/,
|
||||
'cam.position.set must apply the computed _camX / _camY / _camZ',
|
||||
);
|
||||
});
|
||||
|
||||
test('framing multipliers are a clamped zoom-distance interpolation', () => {
|
||||
// _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR.
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/,
|
||||
'_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/,
|
||||
'height multiplier must lerp NEAR->FAR by _zt',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/,
|
||||
'depth multiplier must lerp NEAR->FAR by _zt',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Measure-based lookahead window ──────────────────────────────────────────
|
||||
|
||||
test('lookahead window is expressed in measures with a seconds fallback', () => {
|
||||
assert.match(src, /const\s+CAM_LOOKAHEAD_MEASURES\s*=\s*9\b/,
|
||||
'CAM_LOOKAHEAD_MEASURES must default to 9');
|
||||
assert.match(src, /const\s+CAM_LOOKAHEAD_SEC\s*=\s*3\.0\b/,
|
||||
'CAM_LOOKAHEAD_SEC must stay as the no-beats fallback');
|
||||
});
|
||||
|
||||
test('measure-start cache only keeps beats with measure >= 0', () => {
|
||||
// Intra-measure beats carry measure === -1 and must be skipped.
|
||||
assert.match(
|
||||
src,
|
||||
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?_measureStarts\s*=\s*_ms/,
|
||||
'only measure-start beats (measure >= 0) feed _measureStarts',
|
||||
);
|
||||
});
|
||||
|
||||
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
|
||||
'lookaheadEndTime(now) helper must exist',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
|
||||
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
|
||||
);
|
||||
// No beats → seconds fallback.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/,
|
||||
'lookaheadEndTime must fall back to seconds when there are no measures',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
|
||||
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
|
||||
);
|
||||
});
|
||||
|
||||
test('measure-start cache is invalidated on song change', () => {
|
||||
// The song-change reset (reconnect path) resets _camSnapped; it must also
|
||||
// drop the measure-start cache, otherwise lookaheadEndTime sizes the window
|
||||
// off the previous song's measure grid and over-zooms the first-data snap.
|
||||
assert.match(
|
||||
src,
|
||||
/_camSnapped\s*=\s*false\s*;[\s\S]*?_measureStarts\s*=\s*\[\]\s*;\s*_measureStartsRef\s*=\s*null\s*;/,
|
||||
'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Debug hook stayed removed ───────────────────────────────────────────────
|
||||
|
||||
test('temporary camera debug hook is not present', () => {
|
||||
assert.doesNotMatch(src, /h3dCamDebug/,
|
||||
'the window.h3dCamDebug tuning hook must not ship in the renderer');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Pins the renderOrder of fret-number labels and connector/drop lines in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// The 3D highway uses depth-proportional renderOrder values so near entities
|
||||
// paint over far entities while same-depth sublayers stay deterministic. Fret
|
||||
// numbers must be labels, not gem peers: they render above the note symbols so
|
||||
// incoming gems never partially cover the digits.
|
||||
//
|
||||
// Same-note ordering:
|
||||
// renderOrderForLayerAtZ(z, CHORD_FRAME) chord frame edge
|
||||
// renderOrderForLayerAtZ(noteZ, CONNECTOR_LINE) connector / drop line
|
||||
// renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE) gem outline
|
||||
// renderOrderForLayerAtZ(noteZ, NOTE_CORE) gem core
|
||||
// renderOrderForLayerAtZ(noteZ, TECHNIQUE_MARKER) technique marker
|
||||
// renderOrderForLayerAtZ(noteZ, NOTE_FRET_LABEL) primary fret number
|
||||
// renderOrderForLayerAtZ(noteZ, ARP_NOTE_FRET_LABEL) arpeggio fret-number tie-breaker
|
||||
//
|
||||
// Source-level regex checks; no Three.js or DOM required.
|
||||
|
||||
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');
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
test('connector line uses the named connector layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArpNote\s*\?\s*'ARP_CONNECTOR_LINE'\s*:\s*'CONNECTOR_LINE'\s*\)\s*;/,
|
||||
'pConnectorLine renderOrder must use the connector layer names',
|
||||
);
|
||||
});
|
||||
|
||||
test('primary fret label renders above gem core and technique markers', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/fretLabel\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArpNote\s*\?\s*'ARP_NOTE_FRET_LABEL'\s*:\s*'NOTE_FRET_LABEL'\s*\)\s*;/,
|
||||
'pNoteFretLabel renderOrder must use the fret-label layer names',
|
||||
);
|
||||
});
|
||||
|
||||
test('synthetic chord fret label uses the same label layer as primary labels', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/fl2\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*_isArp2\s*\?\s*'ARP_NOTE_FRET_LABEL'\s*:\s*'NOTE_FRET_LABEL'\s*\)\s*;/,
|
||||
'fl2 renderOrder must use the same fret-label layer names',
|
||||
);
|
||||
});
|
||||
|
||||
test('drop line uses the named connector layer below gems', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/dl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'CONNECTOR_LINE'\s*\)\s*;/,
|
||||
'pDropLine renderOrder must use CONNECTOR_LINE',
|
||||
);
|
||||
});
|
||||
|
||||
test('chord-loop fret labels render above same-depth gem symbols', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lbl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRET_LABEL'\s*\)\s*;/,
|
||||
'chord-loop fret label must use CHORD_FRET_LABEL',
|
||||
);
|
||||
});
|
||||
|
||||
test('chord frame and note outline use named depth-layer helper calls', () => {
|
||||
assert.match(src(), /const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/);
|
||||
assert.match(src(), /outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/);
|
||||
});
|
||||
|
||||
test('no fixed low renderOrder assignments remain for affected label paths', () => {
|
||||
const s = src();
|
||||
assert.doesNotMatch(s, /fretLabel\.renderOrder\s*=\s*(?:16|23)\s*;/);
|
||||
assert.doesNotMatch(s, /fl2\.renderOrder\s*=\s*(?:16|23)\s*;/);
|
||||
assert.doesNotMatch(s, /lbl\.renderOrder\s*=\s*21\s*;/);
|
||||
assert.doesNotMatch(s, /line\.renderOrder\s*=\s*_isArpNote\s*\?\s*22\s*:\s*15\s*;/);
|
||||
assert.doesNotMatch(s, /dl\.renderOrder\s*=\s*22\s*;/);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Pins the fret-row label visibility and color rules in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Rules:
|
||||
// 1. Only main frets (DOTS: 3,5,7,9,12,…) show a gray label by default.
|
||||
// Non-dot frets outside the anchor range are skipped entirely.
|
||||
// 2. Every fret inside the active anchor range [f0, f1] shows a gold label,
|
||||
// even if it is not a dot fret.
|
||||
// f0 = anchor.fret, f1 = anchor.fret + anchor.width - 1
|
||||
// e.g. { fret:3, width:4 } → 3,4,5,6 gold (4 is not a dot fret).
|
||||
//
|
||||
// Source-level regex checks — no Three.js or DOM required.
|
||||
|
||||
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');
|
||||
|
||||
let _src;
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
test('fret row uses anchorPlayedFretSpanAt to get anchor range [f0, f1]', () => {
|
||||
// anchorPlayedFretSpanAt returns { f0: anchor.fret, f1: anchor.fret+width-1 }.
|
||||
// This is the correct span for note labels (the played zone), distinct from
|
||||
// the wire span used by fret wires (anchorLaneBoundsAt: dMin=fret-1, dMax=fret+width-1).
|
||||
assert.match(
|
||||
src(),
|
||||
/anchorPlayedFretSpanAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'fret row must call anchorPlayedFretSpanAt(anchors, now)',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-main frets outside anchor range are skipped (continue)', () => {
|
||||
// The loop must skip frets that are neither in the anchor range nor a
|
||||
// DOTS (main) fret, so non-dot frets never show a gray ghost label.
|
||||
assert.match(
|
||||
src(),
|
||||
/if\s*\(\s*!isInAnchor\s*&&\s*!isMainFret\s*\)\s*continue\s*;/,
|
||||
'fret row loop must skip non-anchor non-dot frets with: if (!isInAnchor && !isMainFret) continue',
|
||||
);
|
||||
});
|
||||
|
||||
test('in-anchor frets use FRET_LABEL_GOLD_HEX color', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/isInAnchor\s*\?\s*FRET_LABEL_GOLD_HEX\s*:\s*FRET_LABEL_IDLE_HEX/,
|
||||
'fret row color must be FRET_LABEL_GOLD_HEX when in anchor, FRET_LABEL_IDLE_HEX otherwise',
|
||||
);
|
||||
});
|
||||
|
||||
test('in-anchor frets use opacity 1.0, idle frets use opacity 0.55', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lb\.material\.opacity\s*=\s*isInAnchor\s*\?\s*1\.0\s*:\s*0\.55\s*;/,
|
||||
'fret row opacity must be 1.0 in anchor and 0.55 idle',
|
||||
);
|
||||
});
|
||||
|
||||
test('isMainFret uses DOTS.includes(f) — same dot positions as fret wires and inlays', () => {
|
||||
// Main frets are the dot-inlay positions (3,5,7,9,12,15,17,19,21,24).
|
||||
// Reusing DOTS keeps the visible set consistent across all fret indicators.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+isMainFret\s*=\s*DOTS\.includes\(\s*f\s*\)/,
|
||||
'isMainFret must be defined as DOTS.includes(f)',
|
||||
);
|
||||
});
|
||||
|
||||
test('isInAnchor checks anchorSpan.f0 and anchorSpan.f1 inclusive bounds', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/f\s*>=\s*anchorSpan\.f0\s*&&\s*f\s*<=\s*anchorSpan\.f1/,
|
||||
'isInAnchor must check f >= anchorSpan.f0 && f <= anchorSpan.f1',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329).
|
||||
// The board can render fret columns either Uniform (equal width, Rocksmith
|
||||
// Remastered style) or Logarithmic (real instrument geometry), switchable at
|
||||
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
|
||||
// refactor that renames the storage key, drops the uniform/log branch in
|
||||
// fretX, or stops validating the mode would silently regress the setting.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_h3dFretUniform\s*=\s*localStorage\.getItem\(\s*'highway_3d\.fretSpacing'\s*\)\s*!==\s*'logarithmic'/,
|
||||
'startup must read highway_3d.fretSpacing and treat anything but "logarithmic" as uniform',
|
||||
);
|
||||
});
|
||||
|
||||
test('fretX switches between the uniform and logarithmic implementations', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+fretX\s*=\s*f\s*=>\s*_h3dFretUniform\s*\?\s*_fretXUni\(f\)\s*:\s*_fretXLog\(f\)/,
|
||||
'fretX must pick _fretXUni when _h3dFretUniform else _fretXLog',
|
||||
);
|
||||
});
|
||||
|
||||
test('h3dSetFretSpacing validates the mode against the two supported values', () => {
|
||||
// An unexpected input must not be persisted verbatim — it is coerced to
|
||||
// one of 'logarithmic' | 'uniform' before writing to localStorage.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?mode\s*===\s*'logarithmic'\s*\?\s*'logarithmic'\s*:\s*'uniform'[\s\S]*?localStorage\.setItem\(\s*'highway_3d\.fretSpacing'/,
|
||||
'h3dSetFretSpacing must coerce mode to a supported value before persisting',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Pins the "lean sustain" rendering default in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Dense palm-mute / fret-hand-mute passages are GPU fill-bound: the
|
||||
// transparent sustain trails/rails stack many blended fragments. Profiling
|
||||
// (a pinned A/B loop) traced most of the cost to the additive rail BLOOM
|
||||
// halo, so by default the renderer skips ONLY the bloom (the most expensive
|
||||
// per-pixel layer) while KEEPING the thin trail/ribbon white outline that
|
||||
// gives tails their hit/miss-coloured border.
|
||||
//
|
||||
// A refactor that (a) flips the lean default off, (b) re-gates the trail or
|
||||
// ribbon outline behind the lean flag, or (c) stops feeding the outline the
|
||||
// hit/miss-aware material would silently regress the look or the perf win.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('lean sustain rendering is the default (_leanSus starts true)', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_leanSus\s*=\s*true\s*;/,
|
||||
'_leanSus must default to true so lean rendering is the out-of-the-box behaviour',
|
||||
);
|
||||
});
|
||||
|
||||
test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_leanSus\s*=\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]/,
|
||||
"lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look",
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// Only the additive rail bloom may hide behind the lean flag. If a future
|
||||
// edit re-gates the trail or ribbon outline behind !_leanSus, this count
|
||||
// climbs above 1 and the test fails — that's the regression guard.
|
||||
const gates = src.match(/if\s*\(\s*!_leanSus\s*\)/g) || [];
|
||||
assert.equal(
|
||||
gates.length,
|
||||
1,
|
||||
'expected exactly one `if (!_leanSus)` gate (the rail bloom); the outline must stay ungated',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*!_leanSus\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
|
||||
'the single lean gate must be the one that wraps pSusRailBloom.get()',
|
||||
);
|
||||
});
|
||||
|
||||
test('the trail + ribbon outline always draw and use the hit/miss-aware material', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// Outline material is hit/miss aware: miss -> mMissOutline, confirmed hit
|
||||
// -> bright, otherwise the default mSusOutline white border.
|
||||
assert.match(
|
||||
src,
|
||||
/_susOlMat\s*=\s*_ndState\s*===\s*'miss'\s*\?\s*mMissOutline[\s\S]*?:\s*mSusOutline\s*;/,
|
||||
'_susOlMat must remain hit/miss aware so the tail border colours track note state',
|
||||
);
|
||||
// Box trail: the outline (trOut, pSusOutline) is drawn and fed _susOlMat,
|
||||
// immediately followed by the coloured core (tr, pSus) — both ungated.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+trOut\s*=\s*pSusOutline\.get\(\)\s*;[\s\S]*?trOut\.material\s*=\s*_susOlMat\s*;[\s\S]{0,400}?const\s+tr\s*=\s*pSus\.get\(\)/,
|
||||
'the box-trail outline (pSusOutline + _susOlMat) must draw alongside the core trail',
|
||||
);
|
||||
// Ribbon trail (slide / bend / tremolo / vibrato): the outline (olMesh,
|
||||
// pSusRibbonOl) is drawn and fed _susOlMat, then the ribbon body.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+olMesh\s*=\s*pSusRibbonOl\.get\(\)\s*;[\s\S]*?olMesh\.material\s*=\s*_susOlMat\s*;[\s\S]*?const\s+body\s*=\s*pSusRibbon\.get\(\)/,
|
||||
'the ribbon-trail outline (pSusRibbonOl + _susOlMat) must draw alongside the ribbon body',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// Pins 3D Highway left-handed fret ordering (slopsmith#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.
|
||||
|
||||
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 HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js');
|
||||
const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js');
|
||||
const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md');
|
||||
|
||||
function src(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*:\s*_lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
test('3D Highway defines lefty-aware fret-position helpers', () => {
|
||||
const screen = src(SCREEN_JS);
|
||||
assert.match(
|
||||
screen,
|
||||
/let\s+_leftyCached\s*=\s*false\s*;/,
|
||||
'renderer must cache the bundle lefty flag',
|
||||
);
|
||||
assert.match(
|
||||
screen,
|
||||
/const\s+xFret\s*=\s*f\s*=>\s*\(\s*_leftyCached\s*\?\s*-fretX\(f\)\s*:\s*fretX\(f\)\s*\)/,
|
||||
'xFret must mirror fret edges when lefty is active',
|
||||
);
|
||||
assert.match(
|
||||
screen,
|
||||
/const\s+xFretMid\s*=\s*f\s*=>\s*\(\s*_leftyCached\s*\?\s*-fretMid\(f\)\s*:\s*fretMid\(f\)\s*\)/,
|
||||
'xFretMid must mirror fret centers when lefty is active',
|
||||
);
|
||||
assert.match(
|
||||
screen,
|
||||
/const\s+boardSpanX\s*=\s*\(\s*\)\s*=>\s*\{[\s\S]*?const\s+x0\s*=\s*xFret\(0\)\s*;[\s\S]*?const\s+xN\s*=\s*xFret\(NFRETS\)\s*;[\s\S]*?min\s*:\s*Math\.min\(x0,\s*xN\)[\s\S]*?max\s*:\s*Math\.max\(x0,\s*xN\)[\s\S]*?center\s*:\s*\(x0\s*\+\s*xN\)\s*\/\s*2[\s\S]*?width\s*:\s*Math\.abs\(xN\s*-\s*x0\)/,
|
||||
'boardSpanX must derive min/max/center/width from the lefty-aware xFret helper',
|
||||
);
|
||||
});
|
||||
|
||||
test('draw(bundle) handles lefty changes by flipping camera X state and rebuilding the board', () => {
|
||||
const screen = src(SCREEN_JS);
|
||||
assert.match(
|
||||
screen,
|
||||
/_leftyCached\s*=\s*!!bundle\.lefty\s*;/,
|
||||
'draw(bundle) must refresh _leftyCached from bundle.lefty',
|
||||
);
|
||||
assert.match(
|
||||
screen,
|
||||
/const\s+leftyChanged\s*=\s*_leftyCached\s*!==\s*_leftyForBoard\s*;/,
|
||||
'draw(bundle) must detect runtime lefty changes',
|
||||
);
|
||||
assert.match(
|
||||
screen,
|
||||
/if\s*\(\s*_invertedCached\s*!==\s*_invertedForBoard\s*\|\|\s*leftyChanged\s*\|\|\s*newNStr\s*!==\s*nStr\s*\)\s*\{[\s\S]*?if\s*\(\s*leftyChanged\s*\)\s*\{[\s\S]*?curX\s*=\s*-curX\s*;[\s\S]*?tgtX\s*=\s*-tgtX\s*;[\s\S]*?_lookaheadCamX\s*=\s*-_lookaheadCamX\s*;[\s\S]*?\}[\s\S]*?buildBoard\(\)\s*;[\s\S]*?_leftyForBoard\s*=\s*_leftyCached\s*;/,
|
||||
'lefty changes must mirror curX/tgtX/_lookaheadCamX, rebuild board geometry, and update _leftyForBoard',
|
||||
);
|
||||
});
|
||||
|
||||
test('camera shoulder offset follows the cached lefty orientation', () => {
|
||||
assert.match(
|
||||
src(SCREEN_JS),
|
||||
// The shoulder offset now feeds the base _camX (which the opt-in
|
||||
// free-camera bridge layers on top of) before cam.position.set (#771).
|
||||
/const\s+shoulderOffset\s*=\s*\(\s*_leftyCached\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
|
||||
'camera shoulder offset must flip with _leftyCached',
|
||||
);
|
||||
});
|
||||
|
||||
test('3D Highway documentation says the renderer consumes bundle.lefty', () => {
|
||||
const doc = src(CLAUDE_MD);
|
||||
assert.doesNotMatch(
|
||||
doc,
|
||||
/bundle\.lefty[\s\S]{0,180}renderer never reads it/,
|
||||
'CLAUDE.md must not claim the 3D renderer ignores bundle.lefty',
|
||||
);
|
||||
assert.match(
|
||||
doc,
|
||||
/bundle\.lefty[\s\S]{0,240}(?:mirrors|lefty|left-handed|left-handed)/i,
|
||||
'CLAUDE.md should document the lefty flag as part of the renderer contract',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Contract test for 3D Highway per-panel control metadata (slopsmith#247).
|
||||
// The plugin script is evaluated in a vm sandbox so factory statics are
|
||||
// tested without constructing a renderer instance or calling init().
|
||||
|
||||
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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// 'palette' was removed — per-string colors are now set via the core
|
||||
// "Highway String Colors" UI, which drives both highways by named string.
|
||||
const REQUIRED_KEYS = ['cameraSmoothing', 'cameraLockLow', 'cameraLockZoom'];
|
||||
const FORBIDDEN_KEYS = ['customImageDataUrl', 'customImageName', 'customVideoName'];
|
||||
const VALID_TYPES = new Set(['select', 'range', 'toggle']);
|
||||
|
||||
function loadHighway3dStatics() {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// Inject test exports right after the factory registration — a stable,
|
||||
// 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;';
|
||||
assert.equal(
|
||||
src.split(ANCHOR).length - 1,
|
||||
1,
|
||||
'expected exactly one factory-registration anchor in screen.js',
|
||||
);
|
||||
const instrumented = src.replace(
|
||||
ANCHOR,
|
||||
`${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`,
|
||||
);
|
||||
assert.notEqual(instrumented, src, 'test export injection anchor not found in screen.js');
|
||||
|
||||
const sandbox = {
|
||||
console: {
|
||||
error() {},
|
||||
log() {},
|
||||
warn() {},
|
||||
},
|
||||
localStorage: {
|
||||
getItem() { return null; },
|
||||
setItem() {},
|
||||
},
|
||||
performance: { now: () => 0 },
|
||||
window: {
|
||||
slopsmithTour: {
|
||||
register() {},
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
|
||||
return sandbox.window;
|
||||
}
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function optionValue(option) {
|
||||
if (option && typeof option === 'object') return option.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assertOptionObject(option, controlKey) {
|
||||
assert.equal(
|
||||
Object.prototype.toString.call(option),
|
||||
'[object Object]',
|
||||
`${controlKey}.options entries must be { id, label } objects`,
|
||||
);
|
||||
assert.equal(typeof option.id, 'string', `${controlKey}.options id must be a string`);
|
||||
assert.ok(option.id.length > 0, `${controlKey}.options id must not be blank`);
|
||||
assert.equal(typeof option.label, 'string', `${controlKey}.options label must be a string`);
|
||||
assert.ok(option.label.trim().length > 0, `${controlKey}.options label must not be blank`);
|
||||
}
|
||||
|
||||
test('3D Highway exposes static panelControls descriptors for per-panel hosts', () => {
|
||||
const window = loadHighway3dStatics();
|
||||
const factory = window.slopsmithViz_highway_3d;
|
||||
assert.equal(typeof factory, 'function', 'screen.js must register the 3D Highway factory');
|
||||
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(factory, 'panelControls'),
|
||||
'panelControls must be an own static property on the factory',
|
||||
);
|
||||
assert.ok(Array.isArray(factory.panelControls), 'panelControls must be an array');
|
||||
|
||||
const controls = cloneJson(factory.panelControls);
|
||||
const defaults = cloneJson(window.__h3dTestExports.BG_DEFAULTS);
|
||||
const keys = controls.map((control) => control && control.key);
|
||||
assert.deepEqual(keys, REQUIRED_KEYS, 'panelControls must expose exactly the issue #247 control set');
|
||||
const duplicateKeys = keys.filter((key, index) => keys.indexOf(key) !== index);
|
||||
assert.deepEqual(duplicateKeys, [], 'panelControls keys must be unique');
|
||||
|
||||
const controlsByKey = new Map();
|
||||
|
||||
for (const control of controls) {
|
||||
assert.equal(
|
||||
Object.prototype.toString.call(control),
|
||||
'[object Object]',
|
||||
'each panel control must be a plain descriptor object',
|
||||
);
|
||||
assert.equal(typeof control.key, 'string', 'descriptor.key must be a string');
|
||||
assert.match(control.key, /^[A-Za-z][A-Za-z0-9]*$/, 'descriptor.key must be a BG_DEFAULTS-style key');
|
||||
assert.equal(typeof control.label, 'string', `${control.key}.label must be a string`);
|
||||
assert.ok(control.label.trim().length > 0, `${control.key}.label must not be blank`);
|
||||
assert.equal(typeof control.type, 'string', `${control.key}.type must be a string`);
|
||||
assert.ok(VALID_TYPES.has(control.type), `${control.key}.type must be select, range, or toggle`);
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(control, 'default'), `${control.key} must declare a default`);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(defaults, control.key),
|
||||
`${control.key} must map to a BG_DEFAULTS entry`,
|
||||
);
|
||||
assert.deepEqual(control.default, defaults[control.key], `${control.key}.default must match BG_DEFAULTS`);
|
||||
assert.ok(!controlsByKey.has(control.key), `${control.key} appears more than once in panelControls`);
|
||||
controlsByKey.set(control.key, control);
|
||||
|
||||
if (control.type === 'select') {
|
||||
assert.ok(Array.isArray(control.options), `${control.key}.options must be an array`);
|
||||
assert.ok(control.options.length > 0, `${control.key}.options must not be empty`);
|
||||
const values = control.options.map(optionValue);
|
||||
assert.equal(values.length, new Set(values).size, `${control.key}.options values must be unique`);
|
||||
for (const option of control.options) {
|
||||
assertOptionObject(option, control.key);
|
||||
}
|
||||
for (const value of values) {
|
||||
assert.equal(typeof value, 'string', `${control.key}.options values must be strings`);
|
||||
}
|
||||
assert.ok(values.includes(control.default), `${control.key}.options must include the default`);
|
||||
}
|
||||
|
||||
if (control.type === 'range') {
|
||||
assert.equal(typeof control.min, 'number', `${control.key}.min must be a number`);
|
||||
assert.equal(typeof control.max, 'number', `${control.key}.max must be a number`);
|
||||
assert.ok(Number.isFinite(control.min), `${control.key}.min must be finite`);
|
||||
assert.ok(Number.isFinite(control.max), `${control.key}.max must be finite`);
|
||||
assert.ok(control.min < control.max, `${control.key}.min must be less than max`);
|
||||
assert.equal(typeof control.default, 'number', `${control.key}.default must be numeric`);
|
||||
assert.ok(control.default >= control.min, `${control.key}.default must be >= min`);
|
||||
assert.ok(control.default <= control.max, `${control.key}.default must be <= max`);
|
||||
if (Object.prototype.hasOwnProperty.call(control, 'step')) {
|
||||
assert.equal(typeof control.step, 'number', `${control.key}.step must be a number`);
|
||||
assert.ok(control.step > 0, `${control.key}.step must be positive`);
|
||||
}
|
||||
}
|
||||
|
||||
if (control.type === 'toggle') {
|
||||
assert.equal(typeof control.default, 'boolean', `${control.key}.default must be boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
assert.ok(controlsByKey.has(key), `panelControls must include ${key}`);
|
||||
}
|
||||
for (const key of FORBIDDEN_KEYS) {
|
||||
assert.ok(!controlsByKey.has(key), `panelControls must not expose global-only asset key ${key}`);
|
||||
}
|
||||
|
||||
const cameraSmoothing = controlsByKey.get('cameraSmoothing');
|
||||
assert.equal(cameraSmoothing.type, 'range', 'cameraSmoothing must be a range control');
|
||||
assert.equal(cameraSmoothing.min, 0);
|
||||
assert.equal(cameraSmoothing.max, 1);
|
||||
assert.equal(cameraSmoothing.default, defaults.cameraSmoothing);
|
||||
|
||||
const cameraLockLow = controlsByKey.get('cameraLockLow');
|
||||
assert.equal(cameraLockLow.type, 'toggle', 'cameraLockLow must be a toggle control');
|
||||
assert.equal(typeof cameraLockLow.default, 'boolean', 'cameraLockLow default must be boolean');
|
||||
assert.equal(cameraLockLow.default, defaults.cameraLockLow);
|
||||
|
||||
const cameraLockZoom = controlsByKey.get('cameraLockZoom');
|
||||
assert.equal(cameraLockZoom.type, 'range', 'cameraLockZoom must be a range control');
|
||||
assert.equal(cameraLockZoom.min, 0);
|
||||
assert.equal(cameraLockZoom.max, 1);
|
||||
assert.equal(cameraLockZoom.default, defaults.cameraLockZoom);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Source-level guards for the pool().warm() helper added in
|
||||
// slopsmith#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
|
||||
// non-finite or negative input can't spin a while-loop until OOM.
|
||||
//
|
||||
// Like the other highway_3d tests in this directory, this pattern-matches
|
||||
// the source rather than executing it — the createHighway() closure owns
|
||||
// canvas + WebGL lifecycle that's too heavy for 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction so warm() / coercion checks scope to the
|
||||
// `function pool(...)` body (matching the helper shape used in
|
||||
// highway_note_state.test.js). Without scoping, a future helper named
|
||||
// `warm(cap)` elsewhere in the file would satisfy these guards while
|
||||
// the pool factory's contract was silently broken.
|
||||
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('pool factory exposes warm(cap)', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// The pool() factory's return object must include a `warm(cap)`
|
||||
// method. Scope the match to the factory body so an unrelated
|
||||
// future `warm(cap)` helper elsewhere in the file can't satisfy
|
||||
// this guard.
|
||||
const poolBody = extractBlock(src, 'function pool(parent, mk)');
|
||||
assert.match(poolBody, /\bwarm\s*\(\s*cap\s*\)\s*\{/, 'pool factory must expose warm(cap)');
|
||||
});
|
||||
|
||||
test('pool.warm coerces cap to a non-negative integer', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// Same scoping discipline as above — the coercion must live
|
||||
// inside the pool factory's warm() body, not anywhere else.
|
||||
const poolBody = extractBlock(src, 'function pool(parent, mk)');
|
||||
assert.match(
|
||||
poolBody,
|
||||
/warm\s*\(\s*cap\s*\)\s*\{[\s\S]*?Math\.max\(\s*0\s*,\s*cap\s*\|\s*0\s*\)/,
|
||||
'pool.warm must guard against non-finite / negative cap via Math.max(0, cap | 0)'
|
||||
);
|
||||
});
|
||||
|
||||
test('warm() is called at boardInit with renderer-scoped cap constants', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
// The note / chord / lane / beat cap constants live inside the
|
||||
// boardInit/initScene path (renderer-instance scope, not module
|
||||
// scope); each must exist as a const and drive at least one .warm()
|
||||
// call site.
|
||||
for (const cap of ['_WARM_NOTE', '_WARM_CHORD', '_WARM_LANE', '_WARM_BEAT']) {
|
||||
assert.match(src, new RegExp(`const ${cap}\\s*=`), `${cap} const must exist`);
|
||||
assert.match(src, new RegExp(`\\.warm\\(\\s*${cap}\\b`), `${cap} must drive at least one .warm() call`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. All 3D-highway materials use depthTest:false, so
|
||||
// renderOrder is the *only* draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
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');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from screen.js. */
|
||||
function layers() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, default gray 0x666688', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*0x666688/,
|
||||
'fret wire material must have default gray color 0x666688',
|
||||
);
|
||||
// Both depth flags asserted independently so the test doesn't pin property
|
||||
// order in the material literal.
|
||||
assert.match(
|
||||
s,
|
||||
/depthTest\s*:\s*false/,
|
||||
'fret wire material must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/depthWrite\s*:\s*false/,
|
||||
'fret wire material must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire gold (0xD8A636) for in-anchor frets, gray (0x666688) otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0xD8A636\s*\)/,
|
||||
'update() must set gold 0xD8A636 for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0x666688\s*\)/,
|
||||
'update() must set gray 0x666688 for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Pins the auto-reframe-on-layout-settle behaviour in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Bug it guards against: when a song opens, the player screen may not have
|
||||
// its final dimensions yet (controls / sections bar still laying out). The
|
||||
// highway canvas is `#highway { flex: 1; min-height: 0 }`, so its real
|
||||
// rendered box (canvasSize() via getBoundingClientRect) is temporarily too
|
||||
// tall — applySize() then frames cam.aspect for the wrong height and the
|
||||
// camera crops the near strings / fret-number row. Once the layout settles
|
||||
// the flex box shrinks to the correct size, but the backing store
|
||||
// (canvas.width/height) does NOT change, so the splitscreen-oriented
|
||||
// `_lastHwW/_lastHwH` check never fires and the framing stays wrong until the
|
||||
// user un/re-maximizes the window (which fires a real `resize`).
|
||||
//
|
||||
// The fix makes draw() additionally compare the live canvas box against the
|
||||
// last logical size handed to applySize() (_appliedW/_appliedH) and re-apply
|
||||
// on >1px drift even when the backing store is unchanged. A refactor that
|
||||
// drops the CSS-box comparison, stops recording _appliedW/_appliedH, or
|
||||
// reverts to backing-store-only detection would silently bring the bug back.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
// ── Applied-size tracking ───────────────────────────────────────────────────
|
||||
|
||||
test('the last applied logical size is tracked as instance state', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_appliedW\s*=\s*0\s*,\s*_appliedH\s*=\s*0\s*;/,
|
||||
'_appliedW / _appliedH must be declared as per-instance state',
|
||||
);
|
||||
});
|
||||
|
||||
test('applySize records the logical w/h it applied', () => {
|
||||
// Recorded right after the aspect/aspectScale update so the draw() drift
|
||||
// check can compare against the size actually framed for.
|
||||
assert.match(
|
||||
src,
|
||||
/aspectScale\s*=\s*Math\.max\(1,[\s\S]*?_appliedW\s*=\s*w\s*;\s*_appliedH\s*=\s*h\s*;/,
|
||||
'applySize must set _appliedW = w; _appliedH = h after computing aspectScale',
|
||||
);
|
||||
});
|
||||
|
||||
// ── draw() re-frames on CSS-box drift ───────────────────────────────────────
|
||||
|
||||
test('draw() reads the live canvas box once per frame', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+box\s*=\s*canvasSize\(\s*highwayCanvas\s*\)\s*;/,
|
||||
'draw() must sample canvasSize(highwayCanvas) for the live box',
|
||||
);
|
||||
});
|
||||
|
||||
test('backing-store drift branch is preserved (splitscreen path)', () => {
|
||||
// The original check that catches the splitscreen hw.resize override
|
||||
// resizing the element without calling renderer.resize() must remain.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
|
||||
'the backing-store (canvas.width/height) drift branch must still re-apply',
|
||||
);
|
||||
});
|
||||
|
||||
test('draw() re-applies on CSS-box drift even without a backing-store change', () => {
|
||||
// The else-if branch: backing store unchanged, but the flex box drifted
|
||||
// from the last applied logical size by more than 1px → re-frame. This is
|
||||
// the branch that fixes the open-song crop without a manual window resize.
|
||||
assert.match(
|
||||
src,
|
||||
/else if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*&&\s*\(\s*Math\.abs\(\s*box\.w\s*-\s*_appliedW\s*\)\s*>\s*1\s*\|\|\s*Math\.abs\(\s*box\.h\s*-\s*_appliedH\s*\)\s*>\s*1\s*\)\s*\)\s*\{\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
|
||||
'draw() must re-apply when the live box drifts >1px from _appliedW/_appliedH',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Lifecycle reset ─────────────────────────────────────────────────────────
|
||||
|
||||
test('destroy() resets the applied-size tracking', () => {
|
||||
// Instances are reused across songs (destroy() → init()); stale applied
|
||||
// dims would suppress the first reframe of the next song.
|
||||
assert.match(
|
||||
src,
|
||||
/_appliedW\s*=\s*0\s*;\s*_appliedH\s*=\s*0\s*;/,
|
||||
'destroy() must reset _appliedW / _appliedH to 0',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Pins slide-target gem suppression in plugins/highway_3d/screen.js (PR #329).
|
||||
// A note that is the slide/link destination of a preceding sustained note has
|
||||
// its gem body suppressed (skipBody=true) so it does not render a duplicate
|
||||
// gem on top of the slide trail — but the sustain/slide trail itself still
|
||||
// renders so the slide motion stays visible. A refactor that drops the
|
||||
// _slideTargetSet pre-pass, stops threading _isSlideTgt into drawNote, or
|
||||
// moves the trail back inside the !skipBody gate would silently reintroduce
|
||||
// duplicate gems or erase slide trails.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+checkSrc\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*?stSet\.add\(/,
|
||||
'pre-pass checkSrc must populate the slide-target set',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*stSet\.size\s*>\s*0\s*\)\s*_slideTargetSet\s*=\s*stSet/,
|
||||
'_slideTargetSet must be assigned from the pre-pass result',
|
||||
);
|
||||
});
|
||||
|
||||
test('_isSlideTgt is derived from _slideTargetSet membership', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/_isSlideTgt\s*=\s*!!\(\s*_slideTargetSet\s*&&\s*_slideTargetSet\.has\(/,
|
||||
'_isSlideTgt must test _slideTargetSet membership',
|
||||
);
|
||||
});
|
||||
|
||||
test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
|
||||
// drawNote(n, now, openX, skipLabel, skipBody, ...) — _isSlideTgt sits in
|
||||
// the 5th (skipBody) position so the gem body is suppressed.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/drawNote\(\s*n\s*,\s*now\s*,\s*singleOpenX\s*,\s*skipLabel\s*,\s*_isSlideTgt\s*,/,
|
||||
'_isSlideTgt must be passed as drawNote\'s skipBody argument',
|
||||
);
|
||||
});
|
||||
|
||||
test('the sustain trail renders for all notes, including skipBody slide targets', () => {
|
||||
// The trail block must stay outside the !skipBody gem gate so suppressed
|
||||
// slide-target gems still show their slide trail.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/Rendered for ALL notes with sustain, including skipBody=true/,
|
||||
'the sustain-trail comment contract must remain, marking the trail as unconditional',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Source-level guards for the smoothNow() pause-drift fix.
|
||||
//
|
||||
// smoothNow() interpolates bundle.currentTime forward with performance.now()
|
||||
// between distinct audio samples. Before this fix it only stopped once the
|
||||
// interpolation cap (dt > 0.1 s) was crossed, so for the first ~100 ms of a
|
||||
// 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
|
||||
// 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
|
||||
// lifecycle that's too heavy to execute in a vm sandbox — same approach as
|
||||
// the other highway source-guard tests in this dir).
|
||||
|
||||
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');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.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('core _makeBundle exposes isPlaying derived from the chart-clock anchor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Field present in the bundle.
|
||||
assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying');
|
||||
// It is computed from the same anchor/advance state getTime() uses, not a
|
||||
// hardcoded literal — anchor must exist AND the clock must have advanced
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
|
||||
);
|
||||
});
|
||||
|
||||
test('smoothNow returns raw and re-anchors when the host reports not playing', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function smoothNow(bundle)');
|
||||
// Strict === false so downlevel hosts (isPlaying undefined) fall through
|
||||
// to the existing staleness-based interpolation cap.
|
||||
const guardIdx = fn.search(/bundle\.isPlaying\s*===\s*false/);
|
||||
assert.ok(guardIdx !== -1, 'smoothNow must check bundle.isPlaying === false');
|
||||
|
||||
// The pause branch re-anchors the clock state and returns the raw sample
|
||||
// (no forward extrapolation).
|
||||
const branch = fn.slice(guardIdx);
|
||||
assert.match(branch, /_clkAudioT\s*=\s*raw/, 'pause branch must re-anchor _clkAudioT to raw');
|
||||
assert.match(branch, /_clkPerf\s*=\s*p/, 'pause branch must re-anchor _clkPerf to now');
|
||||
assert.match(branch, /return\s*\(\s*_frameNow\s*=\s*raw\s*\)/, 'pause branch must return raw');
|
||||
|
||||
// The pause gate must come before the new-sample re-anchor / interpolation
|
||||
// path so a frozen clock never extrapolates forward.
|
||||
const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*_clkAudioT\s*\)/);
|
||||
assert.ok(newSampleIdx !== -1, 'smoothNow new-sample branch not found');
|
||||
assert.ok(guardIdx < newSampleIdx, 'isPlaying pause gate must precede the interpolation path');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pins the sustain bloom glow in plugins/highway_3d/screen.js (PR #329).
|
||||
// Sustained chord rails get a soft gaussian glow: a DataTexture gaussian
|
||||
// (_makeGaussTex) drives a wider, additive-blended plane mesh (pSusRailBloom)
|
||||
// rendered behind the core rail. A refactor that drops the gaussian texture,
|
||||
// stops using additive blending, or bumps the bloom renderOrder above the
|
||||
// core rail (16) would silently regress or invert the effect.
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_makeGaussTex\s*\(/,
|
||||
'_makeGaussTex must exist to build the bloom gaussian texture',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/_bloomGaussTex\s*=\s*_makeGaussTex\(/,
|
||||
'the bloom texture must be produced by _makeGaussTex',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bloom rail material uses additive blending', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/mSusRailBloomBase\s*=\s*new\s+T\.MeshBasicMaterial\(\{[\s\S]*?blending:\s*T\.AdditiveBlending[\s\S]*?\}\)/,
|
||||
'mSusRailBloomBase must blend additively so it brightens what is behind it',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bloom pool seeds meshes at renderOrder 4, behind the core rail (5)', () => {
|
||||
// renderOrder 4 keeps the bloom behind the core sustain rail (5) so the
|
||||
// glow reads as a trail rather than occluding the rail.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/pSusRailBloom\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*4\s*;[\s\S]*?\}\s*\)/,
|
||||
'pSusRailBloom pool must seed meshes with renderOrder = 4',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pins the chord sustain-length rail indicator in plugins/highway_3d/screen.js
|
||||
// (PR #303). The rails are left/right edge plane meshes showing how long a
|
||||
// chord is held. A refactor that drops the !isRepeat gate, mixes up the
|
||||
// arpeggio/teal color choice, or changes the rail renderOrder would silently
|
||||
// regress the indicator (rails on every repeat frame, wrong tint, or rails
|
||||
// occluding note gems).
|
||||
//
|
||||
// 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 SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
test('sustain rails are gated on multi-note chords with a known box width within AHEAD', () => {
|
||||
// Each chord in a sequence (including repeats) draws a rail from its onset
|
||||
// to the next chord's onset, chaining together to cover the full handshape
|
||||
// duration visually. Single notes have no chord frame to anchor a rail to.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*chShape\.size\s*>\s*1\s*&&\s*chordOpenBoxW\s*!=\s*null\s*&&\s*chDt\s*<\s*AHEAD\s*\)/,
|
||||
'sustain-rail block must stay gated on chShape.size > 1, chordOpenBoxW and chDt < AHEAD',
|
||||
);
|
||||
});
|
||||
|
||||
test('sustain rails pick arpeggio color for arpeggio frames, teal otherwise', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/chordHighwayLavenderArpVisual\s*\?\s*ARPEGGIO_RIM_BLUE_HEX\s*:\s*CHORD_BOX_TEAL_HEX/,
|
||||
'rail color must select ARPEGGIO_RIM_BLUE_HEX for arpeggio frames and CHORD_BOX_TEAL_HEX for chords',
|
||||
);
|
||||
});
|
||||
|
||||
test('sustain-rail pool meshes keep renderOrder 5 so strings (7) stay on top', () => {
|
||||
// renderOrder 5 sits below string-line glows (7) so strings render on top
|
||||
// of the rail. Chord frame edges are Z-proportional [48,698] and note gems
|
||||
// are Z-proportional [50,700], so the flat seed value does not conflict —
|
||||
// emitSusStrip() assigns its own Z-proportional RO per segment at draw time.
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/pSusRail\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*5\s*;[\s\S]*?\}\s*\)/,
|
||||
'pSusRail pool must seed meshes with renderOrder = 5',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Source-level guards for the load-adaptive render scale (slopsmith#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.
|
||||
|
||||
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('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
});
|
||||
|
||||
test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
// Derives from the (sanitized) user ceiling and auto factor.
|
||||
assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale');
|
||||
assert.match(fn, /_autoScale/, 'effective scale must derive from the auto factor _autoScale');
|
||||
assert.match(fn, /user\s*\*\s*auto/, 'effective scale must multiply the sanitized factors');
|
||||
assert.match(fn, /_autoScaleMin/, 'must floor at the configurable _autoScaleMin');
|
||||
assert.match(fn, /Math\.min\(\s*user/, 'must cap the effective scale at the user ceiling');
|
||||
});
|
||||
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
'configurable floor must load from localStorage.highwayMinRenderScale');
|
||||
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
|
||||
'effective scale must clamp the floor to the user ceiling');
|
||||
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
|
||||
const adapt = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
|
||||
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
|
||||
});
|
||||
|
||||
test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget');
|
||||
assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget');
|
||||
assert.match(fn, /_AUTO_ADJUST_COOLDOWN_MS/, 'must respect the adjust cooldown');
|
||||
assert.match(fn, /api\.resize\(\)/, 'a scale change must re-apply through api.resize()');
|
||||
});
|
||||
|
||||
test('draw() only adapts during active playback and feeds the HUD', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused');
|
||||
assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame');
|
||||
});
|
||||
|
||||
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
||||
});
|
||||
|
||||
test('api exposes effective scale + perf stats', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing');
|
||||
assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing');
|
||||
});
|
||||
|
||||
// Robustness fixes from the #655 Copilot review.
|
||||
test('render scale is sanitized on load and effective scale guards non-finite', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/,
|
||||
'render scale load must validate via Number.isFinite + clamp');
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Number\.isFinite/, 'effective scale must guard against non-finite inputs');
|
||||
});
|
||||
|
||||
test('stop() tears down the perf HUD and resets per-session accumulators', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/,
|
||||
'stop() must remove the perf HUD so it cannot strand in the DOM');
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/,
|
||||
'stop() must reset _autoScale so the next session starts at the manual scale');
|
||||
assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_lastPausedDrawAt\s*=\s*0/,
|
||||
'stop() must reset _lastPausedDrawAt so a quick stop→init has fresh paused-throttle timing');
|
||||
});
|
||||
|
||||
test('perf HUD throttles its localStorage flag read off the hot path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _updatePerfHud()');
|
||||
assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pins the native-audio barrier ordering in static/highway.js
|
||||
// (slopsmith-desktop#117). The highway must await window.slopsmithAudioBarrier
|
||||
// before touching the JUCE backing engine, otherwise a NAM tone graph build
|
||||
// that restarts the native audio device races the backing-track load.
|
||||
//
|
||||
// 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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
test('highway awaits slopsmithAudioBarrier before the JUCE backing path', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const barrierIdx = src.indexOf('window.slopsmithAudioBarrier');
|
||||
const isRunningIdx = src.indexOf('juceApi.isAudioRunning()');
|
||||
const loadIdx = src.indexOf('juceApi.loadBackingTrack');
|
||||
|
||||
assert.ok(barrierIdx !== -1, 'highway must reference window.slopsmithAudioBarrier');
|
||||
assert.ok(isRunningIdx !== -1, 'highway must still call juceApi.isAudioRunning()');
|
||||
assert.ok(loadIdx !== -1, 'highway must still call juceApi.loadBackingTrack');
|
||||
assert.ok(barrierIdx < isRunningIdx,
|
||||
'the barrier await must precede the isAudioRunning() check');
|
||||
assert.ok(barrierIdx < loadIdx,
|
||||
'the barrier await must precede loadBackingTrack');
|
||||
});
|
||||
|
||||
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 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.
|
||||
assert.match(region, /Promise\.race/,
|
||||
'the barrier await must be raced against a local timeout');
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Source-level guards for the 2D highway chord render cache
|
||||
// (`_ensureChordRenderCache`). Locks in the invalidation contract so a
|
||||
// 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
|
||||
// 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).
|
||||
//
|
||||
// Like the other highway tests in this directory, these inspect the
|
||||
// source rather than executing — the createHighway() closure owns
|
||||
// canvas + WebGL lifecycle that's too heavy for 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');
|
||||
|
||||
test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// The cache key triple must include chordTemplates — without it, a
|
||||
// late-arriving `chord_templates` WS message leaves cached
|
||||
// nonZeroNotes / nonZeroFrets stale until the next chord transition.
|
||||
//
|
||||
// Match either operand order (`A === B` or `B === A`) so a future
|
||||
// stylistic refactor that flips sides doesn't trip these guards —
|
||||
// the semantic invariant is the comparison, not its placement.
|
||||
const eqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*===\\s*\\b${b}\\b|\\b${b}\\b\\s*===\\s*\\b${a}\\b`
|
||||
);
|
||||
const neqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
|
||||
);
|
||||
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// The cache-invalidation block must clear both _chordFretLineNotes
|
||||
// (so _updateFretLinePreview re-publishes with corrected isOpen
|
||||
// classification) and _frameMismatchWarned (so a chord ID warned
|
||||
// against stale templates re-validates against the corrected ones).
|
||||
// Non-greedy `[\s\S]*?` instead of `[^}]*` so a future nested
|
||||
// block inside the `if (templatesChanged) { … }` branch (e.g. an
|
||||
// inner conditional reset) doesn't break the match by introducing
|
||||
// a `}` before the symbol we're checking for.
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
'templatesChanged branch must reset _chordFretLineNotes');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
'templatesChanged branch must null _lastChordOnFretLine');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
|
||||
'templatesChanged branch must clear _frameMismatchWarned');
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// The public plugin API: window.slopsmith.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.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.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.equal(depth, 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
// Build a live facade by injecting stubs into the extracted installer.
|
||||
function buildFacade() {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
const body = [
|
||||
'const _hwcChangeWrappers = new WeakMap();',
|
||||
extractBlock(src, 'function _hwcInstallFacade()'),
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
const calls = [];
|
||||
const bus = {
|
||||
on(e, f) { (listeners[e] = listeners[e] || []).push(f); },
|
||||
off(e, f) { listeners[e] = (listeners[e] || []).filter((x) => x !== f); },
|
||||
emit(e, d) { (listeners[e] || []).slice().forEach((f) => f({ detail: d })); },
|
||||
_count: (e) => (listeners[e] || []).length,
|
||||
};
|
||||
const win = { slopsmith: 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' },
|
||||
{ key: 'A', label: 'A', sub: '5th' }, { key: 'lowE', label: 'Low E', sub: '6th' },
|
||||
{ key: 'low7', label: 'Low B', sub: '7-string' }, { key: 'low8', label: 'Low F#', sub: '8-string' },
|
||||
];
|
||||
const stubs = {
|
||||
getHighwayStringColors: () => ({ lowE: '#111111' }),
|
||||
getHighwayDefaultSlotColors: () => ({ lowE: '#cc0000', highE: '#9900cc' }),
|
||||
_hwcMergedSlotColors: () => ({ lowE: '#111111', A: '#cca800' }),
|
||||
_hwcSlotKeysForChart: (sc, isBass) => ['keys', sc, isBass],
|
||||
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
||||
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
||||
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
||||
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
||||
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
||||
};
|
||||
const installer = new Function(...params, body)(
|
||||
win, HWC_SLOTS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.slopsmith.highwayColors, win, bus, calls, installer, stubs };
|
||||
}
|
||||
|
||||
test('initHighwayColors installs the facade', () => {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
const init = extractBlock(src, 'function initHighwayColors()');
|
||||
assert.match(init, /_hwcInstallFacade\(\)/, 'initHighwayColors must call _hwcInstallFacade');
|
||||
});
|
||||
|
||||
test('facade exposes the documented surface', () => {
|
||||
const { api } = buildFacade();
|
||||
assert.equal(api.version, 1);
|
||||
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
|
||||
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
|
||||
}
|
||||
assert.deepEqual(api.slots.map((s) => s.key),
|
||||
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
|
||||
});
|
||||
|
||||
test('facade read methods delegate to the manager', () => {
|
||||
const { api } = buildFacade();
|
||||
assert.deepEqual(api.get(), { lowE: '#111111' });
|
||||
assert.deepEqual(api.getDefaults(), { lowE: '#cc0000', highE: '#9900cc' });
|
||||
assert.deepEqual(api.getResolved(), { lowE: '#111111', A: '#cca800' });
|
||||
assert.deepEqual(api.keysForChart(7, true), ['keys', 7, true]);
|
||||
assert.deepEqual(api.toEffective(7, true), ['eff', 7, true]);
|
||||
assert.deepEqual(api.toEffective(), ['eff', 6, false], 'no args → current chart shape');
|
||||
assert.deepEqual(api.getCurrent(), ['#aaaaaa'], 'live 2D applied colors');
|
||||
});
|
||||
|
||||
test('apply / share interop delegate', () => {
|
||||
const { api, calls } = buildFacade();
|
||||
api.apply({ lowE: '#abcdef' });
|
||||
assert.deepEqual(calls[0], ['apply', { lowE: '#abcdef' }]);
|
||||
assert.match(api.encodeShare('n', {}), /^SLOPHWY2\./);
|
||||
assert.deepEqual(api.decodeShare('SLOPHWY2.CODE'), { name: 'x', colors: {} });
|
||||
});
|
||||
|
||||
test('onChange fires with resolved map and unsubscribes cleanly', () => {
|
||||
const { api, bus } = buildFacade();
|
||||
let got = null;
|
||||
const handler = (m) => { got = m; };
|
||||
const unsub = api.onChange(handler);
|
||||
assert.equal(bus._count('highway:stringColors'), 1, 'subscribed to the change event');
|
||||
bus.emit('highway:stringColors', { lowE: '#111111' });
|
||||
assert.deepEqual(got, { lowE: '#111111', A: '#cca800' }, 'handler gets the RESOLVED map');
|
||||
got = null;
|
||||
unsub();
|
||||
assert.equal(bus._count('highway:stringColors'), 0, 'unsubscribe removed the listener');
|
||||
bus.emit('highway:stringColors', {});
|
||||
assert.equal(got, null, 'no callback after unsubscribe');
|
||||
// offChange path
|
||||
const h2 = () => {};
|
||||
api.onChange(h2);
|
||||
assert.equal(bus._count('highway:stringColors'), 1);
|
||||
api.offChange(h2);
|
||||
assert.equal(bus._count('highway:stringColors'), 0);
|
||||
});
|
||||
|
||||
test('repeated onChange with the same handler unsubscribes independently (no leak)', () => {
|
||||
const { api, bus } = buildFacade();
|
||||
let n = 0;
|
||||
const handler = () => { n++; };
|
||||
const unsubA = api.onChange(handler);
|
||||
const unsubB = api.onChange(handler);
|
||||
assert.equal(bus._count('highway:stringColors'), 2, 'two independent subscriptions');
|
||||
// First unsubscribe removes only ITS wrapper, leaving the second live.
|
||||
unsubA();
|
||||
assert.equal(bus._count('highway:stringColors'), 1, 'first unsub removes one, not both');
|
||||
bus.emit('highway:stringColors', {});
|
||||
assert.equal(n, 1, 'surviving subscription still fires');
|
||||
unsubB();
|
||||
assert.equal(bus._count('highway:stringColors'), 0, 'second unsub removes the rest');
|
||||
// offChange removes ALL remaining subscriptions of a handler at once.
|
||||
n = 0;
|
||||
api.onChange(handler); api.onChange(handler);
|
||||
assert.equal(bus._count('highway:stringColors'), 2);
|
||||
api.offChange(handler);
|
||||
assert.equal(bus._count('highway:stringColors'), 0, 'offChange clears all of fn');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Source-level tests for highway.getFilteredNotes() / getFilteredChords() and
|
||||
// the hasPhraseData() companion getter. These mirror the pattern used in
|
||||
// highway_note_state.test.js: the createHighway closure is too heavy for a Node
|
||||
// sandbox, so tests inspect the source text to lock in correct wiring.
|
||||
|
||||
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');
|
||||
|
||||
test('highway public API exposes getFilteredNotes', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes\s*!==\s*null/,
|
||||
'getFilteredNotes must check _filteredNotes !== null',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFilteredNotes falls through to notes when _filteredNotes is null', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
|
||||
'getFilteredNotes must return notes as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
test('highway public API exposes getFilteredChords', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords\s*!==\s*null/,
|
||||
'getFilteredChords must check _filteredChords !== null',
|
||||
);
|
||||
});
|
||||
|
||||
test('getFilteredChords falls through to chords when _filteredChords is null', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
|
||||
'getFilteredChords must return chords as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
test('highway public API exposes hasPhraseData', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/hasPhraseData\s*\(\s*\)\s*\{[^}]*_phrases/,
|
||||
'hasPhraseData must reference _phrases',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
// Verify static/highway.js's getTime() interpolates smoothly via
|
||||
// performance.now() between setTime() calls (so plugins observe sub-
|
||||
// frame clock motion despite audio.currentTime's coarse step
|
||||
// quantization — browsers refresh the reported value at ~20+ ms
|
||||
// granularity even though the underlying audio thread runs faster).
|
||||
|
||||
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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
|
||||
// Brace-balanced extraction so source-level tests stay robust to body
|
||||
// growth. Returns the full method-or-block text including its braces.
|
||||
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);
|
||||
}
|
||||
|
||||
// Build a sandbox with the chart-clock state and the extracted setTime
|
||||
// + getTime methods so behavioral tests can exercise the real
|
||||
// implementation in isolation.
|
||||
function buildClockSandbox(perfNowImpl) {
|
||||
const sandbox = {
|
||||
chartTime: 0,
|
||||
currentTime: 0,
|
||||
avOffsetSec: 0,
|
||||
// Per-song chart offset (loose-folder format only). Tests pin
|
||||
// this at 0 so the clock-behaviour assertions can stay
|
||||
// expressed in raw audio time without offset bookkeeping.
|
||||
songOffset: 0,
|
||||
// Match production: NaN sentinels for "no prior anchor" so
|
||||
// the first setTime call always re-anchors (even setTime(0))
|
||||
// and so getTime() before any setTime returns chartTime.
|
||||
_chartAnchorAudioT: NaN,
|
||||
_chartAnchorPerfNow: NaN,
|
||||
_chartLastAdvanceAt: 0,
|
||||
_chartObservedRate: 1,
|
||||
_CHART_MAX_INTERP_MS: 100,
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const setTimeBody = extractBlock(src, 'setTime(t) {');
|
||||
const getTimeBody = extractBlock(src, 'getTime() {');
|
||||
// Strip trailing comma if present (object-literal method declarations).
|
||||
const cleanup = (s) => s.replace(/,?\s*$/, '');
|
||||
vm.runInContext(`
|
||||
globalThis.setTime = function ${cleanup(setTimeBody)};
|
||||
globalThis.getTime = function ${cleanup(getTimeBody)};
|
||||
`, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
// and never re-anchors, leaving the clock uninitialized.
|
||||
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
|
||||
assert.ok(m, 'getTime() body not found');
|
||||
const slice = m[0];
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartObservedRate\s*\*\s*elapsedMs/,
|
||||
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
|
||||
);
|
||||
});
|
||||
|
||||
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
// Repeated setTime calls with the same value must not refresh the
|
||||
// anchor (else interpolation stutters); they also must not refresh
|
||||
// _chartLastAdvanceAt (else getTime would never detect a stalled
|
||||
// audio clock as paused).
|
||||
// The implementation may capture performance.now() into a local
|
||||
// (e.g. newPerfNow) and assign that to both fields; accept either
|
||||
// direct or via-local writes.
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
|
||||
const block = m[0];
|
||||
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
// Find the actual getTime body. Match the whole brace-balanced
|
||||
// method (using a generous greedy slice to ensure we capture both
|
||||
// the stall check and the interpolation expression below it).
|
||||
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
|
||||
assert.ok(m, 'getTime() body not found');
|
||||
const slice = m[0];
|
||||
// Must check stall-since-last-advance against the cap.
|
||||
assert.match(
|
||||
slice,
|
||||
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
'getTime must short-circuit when audio has stalled past the cap',
|
||||
);
|
||||
// Must interpolate when active.
|
||||
assert.match(slice, /performance\.now\(\)|nowP/, 'getTime must use perfNow');
|
||||
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
'getTime must compute anchor + rate-scaled elapsed during play',
|
||||
);
|
||||
});
|
||||
|
||||
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
// Use the brace-balanced extractor so the assertions are scoped to
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
const stopBlock = extractBlock(src, 'stop() {');
|
||||
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
});
|
||||
|
||||
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
|
||||
|
||||
test('behavior: getTime interpolates smoothly between two anchors at 1x', () => {
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
// First anchor at audioT=10, perf=0.
|
||||
sb.setTime(10);
|
||||
// Browser hasn't refreshed audio.currentTime; setTime called again
|
||||
// with the same value at perf=16. Anchor must NOT move.
|
||||
now = 16;
|
||||
sb.setTime(10);
|
||||
// Plugin reads at perf=24 — interpolated 24ms from anchor.
|
||||
now = 24;
|
||||
const t = sb.getTime();
|
||||
assert.ok(Math.abs(t - (10 + 0.024)) < 0.001, `expected ~10.024, got ${t}`);
|
||||
});
|
||||
|
||||
test('behavior: getTime returns chartTime when audio has stalled (paused)', () => {
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(10);
|
||||
now = 16; sb.setTime(10);
|
||||
// 200ms after the last advance — well past the 100ms cap. Even
|
||||
// though setTime is still being called every 16ms with the same
|
||||
// value (the 60Hz tick), getTime must report raw chartTime.
|
||||
now = 200;
|
||||
const t = sb.getTime();
|
||||
assert.equal(t, 10, `paused getTime must be chartTime (10), got ${t}`);
|
||||
});
|
||||
|
||||
test('behavior: getTime adjusts for non-1x playback rate (observed)', () => {
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
// Establish initial anchor.
|
||||
sb.setTime(10);
|
||||
// Audio advanced 0.025s in 50ms real time → observed rate = 0.5.
|
||||
now = 50;
|
||||
sb.setTime(10.025);
|
||||
// Read 25ms after the latest anchor: chart should advance by
|
||||
// rate * elapsed = 0.5 * 0.025 = 0.0125 → 10.0375.
|
||||
now = 75;
|
||||
const t = sb.getTime();
|
||||
assert.ok(Math.abs(t - 10.0375) < 0.0005, `expected ~10.0375 (rate-scaled), got ${t}`);
|
||||
});
|
||||
|
||||
test('behavior: seek discontinuity resets observed rate to 1x', () => {
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
|
||||
// Seek: large t jump in same perf delta — observed-rate clamp
|
||||
// rejects this segment, resets to 1.
|
||||
now = 70;
|
||||
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
|
||||
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
});
|
||||
|
||||
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.05); // observed rate ~1
|
||||
// Long pause-like gap with NO setTime call. getTime should detect
|
||||
// (now - _chartLastAdvanceAt > 100) and return chartTime.
|
||||
now = 200;
|
||||
const t = sb.getTime();
|
||||
assert.equal(t, 10.05, 'beyond cap must fall back to chartTime');
|
||||
});
|
||||
|
||||
test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', () => {
|
||||
// Regression: _chartAnchorAudioT used to start at 0, so setTime(0)
|
||||
// on the very first 60 Hz tick failed the `t !== _chartAnchorAudioT`
|
||||
// check and skipped the re-anchor branch entirely. _chartAnchorPerfNow
|
||||
// would stay NaN, and getTime would propagate NaN to plugins.
|
||||
let now = 16; // realistic first-tick perf
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(0);
|
||||
// Anchor must now be initialized.
|
||||
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
// getTime should return a finite value, not NaN.
|
||||
const t = sb.getTime();
|
||||
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
|
||||
});
|
||||
|
||||
test('behavior: getTime before any setTime returns chartTime (no NaN)', () => {
|
||||
// Regression: getTime called during early boot (before the first
|
||||
// 60 Hz tick) used to compute `nowP - NaN` and propagate NaN. Must
|
||||
// bail to chartTime when the anchor is still the NaN sentinel.
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
const t = sb.getTime();
|
||||
assert.equal(t, 0, 'getTime before any setTime must return chartTime, not NaN');
|
||||
assert.ok(!Number.isNaN(t), 'getTime must not return NaN');
|
||||
});
|
||||
|
||||
test('behavior: long anchor gap resets observed rate to 1x', () => {
|
||||
// After a long gap (e.g. tab inactive, paused for a while), the
|
||||
// next setTime() shouldn't carry forward the prior segment's
|
||||
// observed rate — it likely reflects a different playback state.
|
||||
let now = 0;
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
|
||||
// window, so the rate must reset to 1.
|
||||
now = 1100;
|
||||
sb.setTime(10.5);
|
||||
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Source-level guards for the per-note judgment hook (slopsmith#254):
|
||||
// highway.setNoteStateProvider / getNoteStateProvider / getNoteState,
|
||||
// bundle.getNoteState, isDefaultRenderer, and the _noteState
|
||||
// normalization rules. The createHighway closure owns canvas + WebGL
|
||||
// lifecycle that's too heavy for a vm sandbox, so — like the other
|
||||
// highway tests in this dir — these lock in the wiring by inspecting
|
||||
// the source rather than executing it.
|
||||
|
||||
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');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_visibility.test.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('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// The bundle field must point straight at _noteState — not a fresh
|
||||
// arrow each frame (the per-frame allocation the review flagged).
|
||||
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)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Same allocation discipline as getNoteState: highway_3d uses this
|
||||
// bundle field to tell "provider attached" from "no provider but
|
||||
// getNoteState still exists and returns null", so a per-frame arrow
|
||||
// here would both burn allocations on the hot path and could trip
|
||||
// identity-based guards in renderer code.
|
||||
assert.match(
|
||||
fn,
|
||||
/getNoteStateProvider:\s*_getNoteStateProvider\b/,
|
||||
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
|
||||
);
|
||||
// Sanity: the stable accessor exists per-createHighway-instance
|
||||
// (alongside _noteStateProvider in the closure) and returns the
|
||||
// slot, so renderers see a live "is a provider registered?" view.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
|
||||
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
|
||||
);
|
||||
});
|
||||
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
|
||||
assert.match(fn, /if\s*\(\s*alpha\s*<=\s*0\s*\)\s*return\s+null/, 'must return null when alpha resolves to <= 0');
|
||||
assert.match(fn, /const\s+live\s*=\s*\(raw\s*&&\s*typeof\s+raw\s*===\s*['"]object['"]\s*&&\s*raw\.live\s*===\s*true\)/, 'must pass through the provider live flag');
|
||||
assert.match(fn, /return\s*\{\s*state\s*,\s*alpha\s*,\s*color\s*,\s*live\s*\}/, 'must return the normalized { state, alpha, color, live }');
|
||||
});
|
||||
|
||||
test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// drawNote takes the trailing `ns` param.
|
||||
assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param');
|
||||
// drawNotes / drawSustains / drawChords gate the lookup on the provider.
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*n\s*,\s*n\.t\s*\)\s*:\s*null/, 'visible-note paths must skip the lookup when no provider is set');
|
||||
assert.match(src, /_noteStateProvider\s*\?\s*_noteState\(\s*cn\s*,\s*ch\.t\s*\)\s*:\s*null/, 'chord-note path must key the lookup by the chord time and gate on the provider');
|
||||
});
|
||||
|
||||
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
assert.match(src, /_ndGetNoteState\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteState\s*===\s*['"]function['"]\)\s*\?\s*bundle\.getNoteState\s*:\s*null/, 'update() must capture bundle.getNoteState into _ndGetNoteState');
|
||||
// Provider verdict wins: miss => not _showHit; otherwise provider state
|
||||
// or the legacy fallback (`hit`) plus the pre-hit ghost window preview.
|
||||
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)', () => {
|
||||
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
|
||||
// provider being registered, not on the always-present bundle.
|
||||
// getNoteState. Capture via bundle.getNoteStateProvider() with a
|
||||
// fallback to the getNoteState-existence check so downlevel hosts
|
||||
// (no getNoteStateProvider) still behave as before.
|
||||
assert.match(
|
||||
src,
|
||||
/_ndHasProvider\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteStateProvider\s*===\s*['"]function['"]\)[\s\S]{0,80}bundle\.getNoteStateProvider\s*\(\s*\)\s*!=\s*null[\s\S]{0,80}:\s*!!_ndGetNoteState/,
|
||||
'update() must derive _ndHasProvider from bundle.getNoteStateProvider() with a fallback to _ndGetNoteState'
|
||||
);
|
||||
// Verdict-window cull and chord-frame hold floor + smart drawNote
|
||||
// cull must all gate on _ndHasProvider (not _ndGetNoteState alone).
|
||||
assert.match(src, /ndVerdictT0\s*=\s*_ndHasProvider/, 'ndVerdictT0 outer-loop extension must gate on _ndHasProvider');
|
||||
assert.match(src, /if\s*\(\s*_ndHasProvider\s*&&\s*chordTailHoldS\s*<\s*NOTEDETECT_GEM_VERDICT_WINDOW/,
|
||||
'chord-frame hold floor must gate on _ndHasProvider');
|
||||
assert.match(src, /if\s*\(\s*!_ndHasProvider\s*\|\|\s*dt\s*<\s*-NOTEDETECT_GEM_VERDICT_WINDOW\s*\)\s*return/,
|
||||
'drawNote smart-cull provider probe must gate on _ndHasProvider');
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Source-level guards for the playback-aware paused-render throttle
|
||||
// (slopsmith#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.
|
||||
|
||||
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');
|
||||
|
||||
// Brace-balanced extraction (shared shape with highway_visibility.test.js)
|
||||
// so a future edit that grows the loop body doesn't get truncated.
|
||||
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('highway declares the paused-render throttle state', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
||||
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
});
|
||||
|
||||
test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Reuse getTime()'s pause signal rather than inventing a parallel one.
|
||||
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
||||
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
||||
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
||||
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
});
|
||||
|
||||
test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
||||
// semicolon changes don't break the ordering guard — matches the
|
||||
// search-based style of the other highway source-guard tests.
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
|
||||
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
||||
assert.ok(readyIdx !== -1, 'ready gate not found');
|
||||
assert.ok(throttleIdx !== -1, 'throttle not found');
|
||||
assert.ok(drawIdx !== -1, '_renderer.draw call not found');
|
||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Source-level tests for highway.getPhrases() and getMastery() public API getters.
|
||||
// The createHighway closure is too heavy for a Node sandbox, so tests inspect
|
||||
// source text to lock in correct wiring — same pattern as highway_filtered_notes.test.js.
|
||||
|
||||
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');
|
||||
|
||||
test('highway public API exposes getPhrases', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*_phrases/,
|
||||
'getPhrases must reference _phrases',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPhrases returns null when _phrases is falsy or empty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
|
||||
'getPhrases must return null when no phrase data is available',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPhrases maps phrases to index, start_time, end_time, max_difficulty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Match all four fields within a reasonable window after getPhrases
|
||||
const match = src.match(/getPhrases\s*\(\s*\)([\s\S]{0,400})/);
|
||||
assert.ok(match, 'getPhrases not found in highway.js');
|
||||
const block = match[1];
|
||||
assert.ok(block.includes('start_time'), 'getPhrases must expose start_time');
|
||||
assert.ok(block.includes('end_time'), 'getPhrases must expose end_time');
|
||||
assert.ok(block.includes('max_difficulty'), 'getPhrases must expose max_difficulty');
|
||||
assert.ok(block.includes('index'), 'getPhrases must expose index');
|
||||
});
|
||||
|
||||
test('highway public API exposes getMastery', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getMastery\s*\(\s*\)\s*\{[^}]*_mastery/,
|
||||
'getMastery must reference _mastery',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// User-created, shareable highway string colors. Two layers:
|
||||
// 1. Source-level wiring guards (the createHighway / highway_3d closures own
|
||||
// canvas + WebGL lifecycle too heavy for a vm sandbox — same approach as
|
||||
// the other highway_* tests here), covering the 2D setStringColors API,
|
||||
// the 3D `custom` palette path, and the app.js color manager.
|
||||
// 2. Executable behavior tests for the *pure* pieces — the dim/bright
|
||||
// derivation math and the share-code codec — extracted and run for real.
|
||||
|
||||
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');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.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);
|
||||
}
|
||||
|
||||
// ── 2D highway (static/highway.js) ────────────────────────────────────────
|
||||
|
||||
test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
|
||||
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
});
|
||||
|
||||
test('2D public API exposes getStringColors / setStringColors', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
const fn = extractBlock(src, 'setStringColors(arr)');
|
||||
// Each provided index sets base + derived dim/bright; missing → default.
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
});
|
||||
|
||||
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
|
||||
|
||||
test('3D adds a custom palette path + h3dBgSetStringColors setter', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
assert.match(src, /window\.h3dBgSetStringColors\s*=/, 'window.h3dBgSetStringColors must be defined');
|
||||
assert.match(src, /_bgWriteGlobal\('customColors'/, 'setter must persist customColors');
|
||||
assert.match(src, /_bgWriteGlobal\('palette',\s*'custom'\)/, "setter must flip palette to 'custom'");
|
||||
// 'custom' must survive palette coercion (else it gets reset to default).
|
||||
assert.match(src, /key === 'palette'\)\s*return\s*\(PALETTE_IDS\.includes\(val\)\s*\|\|\s*val === 'custom'\)/, "palette coercion must accept 'custom'");
|
||||
// _bgLoadSettings resolves 'custom' into the in-place _customPalette and
|
||||
// forces a retint on content change via the signature guard.
|
||||
assert.match(src, /newPaletteId === 'custom'/, '_bgLoadSettings must branch on custom');
|
||||
assert.match(src, /_bgPaletteSig/, 'a palette content signature must guard in-place custom edits');
|
||||
});
|
||||
|
||||
test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
// The gem bodies (strings 0..5) are a baked per-vertex gradient; a custom
|
||||
// palette must recolor them, else gems/sustain/vibrato heads stay stock.
|
||||
assert.match(src, /function _recolorGemGradients\(\)/, '_recolorGemGradients must exist');
|
||||
const fn = extractBlock(src, 'function _recolorGemGradients()');
|
||||
assert.match(fn, /isCustom\s*&&\s*base !== PALETTES\.default\[s\]/, 'custom slots must derive stops from the base color');
|
||||
assert.match(fn, /_lightenInt\(base/, 'derived top highlight via _lightenInt');
|
||||
assert.match(fn, /_darkenInt\(base/, 'derived bottom shade via _darkenInt');
|
||||
assert.match(fn, /colAttr\.needsUpdate = true/, 'must flag the color attribute dirty');
|
||||
// Wired into both the build path and the live palette-change path.
|
||||
const apply = extractBlock(src, 'function _applyPaletteToMaterials()');
|
||||
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
|
||||
});
|
||||
|
||||
// ── Core color manager (static/app.js) ────────────────────────────────────
|
||||
|
||||
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function reapplyHighwayStringColors()');
|
||||
assert.match(fn, /_hwcMappingIsIdentity\(sc, isBass\)/, 'must short-circuit the identity (≤6-string / bass default) case');
|
||||
assert.match(fn, /h3d_bg_palette'\) !== 'default'\) window\.h3dBgSetPalette\?\.\('default'\)/, 'identity+default must put the 3D back on its default palette');
|
||||
assert.match(fn, /_hwcEffectiveIndexColors\(_hwcMergedSlotColors\(\), sc, isBass\)/, 'must translate merged (default+custom) slots → per-index colors');
|
||||
assert.match(fn, /window\.highway\?\.setStringColors\?\.\(eff\)/, 'must drive the 2D highway with translated colors');
|
||||
assert.match(fn, /window\.h3dBgSetStringColors\?\.\(eff\)/, 'must always drive the 3D highway (palette picker removed)');
|
||||
// identity = guitar ≤6 strings and 4-string bass; 7/8-string guitar and
|
||||
// 5/6-string bass remap defaults (they prepend lower strings).
|
||||
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');
|
||||
});
|
||||
|
||||
// ── Executable: dim/bright derivation math ────────────────────────────────
|
||||
|
||||
function loadColorMath() {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const snippet = [
|
||||
extractBlock(src, 'function _clampByte(n)'),
|
||||
extractBlock(src, 'function _parseHex(hex)'),
|
||||
extractBlock(src, 'function _toHex(r, g, b)'),
|
||||
extractBlock(src, 'function _darken(hex, factor)'),
|
||||
extractBlock(src, 'function _lighten(hex, t)'),
|
||||
'return { _darken, _lighten, _parseHex };',
|
||||
].join('\n');
|
||||
return new Function('Math', snippet)(Math);
|
||||
}
|
||||
|
||||
test('_darken(0.40) reproduces the default DIM band for known colors', () => {
|
||||
const { _darken } = loadColorMath();
|
||||
// 204*0.40 = 81.6 → 82 = 0x52; matches DEFAULT_STRING_DIM[0] (#520000).
|
||||
assert.equal(_darken('#cc0000', 0.40), '#520000');
|
||||
// Green low E: 204→0x52, 102→0x29; matches DEFAULT_STRING_DIM[4] (#005229).
|
||||
assert.equal(_darken('#00cc66', 0.40), '#005229');
|
||||
});
|
||||
|
||||
test('_lighten(0.30) produces a valid, brighter hex', () => {
|
||||
const { _lighten, _parseHex } = loadColorMath();
|
||||
const out = _lighten('#0066cc', 0.30);
|
||||
assert.match(out, /^#[0-9a-f]{6}$/, 'lighten must yield a #rrggbb string');
|
||||
const a = _parseHex('#0066cc'), b = _parseHex(out);
|
||||
assert.ok(b.r >= a.r && b.g >= a.g && b.b >= a.b, 'each channel must be >= the base');
|
||||
assert.ok(b.r + b.g + b.b > a.r + a.g + a.b, 'result must be overall brighter');
|
||||
});
|
||||
|
||||
test('color helpers reject malformed input gracefully', () => {
|
||||
const { _darken } = loadColorMath();
|
||||
assert.equal(_darken('not-a-color', 0.4), 'not-a-color', 'invalid hex passes through unchanged');
|
||||
});
|
||||
|
||||
// ── Executable: slot↔index translation + share-code codec ─────────────────
|
||||
|
||||
function loadManager() {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
const constLines = [
|
||||
"const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;",
|
||||
"const HWC_SLOT_KEYS = ['highE','B','G','D','A','lowE','low7','low8'];",
|
||||
].join('\n');
|
||||
const snippet = [
|
||||
constLines,
|
||||
extractBlock(src, 'function _hwcSlotKeysForChart(sc, isBass)'),
|
||||
extractBlock(src, 'function _hwcMappingIsIdentity(sc, isBass)'),
|
||||
extractBlock(src, 'function _hwcNormalize(slotMap)'),
|
||||
extractBlock(src, 'function encodeHighwayColorShare(name, slotMap)'),
|
||||
extractBlock(src, 'function decodeHighwayColorShare(code)'),
|
||||
'return { _hwcSlotKeysForChart, _hwcMappingIsIdentity, _hwcNormalize, encodeHighwayColorShare, decodeHighwayColorShare };',
|
||||
].join('\n');
|
||||
const ctx = { btoa, atob, escape, unescape, encodeURIComponent, decodeURIComponent, JSON, Math };
|
||||
return new Function(...Object.keys(ctx), snippet)(...Object.values(ctx));
|
||||
}
|
||||
|
||||
test('translation table keeps Low E stable across string counts', () => {
|
||||
const { _hwcSlotKeysForChart } = loadManager();
|
||||
// 6-string guitar: index 0 = Low E.
|
||||
assert.equal(_hwcSlotKeysForChart(6, false)[0], 'lowE');
|
||||
// 7-string guitar: index 0 = Low B, index 1 = Low E (Low E keeps its slot).
|
||||
assert.deepEqual(_hwcSlotKeysForChart(7, false).slice(0, 2), ['low7', 'lowE']);
|
||||
// 8-string: index 0 = Low F#, index 2 = Low E.
|
||||
assert.equal(_hwcSlotKeysForChart(8, false)[2], 'lowE');
|
||||
// 4-string bass: index 0 = Low E (shares the low strings with guitar).
|
||||
assert.deepEqual(_hwcSlotKeysForChart(4, true), ['lowE', 'A', 'D', 'G']);
|
||||
// 5-string bass: index 0 = Low B, then Low E A D G.
|
||||
assert.deepEqual(_hwcSlotKeysForChart(5, true), ['low7', 'lowE', 'A', 'D', 'G']);
|
||||
// High E is the top guitar slot for 6/7/8-string.
|
||||
for (const sc of [6, 7, 8]) {
|
||||
const keys = _hwcSlotKeysForChart(sc, false);
|
||||
assert.equal(keys[keys.length - 1], 'highE', `${sc}-string top = High E`);
|
||||
}
|
||||
});
|
||||
|
||||
test('identity mapping = 4-string bass and ≤6-string guitar only', () => {
|
||||
const { _hwcMappingIsIdentity, _hwcSlotKeysForChart } = loadManager();
|
||||
// Identity cases: name order == index order (so defaults stay stock).
|
||||
for (const [sc, bass] of [[6, false], [4, false], [4, true], [1, false]]) {
|
||||
assert.ok(_hwcMappingIsIdentity(sc, bass), `${sc}/${bass ? 'bass' : 'gtr'} should be identity`);
|
||||
assert.equal(_hwcSlotKeysForChart(sc, bass)[0], 'lowE', 'identity charts start at Low E');
|
||||
}
|
||||
// Non-identity: extended-range guitar AND bass prepend lower strings.
|
||||
for (const [sc, bass] of [[7, false], [8, false], [5, true], [6, true]]) {
|
||||
assert.ok(!_hwcMappingIsIdentity(sc, bass), `${sc}/${bass ? 'bass' : 'gtr'} should remap`);
|
||||
assert.notEqual(_hwcSlotKeysForChart(sc, bass)[0], 'lowE', 'extended charts start below Low E');
|
||||
}
|
||||
});
|
||||
|
||||
test('_hwcNormalize keeps only valid named slots', () => {
|
||||
const { _hwcNormalize } = loadManager();
|
||||
const out = _hwcNormalize({ lowE: '#ABCDEF', A: 'garbage', B: '#123', highE: '#00ff00', bogus: '#111111' });
|
||||
assert.equal(out.lowE, '#abcdef', 'valid hex lowercased');
|
||||
assert.equal(out.highE, '#00ff00');
|
||||
assert.ok(!('A' in out), 'non-hex dropped');
|
||||
assert.ok(!('B' in out), '3-digit hex rejected by strict regex');
|
||||
assert.ok(!('bogus' in out), 'unknown slot dropped');
|
||||
});
|
||||
|
||||
test('share code round-trips name + named slot colors', () => {
|
||||
const { encodeHighwayColorShare, decodeHighwayColorShare, _hwcNormalize } = loadManager();
|
||||
const colors = { lowE: '#ff0000', A: '#00ff00', highE: '#0000ff' };
|
||||
const code = encodeHighwayColorShare('Neon Test', colors);
|
||||
assert.match(code, /^SLOPHWY2\./, 'code must carry the versioned prefix');
|
||||
assert.ok(!/[+/=]/.test(code), 'code must be base64url (no +, /, =)');
|
||||
const out = decodeHighwayColorShare(code);
|
||||
assert.equal(out.name, 'Neon Test');
|
||||
assert.deepEqual(out.colors, _hwcNormalize(colors));
|
||||
});
|
||||
|
||||
test('decodeHighwayColorShare returns null on garbage / wrong shape', () => {
|
||||
const { decodeHighwayColorShare, encodeHighwayColorShare } = loadManager();
|
||||
assert.equal(decodeHighwayColorShare('not a real code'), null);
|
||||
assert.equal(decodeHighwayColorShare(''), null);
|
||||
assert.equal(decodeHighwayColorShare(null), null);
|
||||
// A legacy array-shaped payload (old index-based format) is rejected.
|
||||
const arrayPayload = 'SLOPHWY2.' + btoa(JSON.stringify({ n: 'x', c: ['#ff0000'] })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
assert.equal(decodeHighwayColorShare(arrayPayload), null, 'array c is rejected (named-map only)');
|
||||
// A non-v2 prefix is rejected even with an otherwise-valid v2-shaped payload.
|
||||
const v1ish = 'SLOPHWY1.' + btoa(JSON.stringify({ n: 'x', c: { lowE: '#ff0000' } })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
assert.equal(decodeHighwayColorShare(v1ish), null, 'non-v2 prefix is rejected');
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// Source-level guards for the visibility-aware rAF skip and the
|
||||
// highway:visibility event (slopsmith#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.
|
||||
|
||||
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');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// Brace-balanced extraction so a future method that grows guards or
|
||||
// nested blocks doesn't get truncated by a naive `[^}]*\}` regex.
|
||||
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('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
});
|
||||
|
||||
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _isHighwayVisible()');
|
||||
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
|
||||
});
|
||||
|
||||
test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
|
||||
// Must short-circuit when the current state equals the cached one.
|
||||
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
// Must update the cache and emit the event with the documented payload shape.
|
||||
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/,
|
||||
'must emit highway:visibility with { visible, canvas }',
|
||||
);
|
||||
});
|
||||
|
||||
test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /_emitVisibilityIfChanged\(\)/, 'rAF draw() must call _emitVisibilityIfChanged each tick');
|
||||
// Ordering: emit → skip-when-hidden → ready gate → renderer.draw.
|
||||
// The emit must run BEFORE the !ready bail so visibility
|
||||
// transitions during loading/reconnect windows still propagate.
|
||||
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
|
||||
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\(/);
|
||||
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
|
||||
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
|
||||
assert.ok(skipIdx < readyIdx, 'skip-when-hidden must short-circuit before the ready gate');
|
||||
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)', () => {
|
||||
// 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
|
||||
// DOM) from genuine off-screen (offsetParent === null). An active custom
|
||||
// renderer keeps getting draw() ONLY while still in layout; the default
|
||||
// 2D renderer and the genuine off-screen case still bail.
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Single render decision drives both the perf-HUD reset and the gate.
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
|
||||
// the exemption must AND together override-hide, an active custom renderer,
|
||||
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
|
||||
// offsetParent clause) must fail this — that's the regression being fixed.
|
||||
assert.match(
|
||||
fn,
|
||||
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
|
||||
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
|
||||
);
|
||||
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
|
||||
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
|
||||
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
|
||||
});
|
||||
|
||||
test('api.isVisible() exposes a snapshot for late subscribers', () => {
|
||||
// The event is transition-only, so renderers that bind after the
|
||||
// initial frame need a way to sync. isVisible() returns the same
|
||||
// value _isHighwayVisible() would return on the next tick.
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'isVisible()');
|
||||
assert.match(fn, /return\s+_isHighwayVisible\(\)/, 'isVisible() must return _isHighwayVisible()');
|
||||
});
|
||||
|
||||
test('canvas-replace resets _lastVisible so the new canvas re-emits', () => {
|
||||
// _lastVisible is per-canvas-lifecycle: a fresh canvas could
|
||||
// be in a different displayed state than the one it replaced.
|
||||
// Without the reset, _emitVisibilityIfChanged would suppress
|
||||
// the first transition on the new canvas.
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _replaceCanvas(newType)');
|
||||
assert.match(fn, /_lastVisible\s*=\s*null/, '_replaceCanvas must reset _lastVisible so the new canvas re-emits');
|
||||
});
|
||||
|
||||
test('api.setVisible accepts bool / null and re-emits inline', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'setVisible(v)');
|
||||
// null/undefined → clears the override
|
||||
assert.match(fn, /v\s*===\s*null\s*\|\|\s*v\s*===\s*undefined/, 'null/undefined must clear the override');
|
||||
// Non-null → coerce to boolean
|
||||
assert.match(fn, /_visibleOverride\s*=.*\?\s*null\s*:\s*!!v/, 'non-null must coerce to !!v');
|
||||
// Re-evaluate immediately so the transition fires on the call, not the next rAF.
|
||||
assert.match(fn, /_emitVisibilityIfChanged\(\)/, 'setVisible must call _emitVisibilityIfChanged inline');
|
||||
});
|
||||
|
||||
test('3D Highway subscribes to highway:visibility and toggles wrap on hide', () => {
|
||||
const src = fs.readFileSync(highway3dJs, 'utf8');
|
||||
// Scope to lifecycle blocks so unrelated / commented mentions
|
||||
// elsewhere in screen.js can't cause false positives.
|
||||
const initSceneBlock = extractBlock(src, 'function initScene()');
|
||||
const teardownBlock = extractBlock(src, 'function teardown()');
|
||||
|
||||
// Listener registration with the documented event name (in init).
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/window\.slopsmith\.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.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/e\.detail\.canvas\s*!==\s*highwayCanvas/,
|
||||
'handler must filter on event.detail.canvas !== highwayCanvas (splitscreen-safe)',
|
||||
);
|
||||
// Handler toggles wrap.style.display based on visible === false.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/wrap\.style\.display\s*=\s*v\s*===\s*false\s*\?\s*['"]none['"]\s*:\s*['"]['"]/,
|
||||
'handler must hide the wrap when visible === false',
|
||||
);
|
||||
// Initial-sync on bind so renderers that mount while the canvas
|
||||
// is already hidden (e.g. plugin loaded mid-splitscreen) don't
|
||||
// leave the wrap stuck in the wrong state.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/highwayCanvas\.offsetParent\s*!==\s*null/,
|
||||
'initScene must compute initial visibility from local highwayCanvas (splitscreen-safe)',
|
||||
);
|
||||
// Subscribes to highway:canvas-replaced so the identity gate
|
||||
// (event.detail.canvas === highwayCanvas) survives core's
|
||||
// context-type-driven canvas swap. Per CLAUDE.md plugin contract.
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/window\.slopsmith\.on\(\s*['"]highway:canvas-replaced['"]/,
|
||||
'initScene must track canvas swaps so the visibility gate keeps matching',
|
||||
);
|
||||
assert.match(
|
||||
initSceneBlock,
|
||||
/highwayCanvas\s*=\s*e\.detail\.newCanvas/,
|
||||
'canvas-replaced handler must update the local highwayCanvas reference',
|
||||
);
|
||||
// Teardown unbinds both listeners.
|
||||
assert.match(
|
||||
teardownBlock,
|
||||
/window\.slopsmith\.off\(\s*['"]highway:visibility['"]/,
|
||||
'teardown must unbind highway:visibility',
|
||||
);
|
||||
assert.match(
|
||||
teardownBlock,
|
||||
/window\.slopsmith\.off\(\s*['"]highway:canvas-replaced['"]/,
|
||||
'teardown must unbind highway:canvas-replaced',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
|
||||
//
|
||||
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
|
||||
// song between the HTML5 <audio> element and the native JUCE backing transport
|
||||
// whenever the audio engine is started/stopped after song-load. These tests
|
||||
// extract that IIFE from source and exercise `window._reevaluateJuceRouting`
|
||||
// against fakes, covering: the happy-path HTML5->JUCE and JUCE->HTML5 switches,
|
||||
// the JUCE hard-reject memoisation, transient-failure retry, and the
|
||||
// stale-song-snapshot abort.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
|
||||
// and ending after the matching `})();`.
|
||||
function extractWatcherIIFE(src) {
|
||||
const marker = '(function _installJuceEngineRoutingWatcher() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
|
||||
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 in watcher IIFE');
|
||||
// Include the trailing `)();` invocation.
|
||||
const tail = src.slice(i, i + 5);
|
||||
assert.match(tail, /^\)\(\)/, 'watcher IIFE not immediately invoked');
|
||||
return src.slice(start, i) + ')();';
|
||||
}
|
||||
|
||||
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
|
||||
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
|
||||
|
||||
const audio = {
|
||||
currentTime: 12.5,
|
||||
src: 'blob:original',
|
||||
dataset: {},
|
||||
readyState: 2,
|
||||
pause() {},
|
||||
play() { calls.audioPlay++; return Promise.resolve(); },
|
||||
load() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
const jucePlayer = {
|
||||
_dur: 0, _pos: 0, _pollAt: 0,
|
||||
currentTime: 30,
|
||||
play() { calls.jucePlay++; return Promise.resolve(true); },
|
||||
pause() { calls.jucePause++; return Promise.resolve(); },
|
||||
};
|
||||
|
||||
const juceApi = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
|
||||
getBackingDuration: () => Promise.resolve(180),
|
||||
seekBacking: () => Promise.resolve(),
|
||||
startBacking: () => Promise.resolve(),
|
||||
stopBacking: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
performance: { now: () => 1000 },
|
||||
setInterval: () => 0, // disable the live poll; tests call directly
|
||||
setTimeout: (fn) => { fn(); return 0; },
|
||||
clearInterval: () => {},
|
||||
clearTimeout: () => {},
|
||||
fetch: () => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ path: '/local/song.ogg' }),
|
||||
}),
|
||||
document: { hidden: false },
|
||||
isPlaying: true,
|
||||
audio,
|
||||
jucePlayer,
|
||||
__calls: calls,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.jucePlayer = jucePlayer;
|
||||
sandbox.window.slopsmithDesktop = { audio: juceApi };
|
||||
sandbox.window.slopsmith = { audio: {} };
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const iife = extractWatcherIIFE(src);
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(iife, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('watcher IIFE exposes _reevaluateJuceRouting', () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
|
||||
assert.equal(typeof sb.window._reevaluateJuceRouting, 'function');
|
||||
});
|
||||
|
||||
test('engine running while on HTML5 → migrates the song to JUCE', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'should have switched into JUCE mode');
|
||||
assert.equal(sb.window._juceAudioUrl, '/audio/song.ogg');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'loadBackingTrack called once');
|
||||
assert.equal(sb.__calls.jucePlay, 1, 'jucePlayer.play called (song was playing)');
|
||||
});
|
||||
|
||||
test('engine stopped while on JUCE → migrates the song back to HTML5', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = '/audio/song.ogg';
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, 'should have switched out of JUCE mode');
|
||||
assert.equal(sb.window._juceAudioUrl, null);
|
||||
assert.equal(sb.audio.src, '/audio/song.ogg', 'HTML5 element re-pointed at the song');
|
||||
});
|
||||
|
||||
test('routing already consistent → no-op', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
|
||||
sb.window._juceMode = false; // engine off, already HTML5
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0, 'no switch attempted');
|
||||
});
|
||||
|
||||
test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/stem.ogg', juceEligible: false };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, 'stems stay on HTML5');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
});
|
||||
|
||||
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'stayed on HTML5 after reject');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1);
|
||||
|
||||
// Second poll with the same song must NOT call loadBackingTrack again.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'rejected URL not retried');
|
||||
});
|
||||
|
||||
test('transient failure is NOT memoised → retried on the next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
// First attempt: fetch rejects (transient). Then make fetch succeed.
|
||||
let firstCall = true;
|
||||
sb.fetch = () => {
|
||||
if (firstCall) { firstCall = false; return Promise.reject(new Error('network blip')); }
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ path: '/local/song.ogg' }) });
|
||||
};
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'transient failure left song on HTML5');
|
||||
|
||||
// Next poll: transient cause cleared → switch should now succeed.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'transient failure was retried and succeeded');
|
||||
});
|
||||
|
||||
test('jucePlayer.play() failure is transient → NOT memoised, retried next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
// First switch: JUCE transport start fails (play returns false). Second: succeeds.
|
||||
let firstPlay = true;
|
||||
sb.jucePlayer.play = () => {
|
||||
sb.__calls.jucePlay++;
|
||||
if (firstPlay) { firstPlay = false; return Promise.resolve(false); }
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'play() failure left song on HTML5');
|
||||
|
||||
// A play() failure must NOT be memoised as a hard reject — retry succeeds.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'transport-start failure was retried and succeeded');
|
||||
});
|
||||
|
||||
test('stale-abort during a swap-then-restore is NOT memoised as a JUCE reject', async () => {
|
||||
// _currentSongAudio is swapped to a different object and then back to the
|
||||
// *same URL* (a new object) mid-flight. The post-await staleness check
|
||||
// would pass, but the switch already aborted as 'stale' — and a 'stale'
|
||||
// abort must never poison _rerouteRejectedUrl. A later poll must still
|
||||
// be able to route the track.
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
const snapA = { url: '/audio/song.ogg', juceEligible: true };
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = snapA;
|
||||
|
||||
let firstLoad = true;
|
||||
sb.window.slopsmithDesktop.audio.loadBackingTrack = (p) => {
|
||||
sb.__calls.loadBackingTrack.push(p);
|
||||
if (firstLoad) {
|
||||
firstLoad = false;
|
||||
// Swap away (makes the in-flight switch stale), then restore a NEW
|
||||
// object with the same URL before _reevaluateJuceRouting's later check.
|
||||
sb.window._currentSongAudio = { url: '/audio/other.ogg', juceEligible: true };
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting(); // aborts 'stale' — must not memoise
|
||||
|
||||
// A fresh poll against the current song must still attempt the switch.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'track was not poisoned by the stale abort');
|
||||
});
|
||||
|
||||
test('deferred JUCE→HTML5 loadedmetadata callback is a no-op once the song changed', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
|
||||
// Element not ready: the resume runs from a loadedmetadata listener.
|
||||
sb.audio.readyState = 0;
|
||||
let metadataCb = null;
|
||||
sb.audio.addEventListener = (ev, cb) => { if (ev === 'loadedmetadata') metadataCb = cb; };
|
||||
let seekedTo = null;
|
||||
Object.defineProperty(sb.audio, 'currentTime', {
|
||||
get() { return 0; },
|
||||
set(v) { seekedTo = v; },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = '/audio/song-a.ogg';
|
||||
const snapshot = { url: '/audio/song-a.ogg', juceEligible: true };
|
||||
sb.window._currentSongAudio = snapshot;
|
||||
|
||||
await sb.window._reevaluateJuceRouting(); // switches to HTML5, arms listener
|
||||
assert.ok(typeof metadataCb === 'function', 'loadedmetadata listener was registered');
|
||||
|
||||
// Song changes before metadata arrives, then the stale callback fires.
|
||||
sb.window._currentSongAudio = { url: '/audio/song-b.ogg', juceEligible: true };
|
||||
metadataCb();
|
||||
|
||||
assert.equal(seekedTo, null, 'stale callback must not seek the newly loaded song');
|
||||
});
|
||||
|
||||
test('reroute sets window._juceRerouteInProgress during the switch and clears it after', async () => {
|
||||
// The <audio> play/pause listeners (outside this IIFE) suppress their
|
||||
// song:play / song:pause emissions while this flag is truthy, keeping a
|
||||
// transparent migration from desyncing plugin play-state. Verify the
|
||||
// watcher raises the flag during the switch and releases it afterwards.
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
let flagSeenDuringPause = false;
|
||||
sb.audio.pause = () => { flagSeenDuringPause = !!sb.window._juceRerouteInProgress; };
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(flagSeenDuringPause, true, 'flag must be set when audio.pause() runs');
|
||||
// setTimeout is patched to run synchronously, so the deferred release has
|
||||
// already happened by here.
|
||||
assert.equal(sb.window._juceRerouteInProgress, 0, 'flag refcount released after the switch');
|
||||
});
|
||||
|
||||
test('JUCE→HTML5 reroute releases the suppression refcount even if metadata never arrives', async () => {
|
||||
// If the new HTML5 source never reaches loadedmetadata (bad URL / network
|
||||
// error), the suppression refcount must still be released — otherwise
|
||||
// song:play / song:pause stay silenced forever. The backstop timeout (and
|
||||
// 'error' listener) guarantee release. setTimeout is patched to run
|
||||
// synchronously here, so the backstop fires immediately.
|
||||
const sb = makeSandbox({ isAudioRunning: () => false, loadBackingTrack: () => true });
|
||||
sb.audio.readyState = 0; // metadata not ready → deferred path
|
||||
sb.audio.addEventListener = () => {}; // loadedmetadata/error never fire
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = '/audio/song.ogg';
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceRerouteInProgress, 0,
|
||||
'suppression refcount must not leak when metadata never arrives');
|
||||
});
|
||||
|
||||
test('_rerouteInFlight guard blocks an overlapping invocation past the first await', async () => {
|
||||
// The flag must be claimed synchronously before isAudioRunning() so a
|
||||
// second poll tick during a slow IPC cannot run a concurrent switch.
|
||||
let resolveRunning;
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => new Promise((r) => { resolveRunning = r; }),
|
||||
loadBackingTrack: () => true,
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
// First call: parks on the pending isAudioRunning() promise.
|
||||
const first = sb.window._reevaluateJuceRouting();
|
||||
// Second call while the first is still awaiting — must early-return.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0,
|
||||
'overlapping invocation must not start a switch while one is in flight');
|
||||
|
||||
// Let the first finish.
|
||||
resolveRunning(true);
|
||||
await first;
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'the first switch ran exactly once');
|
||||
});
|
||||
|
||||
test('_clearJuceRerouteMemo lets a rejected URL be retried after song teardown', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
|
||||
await sb.window._reevaluateJuceRouting(); // hard reject → URL memoised
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1);
|
||||
|
||||
// Without a clear, the same URL is skipped.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1, 'memoised URL skipped');
|
||||
|
||||
// Song teardown clears the memo; a fresh load of the same URL retries.
|
||||
assert.equal(typeof sb.window._clearJuceRerouteMemo, 'function');
|
||||
sb.window._clearJuceRerouteMemo();
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg', juceEligible: true };
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 2, 'cleared memo allows a fresh attempt');
|
||||
});
|
||||
|
||||
test('song change mid-flight aborts the switch without mutating routing', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => true });
|
||||
sb.window._juceMode = false;
|
||||
const original = { url: '/audio/song-a.ogg', juceEligible: true };
|
||||
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._currentSongAudio = { url: '/audio/song-b.ogg', juceEligible: true };
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, 'stale switch must not commit JUCE mode');
|
||||
assert.notEqual(sb.window._juceAudioUrl, '/audio/song-a.ogg', 'stale URL not committed');
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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;
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy bridge hit counts are attributed to canonical audio domains', () => {
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.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' });
|
||||
|
||||
const shims = window.slopsmith.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);
|
||||
});
|
||||
|
||||
test('native audio-mix participant suppresses matching legacy fader and records overshadowed bridge hit', async () => {
|
||||
const { runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
|
||||
const window = loadAudioSession();
|
||||
installMixerDom(window);
|
||||
runBrowserScript(window, 'static/audio-mixer.js');
|
||||
window.slopsmith.audioSession.startSession({ sessionId: 'main:test-song' });
|
||||
|
||||
window.slopsmith.audio.registerFader({
|
||||
id: 'delay.wet',
|
||||
label: 'Delay Wet Legacy',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
defaultValue: 0.2,
|
||||
logicalFaderKey: 'delay:wet',
|
||||
getValue: () => 0.2,
|
||||
setValue: () => {},
|
||||
});
|
||||
window.slopsmith.audioSession.registerMixParticipant({
|
||||
participantId: 'plugin.delay.native',
|
||||
ownerPluginId: 'delay',
|
||||
label: 'Delay Wet',
|
||||
kind: 'plugin',
|
||||
sourceMode: 'native',
|
||||
logicalFaderKey: 'delay:wet',
|
||||
fader: { id: 'wet', label: 'Delay Wet', min: 0, max: 1, step: 0.1, defaultValue: 0.4, currentValue: 0.4 },
|
||||
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 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);
|
||||
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'fader.delay.wet'), false);
|
||||
assert.equal(legacy.supersededBy, 'plugin.delay.native');
|
||||
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.status === 'overshadowed' && bridge.participantId === 'fader.delay.wet'), true);
|
||||
});
|
||||
|
||||
// Source-level guards for PR1 runtime compatibility-shim hit accounting.
|
||||
// Broader app/player/audio domains are reserved for follow-up PRs, so this
|
||||
// file checks plugin attribution helpers and that library now uses the native
|
||||
// capability module instead of legacy shim accounting.
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
const start = src.indexOf(needle);
|
||||
assert.ok(start !== -1, `missing source needle: ${needle}`);
|
||||
return src.slice(start, start + length);
|
||||
}
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
test('library providers route through native library capability', () => {
|
||||
const src = source(APP_JS);
|
||||
const librarySrc = source(LIBRARY_JS);
|
||||
const loader = region(src, 'async function loadLibraryProviders', 1800);
|
||||
const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
||||
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
|
||||
|
||||
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(loader, /api\.refresh\(\{ restoreSaved \}\)/);
|
||||
assert.match(selector, /capabilityApi\.command\(['"]library['"],\s*['"]select-provider['"]/);
|
||||
assert.match(sync, /capabilityApi\.command\(['"]library['"],\s*['"]sync-song['"]/);
|
||||
assert.doesNotMatch(src, /_recordLegacyLibraryProviderShim/);
|
||||
assert.doesNotMatch(src, /_recordLegacyLibraryCommand/);
|
||||
assert.doesNotMatch(librarySrc, /registerCompatibilityShim|recordLegacyHit/);
|
||||
});
|
||||
|
||||
test('visualization renderer installs preserve plugin attribution', () => {
|
||||
const src = source(APP_JS);
|
||||
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
||||
const setViz = region(src, 'function setViz(id)', 3600);
|
||||
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
||||
|
||||
assert.match(tagger, /renderer\.pluginId\s*=\s*id/);
|
||||
assert.match(tagger, /renderer\.source\s*=\s*id/);
|
||||
assert.match(setViz, /_installVizRenderer\(renderer,\s*id\)|_installVizRenderer\(venueRenderer,\s*'highway_3d'\)/);
|
||||
assert.match(autoViz, /_installVizRenderer\(renderer,\s*id,\s*'auto-match'\)/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const reg = require('../../static/capabilities/library-card-actions.js');
|
||||
|
||||
function freshIds() { reg.snapshot().actions.forEach((a) => reg.unregister(a.id)); }
|
||||
|
||||
test('register + list returns applicable actions sorted by order', () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'b', label: 'B', order: 20, run() {} });
|
||||
reg.register({ id: 'a', label: 'A', order: 10, run() {} });
|
||||
const ids = reg.list({ filename: 'x.psarc' }).map((a) => a.id);
|
||||
assert.deepStrictEqual(ids, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('applies() filters out non-applicable actions', () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} });
|
||||
assert.strictEqual(reg.list({ filename: 'x.psarc', format: 'psarc' }).length, 0);
|
||||
assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1);
|
||||
});
|
||||
|
||||
test('enabled() reflected in the summary but action still listed', () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'maybe', label: 'Maybe', enabled: (s) => !!s.allow, run() {} });
|
||||
const off = reg.list({ filename: 'x' })[0];
|
||||
assert.strictEqual(off.enabled, false);
|
||||
const on = reg.list({ filename: 'x', allow: true })[0];
|
||||
assert.strictEqual(on.enabled, true);
|
||||
});
|
||||
|
||||
test('run() invokes the handler and reports handled', async () => {
|
||||
freshIds();
|
||||
let got = null;
|
||||
reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } });
|
||||
const r = await reg.run('go', { filename: 'song.psarc' }, {});
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.outcome, 'handled');
|
||||
assert.strictEqual(got, 'song.psarc');
|
||||
});
|
||||
|
||||
test('run() of a disabled / non-applicable / unknown action does not throw', async () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'dis', label: 'Dis', enabled: () => false, run() { throw new Error('should not run'); } });
|
||||
assert.strictEqual((await reg.run('dis', {})).outcome, 'disabled');
|
||||
assert.strictEqual((await reg.run('nope', {})).outcome, 'no-action');
|
||||
});
|
||||
|
||||
test('run() surfaces handler errors as failed (no throw)', async () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'boom', label: 'Boom', run() { throw new Error('kaboom'); } });
|
||||
const r = await reg.run('boom', {});
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.outcome, 'failed');
|
||||
});
|
||||
|
||||
test('unregister removes the action', () => {
|
||||
freshIds();
|
||||
const off = reg.register({ id: 'temp', label: 'T', run() {} });
|
||||
assert.strictEqual(reg.list({}).length, 1);
|
||||
off();
|
||||
assert.strictEqual(reg.list({}).length, 0);
|
||||
});
|
||||
|
||||
test('bad specs are rejected (no id / no run)', () => {
|
||||
freshIds();
|
||||
reg.register({ label: 'no id', run() {} });
|
||||
reg.register({ id: 'no-run' });
|
||||
assert.strictEqual(reg.list({}).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const toneSource = require('../../static/v3/live-guitar-tone-source.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
test('default live guitar tone source is internal', () => {
|
||||
assert.equal(toneSource.DEFAULT, 'internal');
|
||||
assert.equal(toneSource.normalize(undefined), 'internal');
|
||||
assert.equal(toneSource.normalize(''), 'internal');
|
||||
assert.equal(toneSource.normalize('bogus'), 'internal');
|
||||
});
|
||||
|
||||
test('normalize accepts external and spark values', () => {
|
||||
assert.equal(toneSource.normalize('external_hardware'), 'external_hardware');
|
||||
assert.equal(toneSource.normalize('spark_control_x'), 'spark_control_x');
|
||||
});
|
||||
|
||||
test('shouldSuppressMonitorMuteHint only for external modes', () => {
|
||||
assert.equal(toneSource.shouldSuppressMonitorMuteHint('internal'), false);
|
||||
assert.equal(toneSource.shouldSuppressMonitorMuteHint('external_hardware'), true);
|
||||
assert.equal(toneSource.shouldSuppressMonitorMuteHint('spark_control_x'), true);
|
||||
assert.equal(toneSource.shouldSuppressMonitorMuteHint('invalid'), false);
|
||||
});
|
||||
|
||||
test('labels include internal, external, and spark options', () => {
|
||||
assert.match(toneSource.LABELS.internal, /feed\[dB\]ack internal tone/i);
|
||||
assert.match(toneSource.LABELS.external_hardware, /External amp/i);
|
||||
assert.match(toneSource.LABELS.spark_control_x, /Spark LIVE/i);
|
||||
});
|
||||
|
||||
test('settings UI exposes tone source select with all options', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="setting-live-guitar-tone-source"/);
|
||||
assert.match(html, /value="internal"/);
|
||||
assert.match(html, /value="external_hardware"/);
|
||||
assert.match(html, /value="spark_control_x"/);
|
||||
assert.match(html, /Live guitar tone source/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="player-live-guitar-tone-source"/);
|
||||
});
|
||||
|
||||
test('live-guitar-tone-source script is loaded in v3 shell', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /live-guitar-tone-source\.js/);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const hud = require('../../static/v3/live-performance-hud.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
|
||||
test('accuracy percentage matches server formula', () => {
|
||||
assert.equal(hud.accuracyPct(84, 16), 84);
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 84, misses: 16 }).accuracyPct, 84);
|
||||
});
|
||||
|
||||
test('divide by zero returns null accuracy and idle state', () => {
|
||||
assert.equal(hud.accuracyPct(0, 0), null);
|
||||
const stats = hud.calculateLivePerformanceState({ hits: 0, misses: 0, streak: 0 });
|
||||
assert.equal(stats.accuracyPct, null);
|
||||
assert.equal(stats.state, 'idle');
|
||||
assert.equal(Number.isNaN(stats.accuracyPct), false);
|
||||
});
|
||||
|
||||
test('fire state requires high accuracy and streak', () => {
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 90, misses: 10, streak: 10 }).state, 'fire');
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 95, misses: 5, streak: 9 }).state, 'strong');
|
||||
});
|
||||
|
||||
test('smoke state for low accuracy', () => {
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 40, misses: 60, streak: 0 }).state, 'smoke');
|
||||
});
|
||||
|
||||
test('steady and recovery thresholds', () => {
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 75, misses: 25, streak: 2 }).state, 'steady');
|
||||
assert.equal(hud.calculateLivePerformanceState({ hits: 55, misses: 45, streak: 1 }).state, 'recovery');
|
||||
});
|
||||
|
||||
test('reset counters via bindRuntime song lifecycle', () => {
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
emit(event, detail) {
|
||||
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
assert.equal(runtime.isActive(), true);
|
||||
|
||||
runtime.onHit();
|
||||
runtime.onHit();
|
||||
runtime.onMiss();
|
||||
assert.equal(runtime.getCounters().hits, 2);
|
||||
assert.equal(runtime.getCounters().misses, 1);
|
||||
assert.equal(runtime.getCounters().streak, 0);
|
||||
|
||||
sm.emit('song:arrangement-changed', { filename: 'song.psarc', arrangement: 1 });
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
|
||||
sm.emit('song:stop', { time: 12 });
|
||||
assert.equal(runtime.isActive(), false);
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
});
|
||||
|
||||
test('DOM text updates after hit and miss events', () => {
|
||||
class El {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.textContent = '';
|
||||
this.className = 'hidden is-idle';
|
||||
this.attrs = {};
|
||||
}
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
|
||||
const els = {
|
||||
root: new El('v3-live-performance-hud'),
|
||||
percent: new El('v3-live-performance-percent'),
|
||||
hits: new El('v3-live-performance-hits'),
|
||||
streak: new El('v3-live-performance-streak'),
|
||||
state: new El('v3-live-performance-state'),
|
||||
};
|
||||
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
emit(event, detail) {
|
||||
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
|
||||
},
|
||||
};
|
||||
|
||||
const runtime = hud.bindRuntime(sm, els);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
|
||||
assert.equal(els.percent.textContent, '\u2014');
|
||||
assert.equal(els.hits.textContent, 'Waiting for notes');
|
||||
|
||||
runtime.onHit();
|
||||
runtime.onHit();
|
||||
runtime.onMiss();
|
||||
|
||||
assert.equal(els.percent.textContent, '67%');
|
||||
assert.equal(els.hits.textContent, 'Hits 2 / 3');
|
||||
assert.equal(els.streak.textContent, 'Streak 0');
|
||||
assert.match(els.state.textContent, /Recovering/);
|
||||
});
|
||||
|
||||
test('HUD stays hidden until the first note arrives, then reveals', () => {
|
||||
class El {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.textContent = '';
|
||||
this.className = 'hidden is-idle';
|
||||
}
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute() {}
|
||||
}
|
||||
const els = { root: new El('v3-live-performance-hud') };
|
||||
|
||||
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, els);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
// Primed (tallying) but not yet visible — a user without note detection
|
||||
// never gets note:hit/note:miss, so the HUD must not show on load alone.
|
||||
assert.equal(runtime.isActive(), true);
|
||||
assert.ok(els.root.className.includes('hidden'));
|
||||
|
||||
runtime.onHit();
|
||||
assert.ok(!els.root.className.includes('hidden'));
|
||||
|
||||
// A new song re-hides until the next note.
|
||||
sm.emit('song:stop', { time: 1 });
|
||||
assert.ok(els.root.className.includes('hidden'));
|
||||
sm.emit('song:loading', { filename: 'song2.psarc' });
|
||||
assert.ok(els.root.className.includes('hidden'));
|
||||
});
|
||||
|
||||
test('idle state before judged notes', () => {
|
||||
const stats = hud.calculateLivePerformanceState({ hits: 0, misses: 0, streak: 0 });
|
||||
assert.equal(stats.state, 'idle');
|
||||
assert.equal(hud.formatPercentText(stats), '\u2014');
|
||||
assert.equal(hud.formatHitsText(stats), 'Waiting for notes');
|
||||
});
|
||||
|
||||
test('v3 player markup includes live performance HUD', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="v3-live-performance-hud"/);
|
||||
assert.match(html, /id="v3-live-performance-percent"/);
|
||||
assert.match(html, /id="v3-live-performance-hits"/);
|
||||
assert.match(html, /id="v3-live-performance-streak"/);
|
||||
assert.match(html, /live-performance-hud\.js/);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
// Verify the plugin-facing loop API: setLoop / clearLoop / getLoop on
|
||||
// window.slopsmith, the input validation in setLoop, and the
|
||||
// loadSavedLoop refactor that funnels through setLoop.
|
||||
//
|
||||
// Same isolation strategy as loop_restart.test.js — extract relevant
|
||||
// functions by brace-matching and run them 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 vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
scan++;
|
||||
while (scan < src.length && parenDepth > 0) {
|
||||
const ch = src[scan];
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') parenDepth--;
|
||||
scan++;
|
||||
}
|
||||
}
|
||||
const openBrace = src.indexOf('{', scan);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildSandbox() {
|
||||
const seekCalls = [];
|
||||
const sectionPracticeModeCalls = [];
|
||||
const transportEvents = [];
|
||||
const sandbox = {
|
||||
seekCalls,
|
||||
sectionPracticeModeCalls,
|
||||
transportEvents,
|
||||
// Mutable state (declared as `var` in eval prelude so it lives on
|
||||
// the sandbox global and the extracted functions can read/write).
|
||||
// The actual values are set below.
|
||||
|
||||
// DOM stub: every getElementById returns the same stand-in object.
|
||||
// Writes to className/textContent/classList are absorbed silently;
|
||||
// we don't assert on them here (the runtime would catch a missing
|
||||
// element, the unit test cares about loop bookkeeping).
|
||||
document: {
|
||||
getElementById: () => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
value: '',
|
||||
// _syncSavedLoopSelection iterates over <select>.options.
|
||||
// An empty option list is fine for these unit tests; the
|
||||
// sync becomes a no-op (no matching option found).
|
||||
options: [],
|
||||
classList: { add() {}, remove() {}, toggle() {} },
|
||||
}),
|
||||
},
|
||||
// _audioSeek spy — records every call so tests can assert seek
|
||||
// happened with the right target.
|
||||
// _audioSeek now resolves to { completed, from, to }; the
|
||||
// stub mimics a successful seek that lands exactly on s so
|
||||
// setLoop's off-target check passes.
|
||||
_audioSeek: (s, reason) => {
|
||||
seekCalls.push({ s, reason: reason ?? null });
|
||||
return Promise.resolve({ completed: true, from: 0, to: s });
|
||||
},
|
||||
_audioTime: () => 0,
|
||||
// updateLoopUI references formatTime for the label; we don't
|
||||
// assert on the label text in these tests, so a stub is enough.
|
||||
formatTime: (s) => String(s),
|
||||
window: {
|
||||
slopsmith: {
|
||||
playback: {
|
||||
transportEvent: (...args) => transportEvents.push(args),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadFunctions(sandbox, src) {
|
||||
// Pull just the loop helpers — clearLoop, setLoop, updateLoopUI
|
||||
// (called by setLoop), and the loop state vars.
|
||||
const code = `
|
||||
var loopA = null;
|
||||
var loopB = null;
|
||||
var _loopMutationGen = 0;
|
||||
var _sectionPracticeSelected = -1;
|
||||
var _sectionPracticeWholeSection = false;
|
||||
var _sectionPracticeSavedPartIndex = 0;
|
||||
function _setSectionPracticeMode(on, opts) {
|
||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||
}
|
||||
function _updateSectionPracticeHighlight(ct) {}
|
||||
${extractFunction(src, 'function clearLoop(')}
|
||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||
${extractFunction(src, 'async function setLoop(')}
|
||||
${extractFunction(src, 'function updateLoopUI()')}
|
||||
// Expose for the test runner.
|
||||
globalThis.__setLoop = setLoop;
|
||||
globalThis.__clearLoop = clearLoop;
|
||||
globalThis.__getLoop = () => ({ loopA, loopB });
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
const result = await sandbox.__setLoop(5.5, 12.25);
|
||||
assert.equal(result, true, 'successful seek must resolve to true (plugin contract)');
|
||||
const { loopA, loopB } = sandbox.__getLoop();
|
||||
assert.equal(loopA, 5.5);
|
||||
assert.equal(loopB, 12.25);
|
||||
assert.equal(sandbox.seekCalls.length, 1);
|
||||
assert.equal(sandbox.seekCalls[0].s, 5.5);
|
||||
});
|
||||
|
||||
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
|
||||
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
|
||||
// false; the loop is NOT armed.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
const before = sandbox.__getLoop();
|
||||
const result = await sandbox.__setLoop(5, 10);
|
||||
|
||||
assert.equal(result, false, 'cancelled seek must resolve to false');
|
||||
const after = sandbox.__getLoop();
|
||||
assert.equal(after.loopA, before.loopA, 'loopA must not be committed on cancel');
|
||||
assert.equal(after.loopB, before.loopB, 'loopB must not be committed on cancel');
|
||||
});
|
||||
|
||||
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
|
||||
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
|
||||
// from the requested a. The loop is NOT armed.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
const before = sandbox.__getLoop();
|
||||
const result = await sandbox.__setLoop(5, 10);
|
||||
|
||||
assert.equal(result, false, 'off-target seek must resolve to false');
|
||||
const after = sandbox.__getLoop();
|
||||
assert.equal(after.loopA, before.loopA, 'loopA must not be committed on off-target');
|
||||
assert.equal(after.loopB, before.loopB, 'loopB must not be committed on off-target');
|
||||
});
|
||||
|
||||
test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
||||
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
|
||||
// values may already be strings. Number() coercion in setLoop must
|
||||
// accept finite numeric strings.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__setLoop('3.0', '7.0');
|
||||
const { loopA, loopB } = sandbox.__getLoop();
|
||||
assert.equal(loopA, 3);
|
||||
assert.equal(loopB, 7);
|
||||
});
|
||||
|
||||
test('setLoop rejects non-finite inputs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await assert.rejects(() => sandbox.__setLoop(NaN, 5), /finite a and b/);
|
||||
await assert.rejects(() => sandbox.__setLoop(1, Infinity), /finite a and b/);
|
||||
await assert.rejects(() => sandbox.__setLoop('abc', 5), /finite a and b/);
|
||||
});
|
||||
|
||||
test('setLoop rejects b <= a', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await assert.rejects(() => sandbox.__setLoop(10, 10), /b > a/);
|
||||
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
|
||||
});
|
||||
|
||||
test('clearLoop resets loopA/loopB to null', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__setLoop(5, 10);
|
||||
sandbox.__clearLoop();
|
||||
const { loopA, loopB } = sandbox.__getLoop();
|
||||
assert.equal(loopA, null);
|
||||
assert.equal(loopB, null);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
|
||||
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
|
||||
// Field-wise: vm-context objects break deepStrictEqual across realms.
|
||||
assert.equal(sandbox.sectionPracticeModeCalls[0].opts.skipClearLoop, true);
|
||||
});
|
||||
|
||||
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__setLoop(5, 10);
|
||||
sandbox.__clearLoop();
|
||||
|
||||
assert.equal(sandbox.transportEvents.length, 2);
|
||||
assert.equal(sandbox.transportEvents[0][0], 'loop-set');
|
||||
assert.equal(JSON.stringify(sandbox.transportEvents[0][1].loop), JSON.stringify({ startTime: 5, endTime: 10, enabled: true, state: 'active' }));
|
||||
assert.equal(sandbox.transportEvents[1][0], 'loop-cleared');
|
||||
assert.equal(JSON.stringify(sandbox.transportEvents[1][1].loop), JSON.stringify({ enabled: false, state: 'inactive' }));
|
||||
|
||||
sandbox.transportEvents.length = 0;
|
||||
await sandbox.__setLoop(7, 11, { emitTransportEvent: false });
|
||||
sandbox.__clearLoop({ emitTransportEvent: false });
|
||||
assert.equal(sandbox.transportEvents.length, 0);
|
||||
});
|
||||
|
||||
test('window.slopsmith 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');
|
||||
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');
|
||||
});
|
||||
|
||||
test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () => {
|
||||
// After the refactor, the dropdown path must call setLoop rather than
|
||||
// re-implementing the loopA/loopB assignment. Catches a future drift
|
||||
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
|
||||
// sync.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function loadSavedLoop(');
|
||||
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
|
||||
// The pre-refactor body assigned loopA = parseFloat(...) directly;
|
||||
// ensure that pattern is gone.
|
||||
assert.doesNotMatch(
|
||||
fn,
|
||||
/loopA\s*=\s*parseFloat/,
|
||||
'loadSavedLoop still has the pre-refactor loopA = parseFloat assignment',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// Verify static/app.js emits `loop:restart` exactly once when the A-B
|
||||
// loop wraps, with the documented payload shape. Plugins (notedetect's
|
||||
// drill-mode score capture) consume this contract.
|
||||
//
|
||||
// The test does not load the full app.js into a DOM — it extracts just
|
||||
// the `startCountIn` function source via brace-matching and evaluates it
|
||||
// in a vm sandbox with stubbed dependencies. This trades coverage of the
|
||||
// surrounding script for isolation: a failure here points at the wrap
|
||||
// path, not at unrelated DOM coupling.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
|
||||
// and brace-matching to the closing brace. Skips an optional `( ... )` param
|
||||
// list so `startCountIn(opts = {})` and `startCountIn()` both match the same
|
||||
// prefix. Brittle by design: rename/restructure fails loudly, not silently.
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
scan++;
|
||||
while (scan < src.length && parenDepth > 0) {
|
||||
const ch = src[scan];
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') parenDepth--;
|
||||
scan++;
|
||||
}
|
||||
}
|
||||
const openBrace = src.indexOf('{', scan);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildSandbox() {
|
||||
const emitCalls = [];
|
||||
const sandbox = {
|
||||
// Globals the function reads/writes via closure. Declared as `var`
|
||||
// in the eval prelude so they attach to the sandbox.
|
||||
loopA: 10,
|
||||
loopB: 20,
|
||||
_countingIn: false,
|
||||
isPlaying: false,
|
||||
lastAudioTime: 0,
|
||||
|
||||
// Browser-ish globals.
|
||||
performance: { now: () => Date.now() },
|
||||
// requestAnimationFrame: skip to t >= 1 in one tick so the rewind
|
||||
// animation completes synchronously and we reach the `_audioSeek`
|
||||
// continuation immediately.
|
||||
requestAnimationFrame(fn) {
|
||||
// Fire with `now` far enough in the future that
|
||||
// (now - rewindStart) / rewindDuration >= 1.
|
||||
queueMicrotask(() => fn(Date.now() + 10_000));
|
||||
},
|
||||
// setTimeout: swallow. beginCount schedules ticks via setTimeout;
|
||||
// we don't need them to fire — the emit happens before beginCount.
|
||||
setTimeout: () => 0,
|
||||
|
||||
// Stubbed slopsmith DOM dependencies.
|
||||
audio: { pause() {} },
|
||||
jucePlayer: { pause: () => Promise.resolve(), play: () => Promise.resolve(true) },
|
||||
highway: { setTime() {}, getBPM: () => 120 },
|
||||
|
||||
// Stubbed app.js helpers.
|
||||
// Resolve with the real shape `{ completed, from, to }` so
|
||||
// startCountIn's loop-wrap callback sees completed=true and uses
|
||||
// r.to for highway.setTime / lastAudioTime.
|
||||
_audioSeek: (s) => Promise.resolve({ completed: true, from: 20, to: s }),
|
||||
playClick: () => {},
|
||||
showCountOverlay: () => {},
|
||||
hideCountOverlay: () => {},
|
||||
|
||||
// Stubbed DOM access. Anything querying for a button just gets a
|
||||
// permissive object that ignores writes.
|
||||
document: {
|
||||
getElementById: () => ({
|
||||
textContent: '',
|
||||
className: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} },
|
||||
}),
|
||||
},
|
||||
|
||||
// Spy: records every emit call so the test can assert.
|
||||
window: {
|
||||
slopsmith: {
|
||||
emit(event, detail) { emitCalls.push({ event, detail }); },
|
||||
isPlaying: false,
|
||||
},
|
||||
_juceMode: false,
|
||||
},
|
||||
|
||||
// Capture for assertions.
|
||||
__emitCalls: emitCalls,
|
||||
queueMicrotask,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('loop:restart fires once when wrap path runs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
// Sanity check: the change under test is present at all. Catches
|
||||
// accidental revert before we even run the behavior assertion.
|
||||
assert.match(
|
||||
startCountInSrc,
|
||||
/window\.slopsmith\.emit\(\s*['"]loop:restart['"]/,
|
||||
'startCountIn is missing the loop:restart emit',
|
||||
);
|
||||
|
||||
const sandbox = buildSandbox();
|
||||
// Re-declare the closure-scoped lets as vars so the function can read
|
||||
// them from the sandbox global, then define the function in-context.
|
||||
const prelude = `
|
||||
var loopA = ${sandbox.loopA};
|
||||
var loopB = ${sandbox.loopB};
|
||||
var _countingIn = false;
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
`;
|
||||
vm.runInContext(prelude, sandbox);
|
||||
|
||||
await sandbox.__startCountIn();
|
||||
// Allow the queued requestAnimationFrame microtask + the _audioSeek
|
||||
// promise chain to settle. Two awaits is enough: rAF microtask -> rewind
|
||||
// completion -> _audioSeek().then() -> emit.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
|
||||
assert.equal(restarts.length, 1, `expected 1 loop:restart emit, got ${restarts.length}`);
|
||||
// Field-wise assertion: deepStrictEqual fails across vm-context object
|
||||
// realms because Object.prototype identities differ even when contents
|
||||
// match. Compare values, not prototype graphs.
|
||||
const detail = restarts[0].detail;
|
||||
assert.equal(detail.loopA, 10);
|
||||
assert.equal(detail.loopB, 20);
|
||||
assert.equal(detail.time, 10);
|
||||
assert.equal(Object.keys(detail).length, 3, `unexpected extra keys in detail: ${Object.keys(detail)}`);
|
||||
});
|
||||
|
||||
test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async () => {
|
||||
// Regression: if jucePlayer.seek rolls back (currentTime stays put),
|
||||
// _audioSeek resolves with completed:true but r.to !== loopA. The
|
||||
// wrap handler must abort instead of running beginCount on the wrong
|
||||
// position and emitting a misleading loop:restart.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
const sandbox = buildSandbox();
|
||||
// Override _audioSeek to mimic JUCE rollback: completed but to=from,
|
||||
// far from the requested loopA (10).
|
||||
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 20, to: 20 });
|
||||
const prelude = `
|
||||
var loopA = ${sandbox.loopA};
|
||||
var loopB = ${sandbox.loopB};
|
||||
var _countingIn = false;
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
globalThis.__getCountingIn = () => _countingIn;
|
||||
`;
|
||||
vm.runInContext(prelude, sandbox);
|
||||
|
||||
await sandbox.__startCountIn();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
|
||||
assert.equal(restarts.length, 0, 'rollback must not emit loop:restart');
|
||||
assert.equal(sandbox.__getCountingIn(), false, '_countingIn must be cleared on abort');
|
||||
});
|
||||
|
||||
test('count-in cancellation token bails delayed callbacks (rewindStep + tick)', () => {
|
||||
// Source-level assertion: the gen-capture pattern is in place so
|
||||
// teardown can interrupt an in-flight count-in. Behavioral simulation
|
||||
// of timer cancellation is out of scope for the static extractor; this
|
||||
// verifies the contract is wired into the source.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
// Captures gen at entry
|
||||
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
|
||||
// Each delayed callback bails on mismatch
|
||||
const guards = [...fn.matchAll(/if \(gen !== _countInGen\) return/g)];
|
||||
assert.ok(guards.length >= 4, `expected ≥4 gen-mismatch bails, found ${guards.length}`);
|
||||
// RAF and timer handles tracked so _cancelCountIn can cancel them
|
||||
assert.match(fn, /_countInRaf = requestAnimationFrame/, 'rewindStep must store its RAF handle in _countInRaf');
|
||||
assert.match(fn, /_countInTimer = setTimeout/, 'tick scheduling must store its timer in _countInTimer');
|
||||
});
|
||||
|
||||
test('loop:restart fires after highway.setTime, before beginCount', () => {
|
||||
// Source-order assertion on the A-B wrap path only. Section-practice
|
||||
// `opts.immediate` also emits loop:restart but is a separate entry path;
|
||||
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
|
||||
const wrapStart = fn.indexOf(wrapMarker);
|
||||
assert.ok(wrapStart !== -1, 'loop-wrap _audioSeek call not found in startCountIn');
|
||||
const wrapSlice = fn.slice(wrapStart);
|
||||
|
||||
const setTimeMatches = [...wrapSlice.matchAll(/highway\.setTime\(\s*[^)]+\)/g)];
|
||||
const setTimeIdx = setTimeMatches.length
|
||||
? wrapStart + setTimeMatches[setTimeMatches.length - 1].index
|
||||
: -1;
|
||||
const emitRel = wrapSlice.search(/window\.slopsmith\.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*\(/);
|
||||
const beginCallIdx = beginCallMatch ? emitIdx + beginCallMatch.index : -1;
|
||||
|
||||
assert.ok(setTimeIdx !== -1, 'post-seek highway.setTime not found on wrap path');
|
||||
assert.ok(emitIdx !== -1, 'loop:restart emit not found on wrap path');
|
||||
assert.ok(beginCallIdx !== -1, 'beginCount() call not found after wrap emit');
|
||||
assert.ok(setTimeIdx < emitIdx, 'wrap emit must come after highway.setTime');
|
||||
assert.ok(emitIdx < beginCallIdx, 'wrap emit must come before beginCount()');
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
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 { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const NOTE_DETECTION_JS = path.join(ROOT, 'static', 'capabilities', 'note-detection.js');
|
||||
|
||||
function loadNoteDetection(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(NOTE_DETECTION_JS, 'utf8'), context, { filename: NOTE_DETECTION_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
function captureEvents(api, eventNames) {
|
||||
const events = [];
|
||||
for (const name of eventNames) {
|
||||
api.subscribe(name, (detail) => events.push(detail));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function registerMidiProvider(api) {
|
||||
return api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'keys_highway_3d',
|
||||
payload: { providerId: 'keys-midi', label: 'Keys MIDI', kind: 'midi', primitives: ['verify.target'] },
|
||||
});
|
||||
}
|
||||
|
||||
test('note-detection domain registers an active sensitive provider-coordinator', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.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);
|
||||
});
|
||||
|
||||
test('open-binding without a provider reports unavailable, never a silent verdict', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'keys_highway_3d', payload: { context: { arrangement: 'keys' } },
|
||||
});
|
||||
assert.equal(result.outcome, 'unavailable');
|
||||
assert.match(result.reason, /No note-detection provider/);
|
||||
});
|
||||
|
||||
test('provider registration + binding lifecycle with per-binding context', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const events = captureEvents(api, [
|
||||
'note-detection:provider-registered',
|
||||
'note-detection:binding-opened',
|
||||
'note-detection:binding-closed',
|
||||
'note-detection:target-changed',
|
||||
]);
|
||||
|
||||
const reg = await registerMidiProvider(api);
|
||||
assert.equal(reg.outcome, 'handled');
|
||||
assert.ok(api.inspect('note-detection').participants.some(p => p.pluginId === 'keys_highway_3d'));
|
||||
|
||||
const open = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'keys_highway_3d',
|
||||
payload: { providerId: 'keys-midi', context: { arrangement: 'keys', midiLow: 21, midiHigh: 108, capo: 0 } },
|
||||
});
|
||||
assert.equal(open.outcome, 'handled');
|
||||
const bindingId = open.payload.bindingId;
|
||||
assert.ok(bindingId);
|
||||
const binding = open.payload.bindings.find(b => b.id === bindingId);
|
||||
assert.equal(binding.context.arrangement, 'keys');
|
||||
assert.equal(binding.context.midiLow, 21);
|
||||
|
||||
const target = await api.dispatch({
|
||||
capability: 'note-detection', command: 'set-target',
|
||||
source: 'keys_highway_3d',
|
||||
payload: { bindingId, notes: [{ midi: 60 }, { midi: 64 }, { midi: 67 }] },
|
||||
});
|
||||
assert.equal(target.outcome, 'handled');
|
||||
assert.equal(target.payload.targetSize, 3);
|
||||
|
||||
const close = await api.dispatch({
|
||||
capability: 'note-detection', command: 'close-binding',
|
||||
source: 'keys_highway_3d', payload: { bindingId },
|
||||
});
|
||||
assert.equal(close.outcome, 'handled');
|
||||
|
||||
const names = events.map(e => e.event);
|
||||
assert.ok(names.includes('provider-registered'));
|
||||
assert.ok(names.includes('binding-opened'));
|
||||
assert.ok(names.includes('target-changed'));
|
||||
assert.ok(names.includes('binding-closed'));
|
||||
});
|
||||
|
||||
test('concurrent bindings keep independent contexts (FR-003)', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const a = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'highway', payload: { context: { arrangement: 'guitar', stringCount: 6, capo: 2 } },
|
||||
});
|
||||
const b = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'slopscale', payload: { context: { arrangement: 'bass', stringCount: 4, capo: 0 } },
|
||||
});
|
||||
const snapshot = window.slopsmith.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');
|
||||
assert.equal(ctxA.capo, 2);
|
||||
assert.equal(ctxB.arrangement, 'bass');
|
||||
assert.equal(ctxB.capo, 0);
|
||||
});
|
||||
|
||||
test('unregistering a provider closes its bindings and flips availability', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.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);
|
||||
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);
|
||||
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'));
|
||||
// Runtime participant must be removed from the pipeline so inspect() no
|
||||
// longer lists the provider as active after it unregisters.
|
||||
const participants = api.inspect('note-detection').participants || [];
|
||||
assert.ok(!participants.some(p => p.pluginId === 'keys_highway_3d' && (p.roles || []).includes('provider')),
|
||||
'provider participant should be removed from the pipeline on unregister');
|
||||
});
|
||||
|
||||
test('hit/miss reports flow as observability events with bounded fields', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.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 });
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(events[0].payload.midi, 64);
|
||||
// Unknown fields are dropped — payloads stay bounded and device-label free.
|
||||
assert.equal(events[0].payload.secretDevice, undefined);
|
||||
assert.equal(events[1].event, 'miss');
|
||||
});
|
||||
|
||||
test('diagnostics contribution is redaction-safe', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.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 });
|
||||
const contribution = window.__diagnosticsContributions.get('note-detection-capability');
|
||||
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
|
||||
const serialized = JSON.stringify(contribution);
|
||||
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.psarc/i.test(serialized), serialized);
|
||||
});
|
||||
|
||||
test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.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', {});
|
||||
const provider = () => ({ state: 'hit' });
|
||||
window.highway.setNoteStateProvider(provider);
|
||||
assert.equal(installed, provider, 'legacy behavior preserved');
|
||||
const shims = api.snapshotDiagnostics().compatibilityShims
|
||||
.filter(s => s.capability === 'note-detection');
|
||||
assert.equal(shims.length, 1);
|
||||
assert.equal(shims[0].shimId, 'note-detection:highway.setNoteStateProvider');
|
||||
assert.equal(shims[0].status, 'used');
|
||||
assert.ok(shims[0].hitCount >= 1);
|
||||
});
|
||||
|
||||
test('unsupported binding/provider ids degrade with bounded reasons (FR-008)', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
const badProvider = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'x', payload: { providerId: 'nope' },
|
||||
});
|
||||
assert.equal(badProvider.outcome, 'degraded');
|
||||
const badBinding = await api.dispatch({
|
||||
capability: 'note-detection', command: 'set-target',
|
||||
source: 'x', payload: { bindingId: 'ndb-999', notes: [] },
|
||||
});
|
||||
assert.equal(badBinding.outcome, 'degraded');
|
||||
});
|
||||
|
||||
test('_contextSummary whitelists arrangement kind — unknown values are dropped', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
// Known arrangement kinds pass through.
|
||||
const open = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'caller', payload: { context: { arrangement: 'keys', stringCount: 6 } },
|
||||
});
|
||||
assert.equal(open.outcome, 'handled');
|
||||
const snap = window.slopsmith.noteDetection.snapshot();
|
||||
const ctx = snap.bindings.find(b => b.id === open.payload.bindingId).context;
|
||||
assert.equal(ctx.arrangement, 'keys');
|
||||
|
||||
// Arbitrary string must not appear in context summary.
|
||||
const open2 = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.psarc' } },
|
||||
});
|
||||
assert.equal(open2.outcome, 'handled');
|
||||
const snap2 = window.slopsmith.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;
|
||||
await registerMidiProvider(api);
|
||||
const snap1 = window.slopsmith.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 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;
|
||||
await registerMidiProvider(api);
|
||||
const open = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'owner-plugin', payload: {},
|
||||
});
|
||||
const bindingId = open.payload.bindingId;
|
||||
|
||||
// A different requester must not close the binding.
|
||||
const stealClose = await api.dispatch({
|
||||
capability: 'note-detection', command: 'close-binding',
|
||||
source: 'other-plugin', payload: { bindingId },
|
||||
});
|
||||
assert.equal(stealClose.outcome, 'degraded', 'non-owner close must be rejected');
|
||||
|
||||
// A different requester must not retarget the binding.
|
||||
const stealTarget = await api.dispatch({
|
||||
capability: 'note-detection', command: 'set-target',
|
||||
source: 'other-plugin', payload: { bindingId, notes: [{ midi: 60 }] },
|
||||
});
|
||||
assert.equal(stealTarget.outcome, 'degraded', 'non-owner set-target must be rejected');
|
||||
|
||||
// The binding must still exist and be closeable by the original owner.
|
||||
const ownerClose = await api.dispatch({
|
||||
capability: 'note-detection', command: 'close-binding',
|
||||
source: 'owner-plugin', payload: { bindingId },
|
||||
});
|
||||
assert.equal(ownerClose.outcome, 'handled');
|
||||
});
|
||||
|
||||
test('register-provider rejects cross-owner re-registration', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
// First registration by plugin-a.
|
||||
const first = await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'plugin-a',
|
||||
payload: { providerId: 'shared-provider', label: 'Shared', kind: 'js', primitives: [] },
|
||||
});
|
||||
assert.equal(first.outcome, 'handled');
|
||||
|
||||
// A different participant must not overwrite the same providerId.
|
||||
const steal = await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'plugin-b',
|
||||
payload: { providerId: 'shared-provider', label: 'Hijacked', kind: 'js', primitives: [] },
|
||||
});
|
||||
assert.equal(steal.outcome, 'degraded', 'cross-owner re-registration must be rejected');
|
||||
|
||||
// The original owner may still update its own registration.
|
||||
const refresh = await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'plugin-a',
|
||||
payload: { providerId: 'shared-provider', label: 'Refreshed', kind: 'js', primitives: [] },
|
||||
});
|
||||
assert.equal(refresh.outcome, 'handled', 'owner may refresh its own registration');
|
||||
});
|
||||
|
||||
test('unregister-provider rejects cross-owner unregister', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
await registerMidiProvider(api);
|
||||
|
||||
// A different participant must not unregister a provider it does not own.
|
||||
const steal = await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'other-plugin', payload: { providerId: 'keys-midi' },
|
||||
});
|
||||
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();
|
||||
assert.ok(snap.providers.some(p => p.id === 'keys-midi'), 'provider must survive cross-owner unregister attempt');
|
||||
|
||||
// The original owner can still unregister.
|
||||
const ownerUnreg = await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
|
||||
});
|
||||
assert.equal(ownerUnreg.outcome, 'handled', 'owner must be able to unregister its own provider');
|
||||
});
|
||||
|
||||
test('availability-changed fires only on 0→1 and 1→0 transitions', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const events = captureEvents(api, ['note-detection:availability-changed']);
|
||||
|
||||
// First registration: 0→1, should emit.
|
||||
await registerMidiProvider(api);
|
||||
// Second registration of a different provider by same owner: already available, must NOT emit.
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'plugin-b',
|
||||
payload: { providerId: 'engine-provider', label: 'Engine', kind: 'engine', primitives: [] },
|
||||
});
|
||||
// Unregister first provider: still one left, must NOT emit.
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'keys_highway_3d', payload: { providerId: 'keys-midi' },
|
||||
});
|
||||
// Unregister last provider: 1→0, should emit false.
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'plugin-b', payload: { providerId: 'engine-provider' },
|
||||
});
|
||||
|
||||
const available = events.map(e => e.payload.available);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(available)), [true, false],
|
||||
'only the 0→1 and final 1→0 transitions should emit availability-changed');
|
||||
});
|
||||
|
||||
test('unregistering one of two providers from the same participant keeps participant in the pipeline', async () => {
|
||||
const window = loadNoteDetection();
|
||||
const api = window.slopsmith.capabilities;
|
||||
|
||||
// One plugin registers two providers under the same participantId.
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'multi-plugin',
|
||||
payload: { providerId: 'multi-provider-a', label: 'A', kind: 'js', primitives: [] },
|
||||
});
|
||||
await api.dispatch({
|
||||
capability: 'note-detection', command: 'register-provider',
|
||||
source: 'multi-plugin',
|
||||
payload: { providerId: 'multi-provider-b', label: 'B', kind: 'js', primitives: [] },
|
||||
});
|
||||
|
||||
// Unregister one provider — the participant must remain in the pipeline
|
||||
// because the second provider from the same plugin still exists.
|
||||
const unreg = await api.dispatch({
|
||||
capability: 'note-detection', command: 'unregister-provider',
|
||||
source: 'multi-plugin', payload: { providerId: 'multi-provider-a' },
|
||||
});
|
||||
assert.equal(unreg.outcome, 'handled');
|
||||
|
||||
const snap = window.slopsmith.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 || [];
|
||||
assert.ok(participants.some(p => p.pluginId === 'multi-plugin'),
|
||||
'multi-plugin participant must remain in pipeline while it still has a live provider');
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Verify the decorative pedalboard patch-cable engine (static/v3/pedal-cables.js):
|
||||
// pure geometry helpers (jack positions, point seeding, path building, static
|
||||
// sag), the segment cap, and the prefers-reduced-motion branch — which must
|
||||
// draw a STATIC sagged path and start NO requestAnimationFrame loop, while the
|
||||
// normal path DOES start the loop.
|
||||
|
||||
const { test } = require('node:test');
|
||||
// Non-strict assert: helpers return objects created inside the vm realm, whose
|
||||
// Object.prototype differs from this realm's — strict deepEqual would fail the
|
||||
// prototype check even when the structure matches.
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = path.join(__dirname, '..', '..', 'static', 'v3', 'pedal-cables.js');
|
||||
|
||||
function rect(l, t, r, b) { return { left: l, top: t, right: r, bottom: b, width: r - l, height: b - t }; }
|
||||
|
||||
function loadCables(opts) {
|
||||
opts = opts || {};
|
||||
const rafCalls = [];
|
||||
const created = []; // every createElementNS node
|
||||
const win = { slopsmith: null };
|
||||
win.addEventListener = () => {};
|
||||
win.matchMedia = () => ({ matches: !!opts.reduce });
|
||||
const doc = {
|
||||
createElementNS: (ns, tag) => {
|
||||
const node = {
|
||||
_tag: tag, attrs: {}, children: [],
|
||||
setAttribute: (k, v) => { node.attrs[k] = v; },
|
||||
appendChild: (c) => { node.children.push(c); },
|
||||
insertBefore: (c) => { node.children.unshift(c); },
|
||||
firstChild: null,
|
||||
};
|
||||
created.push(node);
|
||||
return node;
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
window: win, document: doc, console,
|
||||
requestAnimationFrame: (cb) => { rafCalls.push(cb); return rafCalls.length; },
|
||||
cancelAnimationFrame: () => {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: 'pedal-cables.js' });
|
||||
return { api: win.v3PedalCables, t: win.v3PedalCables._test, rafCalls, created };
|
||||
}
|
||||
|
||||
// A fake board containing two pedals; root yields the one board.
|
||||
function fakeRoot() {
|
||||
const pedalA = { getBoundingClientRect: () => rect(10, 10, 160, 210) };
|
||||
const pedalB = { getBoundingClientRect: () => rect(220, 10, 370, 210) };
|
||||
const board = {
|
||||
firstChild: null,
|
||||
scrollWidth: 800, scrollHeight: 420,
|
||||
getBoundingClientRect: () => rect(0, 0, 800, 420),
|
||||
setAttribute: () => {},
|
||||
insertBefore: () => {},
|
||||
querySelectorAll: (sel) => (sel === '.v3-pedal' ? [pedalA, pedalB] : []),
|
||||
};
|
||||
return { querySelectorAll: (sel) => (sel === '.v3-pedalboard' ? [board] : []) };
|
||||
}
|
||||
|
||||
test('computeJacks: out = right side, in = left side, both at vertical centre', () => {
|
||||
const { t } = loadCables();
|
||||
const j = t.computeJacks(rect(100, 50, 250, 250), rect(0, 0, 800, 400), 10);
|
||||
// pedal spans x[100,250] y[50,250]; jack y = top + JACK_FRAC*height to line
|
||||
// up with the photo's side jacks.
|
||||
const y = 50 + t.JACK_FRAC * 200;
|
||||
assert.deepEqual(j.out, { x: 240, y }); // right-10
|
||||
assert.deepEqual(j.in, { x: 110, y }); // left+10
|
||||
});
|
||||
|
||||
test('seedPoints: N points, endpoints pinned to a and b', () => {
|
||||
const { t } = loadCables();
|
||||
const pts = t.seedPoints({ x: 0, y: 0 }, { x: 100, y: 0 }, 12);
|
||||
assert.equal(pts.length, 12);
|
||||
assert.deepEqual({ x: pts[0].x, y: pts[0].y }, { x: 0, y: 0 });
|
||||
assert.deepEqual({ x: pts[11].x, y: pts[11].y }, { x: 100, y: 0 });
|
||||
});
|
||||
|
||||
test('pointsToPath: M then L per subsequent point', () => {
|
||||
const { t } = loadCables();
|
||||
const d = t.pointsToPath([{ x: 0, y: 0 }, { x: 5, y: 5 }, { x: 9, y: 1 }]);
|
||||
assert.ok(d.startsWith('M 0.0 0.0'));
|
||||
assert.equal((d.match(/L /g) || []).length, 2);
|
||||
});
|
||||
|
||||
test('staticCablePath: quadratic with a downward sag', () => {
|
||||
const { t } = loadCables();
|
||||
const d = t.staticCablePath({ x: 0, y: 100 }, { x: 100, y: 100 }, 0.2);
|
||||
assert.ok(d.startsWith('M 0.0 100.0'));
|
||||
assert.ok(d.includes('Q '));
|
||||
// Control-point y is below the endpoints (larger y == lower on screen).
|
||||
const cy = parseFloat(d.split('Q ')[1].split(' ')[1]);
|
||||
assert.ok(cy > 100);
|
||||
});
|
||||
|
||||
test('segment cap constants are sane', () => {
|
||||
const { t } = loadCables();
|
||||
assert.ok(t.SEGMENTS >= 2 && t.SEGMENTS <= 40);
|
||||
assert.ok(t.MAX_CABLES >= 1);
|
||||
});
|
||||
|
||||
test('attach draws each cable as a static curve with NO idle rAF loop', () => {
|
||||
// The cable shape is a pure function of the endpoints (no Verlet/inertia),
|
||||
// so attach renders once and schedules no continuous animation loop —
|
||||
// there is nothing to settle, which is what kills the "moon gravity" drift.
|
||||
const { api, rafCalls, created } = loadCables({ reduce: false });
|
||||
api.attach(fakeRoot());
|
||||
assert.equal(rafCalls.length, 0, 'no idle animation loop');
|
||||
const paths = created.filter((n) => n._tag === 'path');
|
||||
assert.equal(paths.length, 1, 'one cable between two pedals');
|
||||
assert.ok((paths[0].attrs.d || '').includes('Q '), 'curved (quadratic) path drawn');
|
||||
});
|
||||
|
||||
test('a drag schedules a short tracking loop; ending it stops the loop', () => {
|
||||
const { api, rafCalls } = loadCables({ reduce: false });
|
||||
api.attach(fakeRoot());
|
||||
api.setDragging(true);
|
||||
assert.ok(rafCalls.length >= 1, 'drag runs a per-frame tracking loop');
|
||||
const before = rafCalls.length;
|
||||
api.setDragging(false);
|
||||
// Run the already-scheduled frame: with dragging off it renders once and
|
||||
// does NOT reschedule.
|
||||
rafCalls[rafCalls.length - 1]();
|
||||
assert.equal(rafCalls.length, before, 'loop is not rescheduled after drag ends');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildReadySandbox() {
|
||||
const listeners = new Map();
|
||||
const sandbox = {
|
||||
window: {
|
||||
slopsmith: {
|
||||
on(event, fn) { listeners.set(event, fn); },
|
||||
off(event, fn) { if (listeners.get(event) === fn) listeners.delete(event); },
|
||||
},
|
||||
},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Promise,
|
||||
__emit(event) {
|
||||
const fn = listeners.get(event);
|
||||
if (fn) fn();
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadReadyHelper(sandbox, src) {
|
||||
const code = `
|
||||
let _audioSeekGen = 10;
|
||||
${extractFunction(src, 'function _waitForSongReady(')}
|
||||
globalThis.__waitForSongReady = _waitForSongReady;
|
||||
globalThis.__setAudioSeekGen = value => { _audioSeekGen = value; };
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildReadySandbox();
|
||||
loadReadyHelper(sandbox, src);
|
||||
|
||||
const stale = sandbox.__waitForSongReady(11, 1000);
|
||||
sandbox.__emit('song:ready');
|
||||
assert.equal(await stale, false);
|
||||
|
||||
sandbox.__setAudioSeekGen(11);
|
||||
const current = sandbox.__waitForSongReady(11, 1000);
|
||||
sandbox.__emit('song:ready');
|
||||
assert.equal(await current, true);
|
||||
});
|
||||
|
||||
test('playback adapter scopes startTime readiness and validates seek targets', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
|
||||
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
|
||||
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
|
||||
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
|
||||
assert.match(fn, /throw new Error\(`Invalid seek time:/);
|
||||
assert.match(fn, /return _audioSeek\(seconds, reason \|\| 'playback-command'\);/);
|
||||
});
|
||||
|
||||
test('playback adapter suppresses duplicate HTML5 pause events before emitting canonical pause', () => {
|
||||
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*\}/);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { loadPlayback, captureEvents, diagnosticsSnapshot, makeTarget, dispatch, makeAdapter, ROOT } = require('./playback_test_harness');
|
||||
const { loadCapabilities } = require('./capabilities_test_harness');
|
||||
|
||||
test('requester and observer registration is idempotent and visible in diagnostics', async () => {
|
||||
const window = loadPlayback();
|
||||
await dispatch(window, 'register-requester', { requesterId: 'plugin.practice', kind: 'plugin', requests: ['pause'] });
|
||||
await dispatch(window, 'register-requester', { requesterId: 'plugin.practice', kind: 'plugin', requests: ['seek'], status: 'available' });
|
||||
await dispatch(window, 'register-observer', { observerId: 'plugin.hud', observes: ['ready', 'seeked'] });
|
||||
|
||||
const participants = diagnosticsSnapshot(window).participants;
|
||||
assert.equal(participants.filter(item => item.requesterId === 'plugin.practice').length, 1);
|
||||
assert.equal(participants.filter(item => item.observerId === 'plugin.hud').length, 1);
|
||||
});
|
||||
|
||||
test('plugin fresh starts require user action and incompatible playback participants do not execute', async () => {
|
||||
const window = loadPlayback();
|
||||
const denied = await dispatch(window, 'start', { requesterId: 'plugin.remote', target: makeTarget() });
|
||||
assert.equal(denied.status, 'user-action-required');
|
||||
|
||||
const incompatibleWindow = loadCapabilities();
|
||||
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' });
|
||||
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());
|
||||
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' });
|
||||
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
const blockedResume = await dispatch(window, 'resume', { requesterId: 'plugin.b', priority: 'normal' });
|
||||
|
||||
assert.equal(firstPause.status, 'paused');
|
||||
assert.equal(normalResume.status, 'playing');
|
||||
assert.equal(blockedResume.status, 'denied');
|
||||
});
|
||||
|
||||
test('legacy bridge hits are attributed to playback compatibility shims', () => {
|
||||
const window = loadPlayback();
|
||||
const bridgeEvents = captureEvents(window, 'playback:bridge-hit');
|
||||
|
||||
window.slopsmith.playback.recordBridgeHit({
|
||||
bridgeId: 'playback.window-play-song',
|
||||
legacySurface: 'window.playSong',
|
||||
source: 'core.app',
|
||||
reason: 'legacy playSong entry point used',
|
||||
});
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
const runtime = window.slopsmith.capabilities.snapshotDiagnostics();
|
||||
const shim = runtime.compatibilityShims.find(item => item.capability === 'playback' && item.legacySurface === 'window.playSong');
|
||||
|
||||
assert.equal(bridgeEvents.length, 1);
|
||||
assert.equal(playback.bridges[0].bridgeId, 'playback.window-play-song');
|
||||
assert.equal(playback.bridges[0].hitCount, 1);
|
||||
assert.ok(shim);
|
||||
assert.equal(shim.hitCount, 1);
|
||||
});
|
||||
|
||||
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.psarc', arrangement: 0 });
|
||||
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.psarc' }));
|
||||
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
|
||||
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(playback);
|
||||
|
||||
assert.equal(playback.state.state, 'playing');
|
||||
assert.equal(playback.state.media.currentTime, 12);
|
||||
assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events'));
|
||||
assert.doesNotMatch(encoded, /Secret Folder/);
|
||||
assert.doesNotMatch(encoded, /Artist - Song_p\.psarc/);
|
||||
});
|
||||
|
||||
test('route changes are captured as redaction-safe playback lifecycle events', () => {
|
||||
const window = loadPlayback();
|
||||
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' });
|
||||
|
||||
const playback = diagnosticsSnapshot(window);
|
||||
assert.equal(changing.length, 1);
|
||||
assert.equal(changed.length, 1);
|
||||
assert.equal(playback.state.route.routeKind, 'desktop-native');
|
||||
assert.equal(playback.state.route.state, 'active');
|
||||
assert.equal(playback.state.route.preservedTime, true);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadPlayback, captureEvents, dispatch, diagnosticsSnapshot, makeTarget, makeAdapter } = require('./playback_test_harness');
|
||||
|
||||
test('exported diagnostics pseudonymize targets while local inspector may show display names', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
await dispatch(window, 'start', {
|
||||
authorization: 'user-action',
|
||||
requesterId: 'core.player.controls',
|
||||
target: makeTarget({
|
||||
filename: '/Users/example/DLC/Private Artist - Private Song_p.psarc',
|
||||
title: 'Private Song',
|
||||
artist: 'Private Artist',
|
||||
arrangement: 'Lead',
|
||||
}),
|
||||
});
|
||||
|
||||
const exported = diagnosticsSnapshot(window);
|
||||
const local = diagnosticsSnapshot(window, { exportMode: 'local-inspector' });
|
||||
const exportedText = JSON.stringify(exported);
|
||||
|
||||
assert.match(exported.state.target.targetId, /^target-/);
|
||||
assert.match(exported.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.equal(exported.state.target.localDisplay, undefined);
|
||||
assert.doesNotMatch(exportedText, /Private Song/);
|
||||
assert.doesNotMatch(exportedText, /Private Artist/);
|
||||
assert.doesNotMatch(exportedText, /DLC/);
|
||||
assert.equal(local.state.target.settingsKey, exported.state.target.settingsKey);
|
||||
assert.equal(local.state.target.localDisplay.title, 'Private Song');
|
||||
assert.equal(local.state.target.localDisplay.artist, 'Private Artist');
|
||||
});
|
||||
|
||||
test('diagnostic history is bounded for current and stopped sessions', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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}.psarc`, title: `Song ${index}` }) });
|
||||
for (let seek = 0; seek < 12; seek += 1) {
|
||||
await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek });
|
||||
}
|
||||
await dispatch(window, 'stop', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
}
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
assert.ok(snapshot.history.current.recentOutcomes.length <= 50);
|
||||
assert.ok(snapshot.history.current.lifecycleEvents.length <= 50);
|
||||
assert.ok(snapshot.history.stoppedSessions.length <= 5);
|
||||
for (const session of snapshot.history.stoppedSessions) {
|
||||
assert.ok(session.recentOutcomes.length <= 20);
|
||||
assert.ok(session.lifecycleEvents.length <= 20);
|
||||
}
|
||||
});
|
||||
|
||||
test('diagnostics contribution is exported under playback schema', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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');
|
||||
assert.equal(contribution.domain, 'playback');
|
||||
assert.equal(contribution.exportMode, 'exported');
|
||||
assert.match(contribution.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.equal(contribution.state.target.localDisplay, undefined);
|
||||
});
|
||||
|
||||
test('diagnostics redact caller-supplied route and stale session ids', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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' });
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
const stale = snapshot.history.current.recentOutcomes.find(item => item.status === 'stale');
|
||||
|
||||
assert.match(snapshot.state.route.routeId, /^route-/);
|
||||
assert.notEqual(snapshot.state.route.routeId, '/Users/example/native-route?token=secret');
|
||||
assert.match(stale.sessionId, /^playback-/);
|
||||
assert.notEqual(stale.sessionId, '/Users/example/private-session?token=secret');
|
||||
assert.doesNotMatch(encoded, /private-session|native-route|token=secret|\/Users\/example/);
|
||||
});
|
||||
|
||||
test('diagnostics redact requester ids and raw camel-case payload keys', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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', {
|
||||
requesterId: '/Users/example/transport token=secret',
|
||||
accessToken: 'plain-secret-token',
|
||||
nativeHandleRef: 'native-secret-handle',
|
||||
mediaStream: 'raw-stream-id',
|
||||
reason: '/Users/example/private song.psarc token=secret',
|
||||
safeDetail: 'safe value',
|
||||
});
|
||||
window.slopsmith.playback.recordBridgeHit({
|
||||
bridgeId: '/Users/example/bridge token=secret',
|
||||
legacySurface: 'window.playSong',
|
||||
source: '/Users/example/source token=secret',
|
||||
reason: '/Users/example/bridge path token=secret',
|
||||
nativeHandleRef: 'native-secret-handle',
|
||||
});
|
||||
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const encoded = JSON.stringify({ snapshot, degradedEvents });
|
||||
|
||||
assert.match(snapshot.state.transport.requesterId, /path/);
|
||||
assert.doesNotMatch(encoded, /plain-secret-token|native-secret-handle|raw-stream-id/);
|
||||
assert.doesNotMatch(encoded, /private song|bridge path|\/Users\/example|token=secret|source-token-secret|bridge-token-secret/);
|
||||
assert.match(encoded, /safe value/);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadPlayback, captureEvents, dispatch, makeTarget, makeAdapter, diagnosticsSnapshot } = require('./playback_test_harness');
|
||||
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' });
|
||||
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' });
|
||||
assert.equal(noHandler.status, 'no-handler');
|
||||
|
||||
const unsupportedWindow = loadPlayback();
|
||||
const unsupported = await dispatch(unsupportedWindow, 'teleport', { requesterId: 'test' });
|
||||
assert.equal(unsupported.status, 'unsupported-command');
|
||||
});
|
||||
|
||||
test('playback registers as an active core owner', async () => {
|
||||
const window = loadPlayback();
|
||||
const snapshot = window.slopsmith.capabilities.snapshotDiagnostics();
|
||||
const playback = snapshot.pipelines.find(pipeline => pipeline.name === 'playback');
|
||||
|
||||
assert.ok(playback, 'playback pipeline exists');
|
||||
assert.equal(playback.review.lifecycle, 'active');
|
||||
assert.ok(playback.participants.some(participant => participant.pluginId === 'core.playback' && participant.roles.includes('owner')));
|
||||
assert.ok(playback.participants[0].commands.includes('start'));
|
||||
assert.ok(playback.participants[0].events.includes('seeked'));
|
||||
});
|
||||
|
||||
test('start requires a target and explicit user authorization for fresh audible playback', async () => {
|
||||
const window = loadPlayback();
|
||||
|
||||
const missing = await dispatch(window, 'start', { requesterId: 'plugin.practice', authorization: 'user-action' });
|
||||
assert.equal(missing.status, 'no-target');
|
||||
|
||||
const noGesture = await dispatch(window, 'start', { requesterId: 'plugin.practice', target: makeTarget() });
|
||||
assert.equal(noGesture.status, 'user-action-required');
|
||||
|
||||
const adapter = makeAdapter();
|
||||
window.slopsmith.playback.registerTransportAdapter(adapter);
|
||||
const events = captureEvents(window, 'playback:ready');
|
||||
const result = await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: makeTarget() });
|
||||
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.ok(adapter.calls.some(call => call[0] === 'start'));
|
||||
assert.equal(events.length, 1);
|
||||
const state = diagnosticsSnapshot(window).state;
|
||||
assert.equal(state.state, 'ready');
|
||||
assert.equal(state.target.targetId.startsWith('target-'), true);
|
||||
assert.match(state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.equal(events[0].payload.target.settingsKey, state.target.settingsKey);
|
||||
});
|
||||
|
||||
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.psarc', arrangement: 'Lead', arrangementIndex: 0 });
|
||||
|
||||
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
|
||||
const leadTarget = diagnosticsSnapshot(window).state.target;
|
||||
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: { ...base, arrangement: 'Bass', arrangementIndex: 1 } });
|
||||
const bassTarget = diagnosticsSnapshot(window).state.target;
|
||||
|
||||
assert.match(leadTarget.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.equal(bassTarget.settingsKey, leadTarget.settingsKey);
|
||||
assert.notEqual(bassTarget.targetId, leadTarget.targetId);
|
||||
});
|
||||
|
||||
test('unsafe caller-supplied settings keys are hashed before exposure', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
await dispatch(window, 'start', {
|
||||
requesterId: 'core.player.controls',
|
||||
authorization: 'user-action',
|
||||
target: makeTarget({ settingsKey: 'settings-private-song-name' }),
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.match(diagnosticsSnapshot(window).state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.doesNotMatch(encoded, /private-song-name/);
|
||||
});
|
||||
|
||||
test('unsafe caller-supplied target ids are hashed before exposure', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
await dispatch(window, 'start', {
|
||||
requesterId: 'core.player.controls',
|
||||
authorization: 'user-action',
|
||||
target: makeTarget({ targetId: 'target-private-song-name' }),
|
||||
});
|
||||
const encoded = JSON.stringify(diagnosticsSnapshot(window));
|
||||
|
||||
assert.match(diagnosticsSnapshot(window).state.target.targetId, /^target-[a-z0-9]+$/);
|
||||
assert.doesNotMatch(encoded, /private-song-name/);
|
||||
});
|
||||
|
||||
test('dispatch requester owns playback command attribution', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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');
|
||||
const snapshot = diagnosticsSnapshot(window);
|
||||
const outcomeIds = snapshot.history.current.recentOutcomes.map(item => item.requesterId).join(',');
|
||||
|
||||
assert.equal(started.status, 'ready');
|
||||
assert.equal(paused.status, 'paused');
|
||||
assert.equal(snapshot.state.transport.requesterId, 'plugin.remote');
|
||||
assert.equal(outcomeIds.includes('core.player.controls'), false);
|
||||
assert.equal(outcomeIds.includes('plugin.remote'), true);
|
||||
});
|
||||
|
||||
test('transport commands emit ordered lifecycle events and normalize outcomes', async () => {
|
||||
const window = loadPlayback();
|
||||
const adapter = makeAdapter({ duration: 10 });
|
||||
window.slopsmith.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:', '')));
|
||||
}
|
||||
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget() });
|
||||
await dispatch(window, 'pause', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
await dispatch(window, 'resume', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
const seek = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 999 });
|
||||
const loop = await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 2, endTime: 4 });
|
||||
const cleared = await dispatch(window, 'clear-loop', { requesterId: 'core.player.controls' });
|
||||
|
||||
assert.deepEqual(events.slice(0, 3), ['requested', 'loading', 'ready']);
|
||||
assert.ok(events.indexOf('seeking') < events.indexOf('seeked'));
|
||||
assert.equal(seek.status, 'clamped');
|
||||
assert.equal(seek.payload.landedTime, 10);
|
||||
assert.equal(loop.status, 'active');
|
||||
assert.equal(cleared.status, 'cleared');
|
||||
});
|
||||
|
||||
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 } }));
|
||||
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 });
|
||||
assert.equal(diagnosticsSnapshot(window).state.state, 'seeking');
|
||||
window.slopsmith.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());
|
||||
|
||||
const cleared = await dispatch(window, 'clear-loop', { requesterId: 'core.player.controls' });
|
||||
assert.equal(cleared.status, 'no-target');
|
||||
});
|
||||
|
||||
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 } }));
|
||||
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' }));
|
||||
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');
|
||||
assert.equal(failed.outcome, 'failed');
|
||||
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 }) });
|
||||
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 } }));
|
||||
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 });
|
||||
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());
|
||||
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 });
|
||||
|
||||
const rejected = await dispatch(window, 'set-loop', { requesterId: 'core.player.controls', startTime: 8, endTime: 4 });
|
||||
const loop = diagnosticsSnapshot(window).state.loop;
|
||||
|
||||
assert.equal(rejected.status, 'rejected');
|
||||
assert.equal(loop.state, 'active');
|
||||
assert.equal(loop.startTime, 2);
|
||||
assert.equal(loop.endTime, 4);
|
||||
});
|
||||
|
||||
test('normal resume is denied after a user-priority pause until a user action resumes', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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' });
|
||||
|
||||
const blocked = await dispatch(window, 'resume', { requesterId: 'plugin.remote', priority: 'normal' });
|
||||
assert.equal(blocked.status, 'denied');
|
||||
|
||||
const accepted = await dispatch(window, 'resume', { requesterId: 'core.player.controls', priority: 'user' });
|
||||
assert.equal(accepted.status, 'playing');
|
||||
});
|
||||
|
||||
test('stale and cancelled operations are reported distinctly', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.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;
|
||||
|
||||
const stale = await dispatch(window, 'pause', { requesterId: 'plugin.remote', sessionId: `${sessionId}-old` });
|
||||
const cancelled = await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: 8 });
|
||||
|
||||
assert.equal(stale.status, 'stale');
|
||||
assert.equal(cancelled.status, 'cancelled');
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const PLAYBACK_JS = path.join(ROOT, 'static', 'capabilities', 'playback.js');
|
||||
const INSPECTOR_JS = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.js');
|
||||
|
||||
function loadPlayback(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(PLAYBACK_JS, 'utf8'), context, { filename: PLAYBACK_JS });
|
||||
window.__vmContext = context;
|
||||
return window;
|
||||
}
|
||||
|
||||
function runBrowserScript(window, filePath) {
|
||||
vm.runInContext(fs.readFileSync(filePath, 'utf8'), window.__vmContext, { filename: filePath });
|
||||
}
|
||||
|
||||
function loadInspector(window) {
|
||||
runBrowserScript(window, INSPECTOR_JS);
|
||||
return window;
|
||||
}
|
||||
|
||||
function captureEvents(window, eventName) {
|
||||
const events = [];
|
||||
window.slopsmith.on(eventName, event => events.push(event.detail));
|
||||
return events;
|
||||
}
|
||||
|
||||
function diagnosticsSnapshot(window, options = {}) {
|
||||
return window.slopsmith.playback.snapshot(options);
|
||||
}
|
||||
|
||||
function dispatch(window, command, payload = {}, requester = 'test') {
|
||||
return window.slopsmith.capabilities.dispatch({ capability: 'playback', command, args: payload, requester });
|
||||
}
|
||||
|
||||
function makeTarget(overrides = {}) {
|
||||
return {
|
||||
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.psarc',
|
||||
title: overrides.title || 'Visible Title',
|
||||
artist: overrides.artist || 'Visible Artist',
|
||||
arrangement: overrides.arrangement || 'Lead',
|
||||
arrangementIndex: overrides.arrangementIndex ?? 0,
|
||||
format: overrides.format || 'psarc',
|
||||
sourceKind: overrides.sourceKind || 'local',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(overrides = {}) {
|
||||
const calls = [];
|
||||
let currentTime = overrides.currentTime ?? 0;
|
||||
let duration = overrides.duration ?? 120;
|
||||
let isPlaying = false;
|
||||
let loop = { enabled: false, state: 'inactive' };
|
||||
let route = { routeKind: 'browser-media', state: 'active', preservedTime: true };
|
||||
const adapter = {
|
||||
calls,
|
||||
inspect() {
|
||||
calls.push(['inspect']);
|
||||
return overrides.inspectResult || { currentTime, mediaTime: currentTime, chartTime: currentTime, duration, playbackRate: 1, isPlaying, route, loop };
|
||||
},
|
||||
async start(request) {
|
||||
calls.push(['start', request]);
|
||||
isPlaying = !!overrides.startPlaying;
|
||||
if (overrides.startError) throw new Error(overrides.startError);
|
||||
return overrides.startResult || { currentTime, duration, isPlaying, route, loop };
|
||||
},
|
||||
async pause(request) {
|
||||
calls.push(['pause', request]);
|
||||
isPlaying = false;
|
||||
if (overrides.pauseError) throw new Error(overrides.pauseError);
|
||||
return overrides.pauseResult || { currentTime, duration, isPlaying, route, loop };
|
||||
},
|
||||
async resume(request) {
|
||||
calls.push(['resume', request]);
|
||||
if (overrides.resumeUnavailable) return { unavailable: true, reason: 'unavailable' };
|
||||
if (overrides.resumeError) throw new Error(overrides.resumeError);
|
||||
isPlaying = true;
|
||||
return overrides.resumeResult || { currentTime, duration, isPlaying, route, loop };
|
||||
},
|
||||
async stop(request) {
|
||||
calls.push(['stop', request]);
|
||||
isPlaying = false;
|
||||
if (overrides.stopError) throw new Error(overrides.stopError);
|
||||
return overrides.stopResult || { currentTime, duration, isPlaying, route, loop };
|
||||
},
|
||||
async seek(request) {
|
||||
calls.push(['seek', request]);
|
||||
if (overrides.seekError) throw new Error(overrides.seekError);
|
||||
if (overrides.seekResult) {
|
||||
currentTime = overrides.seekResult.to ?? overrides.seekResult.landedTime ?? currentTime;
|
||||
return overrides.seekResult;
|
||||
}
|
||||
const to = Math.max(0, Math.min(duration, Number(request.time)));
|
||||
const from = currentTime;
|
||||
currentTime = to;
|
||||
return { completed: true, from, to };
|
||||
},
|
||||
async setLoop(request) {
|
||||
calls.push(['setLoop', request]);
|
||||
if (overrides.setLoopError) throw new Error(overrides.setLoopError);
|
||||
if (overrides.setLoopResult !== undefined) return overrides.setLoopResult;
|
||||
loop = { startTime: request.startTime, endTime: request.endTime, enabled: true, state: 'active' };
|
||||
return true;
|
||||
},
|
||||
async clearLoop(request) {
|
||||
calls.push(['clearLoop', request]);
|
||||
loop = { enabled: false, state: 'cleared' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function installInspectorDom(window) {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
const item = {
|
||||
id,
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
className: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelectorAll() { return []; },
|
||||
querySelector() { return null; },
|
||||
closest() { return null; },
|
||||
getBoundingClientRect() { return { width: 1000, height: 400, left: 0, top: 0 }; },
|
||||
appendChild(child) { return child; },
|
||||
};
|
||||
elements.set(id, item);
|
||||
return item;
|
||||
}
|
||||
element('capability-inspector-filter');
|
||||
element('capability-inspector-content');
|
||||
element('capability-inspector-empty');
|
||||
element('capability-inspector-summary');
|
||||
window.document.getElementById = id => elements.get(id) || null;
|
||||
window.document.querySelectorAll = () => [];
|
||||
window.document.addEventListener = () => {};
|
||||
window.document.createElement = () => element(`created-${elements.size}`);
|
||||
window.requestAnimationFrame = callback => callback();
|
||||
return elements;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROOT,
|
||||
loadPlayback,
|
||||
loadInspector,
|
||||
captureEvents,
|
||||
diagnosticsSnapshot,
|
||||
dispatch,
|
||||
makeTarget,
|
||||
makeAdapter,
|
||||
installInspectorDom,
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
|
||||
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
||||
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
||||
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
||||
// nested object/template literal can't make a naive regex stop early.
|
||||
function extractConstArrow(src, name) {
|
||||
const sig = `const ${name} = `;
|
||||
const start = src.indexOf(sig);
|
||||
assert.ok(start !== -1, `const arrow '${name}' not found`);
|
||||
const openBrace = src.indexOf('{', src.indexOf('=>', start));
|
||||
assert.ok(openBrace !== -1, `arrow body for '${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 '${name}'`);
|
||||
// include the trailing `;`
|
||||
return src.slice(start, src.indexOf(';', i) + 1);
|
||||
}
|
||||
|
||||
// Fresh sandbox per test so head state and the loadedStyles Map can't leak
|
||||
// across cases. Returns the hoisted _injectPluginStyles plus the head array.
|
||||
function setupSandbox() {
|
||||
const headLinks = [];
|
||||
const makeLink = () => ({
|
||||
dataset: {},
|
||||
rel: '',
|
||||
href: '',
|
||||
remove() {
|
||||
const idx = headLinks.indexOf(this);
|
||||
if (idx >= 0) headLinks.splice(idx, 1);
|
||||
},
|
||||
});
|
||||
// Seed a stand-in for core's prebuilt <link href="/static/tailwind.min.css">
|
||||
// so the ordering test can assert plugin sheets are inserted before it.
|
||||
const seedCore = () => {
|
||||
const core = makeLink();
|
||||
core.rel = 'stylesheet';
|
||||
core.href = '/static/tailwind.min.css';
|
||||
headLinks.push(core);
|
||||
return core;
|
||||
};
|
||||
// Minimal but faithful <head>: appendChild pushes to the end, insertBefore
|
||||
// splices before the reference node, and querySelector resolves the two
|
||||
// anchor selectors _injectPluginStyles uses to find core's stylesheet
|
||||
// (the tailwind-specific one, then any stylesheet as a fallback).
|
||||
const head = {
|
||||
appendChild: (node) => { headLinks.push(node); },
|
||||
insertBefore: (node, ref) => {
|
||||
const i = ref ? headLinks.indexOf(ref) : -1;
|
||||
if (i >= 0) headLinks.splice(i, 0, node);
|
||||
else headLinks.push(node);
|
||||
},
|
||||
querySelector: (sel) => {
|
||||
if (sel.includes('tailwind.min.css')) {
|
||||
return headLinks.find((l) => (l.href || '').includes('tailwind.min.css')) || null;
|
||||
}
|
||||
return headLinks.find((l) => l.rel === 'stylesheet') || null;
|
||||
},
|
||||
};
|
||||
const sandbox = {
|
||||
console: { warn() {} },
|
||||
encodeURIComponent,
|
||||
document: {
|
||||
head,
|
||||
createElement: () => makeLink(),
|
||||
// Production only ever queries 'link[data-plugin-id]'; return a
|
||||
// copy so a remove() splice during forEach is safe.
|
||||
querySelectorAll: () => headLinks.slice(),
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
||||
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
||||
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
||||
// One script so all share a lexical scope (the arrows close over
|
||||
// loadedStyles + _removePluginStyleTags), then hoist for the test to call.
|
||||
vm.runInContext(
|
||||
`const loadedStyles = new Map();\n${removeSrc}\n${injectSrc}\n${reconcileSrc}\n` +
|
||||
`globalThis.__inject = _injectPluginStyles;\n` +
|
||||
`globalThis.__reconcile = _reconcilePluginStyles;\n` +
|
||||
`globalThis.__head = () => null;`,
|
||||
sandbox,
|
||||
);
|
||||
return { inject: sandbox.__inject, reconcile: sandbox.__reconcile, headLinks, seedCore };
|
||||
}
|
||||
|
||||
const plug = (over = {}) => ({
|
||||
id: 'demo', version: '1', has_styles: true, styles: 'assets/plugin.css', ...over,
|
||||
});
|
||||
|
||||
test('injects exactly one <link> for a plugin with styles', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug());
|
||||
assert.equal(headLinks.length, 1);
|
||||
const link = headLinks[0];
|
||||
assert.equal(link.rel, 'stylesheet');
|
||||
assert.equal(link.dataset.pluginId, 'demo');
|
||||
assert.equal(link.dataset.pluginVersion, '1');
|
||||
// Root-relative styles → routes through /api/plugins/{id}/assets/... (no
|
||||
// doubled "assets/"), with the version as a cache-busting query.
|
||||
assert.equal(link.href, '/api/plugins/demo/assets/plugin.css?v=1');
|
||||
});
|
||||
|
||||
test('inserts the plugin <link> before core tailwind.min.css so core wins equal-specificity collisions', () => {
|
||||
const { inject, headLinks, seedCore } = setupSandbox();
|
||||
const core = seedCore();
|
||||
inject(plug());
|
||||
assert.equal(headLinks.length, 2, 'core link plus the plugin link');
|
||||
const pluginIdx = headLinks.findIndex((l) => l.dataset.pluginId === 'demo');
|
||||
const coreIdx = headLinks.indexOf(core);
|
||||
assert.ok(pluginIdx >= 0, 'plugin <link> was injected');
|
||||
assert.ok(pluginIdx < coreIdx, 'plugin <link> must precede core tailwind.min.css');
|
||||
});
|
||||
|
||||
test('falls back to appendChild when no stylesheet <link> anchor exists in <head>', () => {
|
||||
// With an empty <head>, both anchor queries (tailwind-specific, then any
|
||||
// stylesheet) miss, so coreSheet is null and the link is appended.
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug());
|
||||
assert.equal(headLinks.length, 1, 'still injects when there is no anchor to insert before');
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'demo');
|
||||
});
|
||||
|
||||
test('is idempotent — re-activation does not duplicate the tag', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug());
|
||||
inject(plug());
|
||||
inject(plug());
|
||||
assert.equal(headLinks.length, 1);
|
||||
});
|
||||
|
||||
test('swaps the <link> on a version upgrade (no stale duplicates)', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug({ version: '1' }));
|
||||
inject(plug({ version: '2' }));
|
||||
assert.equal(headLinks.length, 1, 'old version <link> must be removed');
|
||||
assert.equal(headLinks[0].dataset.pluginVersion, '2');
|
||||
assert.equal(headLinks[0].href, '/api/plugins/demo/assets/plugin.css?v=2');
|
||||
});
|
||||
|
||||
test('injects nothing for a plugin without styles (regression)', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject({ id: 'plain', version: '1', has_styles: false });
|
||||
inject({ id: 'plain2', version: '1' }); // has_styles undefined
|
||||
assert.equal(headLinks.length, 0);
|
||||
});
|
||||
|
||||
test('skips an unsafe styles path (not under assets/, traversal, backslash, query/fragment)', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
const bad = [
|
||||
'plugin.css', // not under assets/
|
||||
'../routes.py', // not under assets/, traversal
|
||||
'assets/../routes.py', // starts with assets/ but escapes via ..
|
||||
'assets/..', // trailing traversal
|
||||
'assets\\plugin.css', // backslash
|
||||
'assets/plugin.css?x=1', // query char would collide with our ?v=
|
||||
'assets/plugin.css#frag',// fragment
|
||||
];
|
||||
bad.forEach((styles, i) => inject(plug({ id: `bad${i}`, styles })));
|
||||
assert.equal(headLinks.length, 0, 'no unsafe path should inject a <link>');
|
||||
});
|
||||
|
||||
test('removes the stale <link> when a plugin upgrade drops styles', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug({ version: '1' }));
|
||||
assert.equal(headLinks.length, 1);
|
||||
// Same plugin re-processed after an in-session upgrade that no longer
|
||||
// declares styles — the old stylesheet must be torn down, not left active.
|
||||
inject({ id: 'demo', version: '2', has_styles: false });
|
||||
assert.equal(headLinks.length, 0, 'stale stylesheet must be removed when styles disappear');
|
||||
});
|
||||
|
||||
test('removes the stale <link> when a plugin upgrade points styles outside assets/', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug({ version: '1' }));
|
||||
assert.equal(headLinks.length, 1);
|
||||
// Upgrade to an unusable path → the prior valid <link> is torn down rather
|
||||
// than left applying stale CSS.
|
||||
inject(plug({ version: '2', styles: '../routes.py' }));
|
||||
assert.equal(headLinks.length, 0);
|
||||
});
|
||||
|
||||
test('does not collide tags across two different plugins', () => {
|
||||
const { inject, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
reconcile([plug({ id: 'a', status: 'installing' })]);
|
||||
assert.equal(headLinks.length, 0);
|
||||
});
|
||||
|
||||
test('reconcile keeps a still-ready, still-styled plugin', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
reconcile([plug({ id: 'a' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// Verify the v3 Pedalboard Plugins page (static/v3/plugins-page.js) pure
|
||||
// helpers: category resolution (manifest override > curated > derive > other),
|
||||
// thumbnail URL building, click-target selection (settings vs screen vs none),
|
||||
// drag clamping, default-flow slots, and the localStorage layout round-trip.
|
||||
//
|
||||
// The page exposes these helpers on window.v3PluginsPage._test, so we load the
|
||||
// IIFE in a vm sandbox with a minimal window/document and read them back —
|
||||
// no brace-extraction needed.
|
||||
|
||||
const { test } = require('node:test');
|
||||
// Non-strict assert on purpose: helpers return objects created inside the vm
|
||||
// realm, whose Object.prototype differs from this realm's — strict deepEqual
|
||||
// would fail the prototype check even when the structure matches.
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = path.join(__dirname, '..', '..', 'static', 'v3', 'plugins-page.js');
|
||||
|
||||
function loadPage(opts) {
|
||||
opts = opts || {};
|
||||
const store = opts.store || {};
|
||||
const win = { slopsmith: 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); },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
};
|
||||
win.addEventListener = () => {};
|
||||
win.matchMedia = () => ({ matches: false });
|
||||
const doc = {
|
||||
readyState: 'complete',
|
||||
getElementById: () => null, // render() bails: no #v3-plugins
|
||||
querySelector: () => null,
|
||||
addEventListener: () => {},
|
||||
};
|
||||
const ctx = {
|
||||
window: win, document: doc, console,
|
||||
fetch: () => Promise.reject(new Error('no fetch in test')),
|
||||
requestAnimationFrame: () => 0,
|
||||
setTimeout: () => 0, clearTimeout: () => {},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: 'plugins-page.js' });
|
||||
return { api: win.v3PluginsPage, t: win.v3PluginsPage._test, store, win };
|
||||
}
|
||||
|
||||
test('categoryOf: manifest category is authoritative (lowercased)', () => {
|
||||
const { t } = loadPage();
|
||||
assert.equal(t.categoryOf({ id: 'whatever', category: 'Audio' }), 'audio');
|
||||
// Even an unknown manifest category wins over the curated map.
|
||||
assert.equal(t.categoryOf({ id: 'nam_tone', category: 'Custom' }), 'custom');
|
||||
});
|
||||
|
||||
test('categoryOf: curated map, then type-derive, then other', () => {
|
||||
const { t } = loadPage();
|
||||
assert.equal(t.categoryOf({ id: 'nam_tone' }), 'audio');
|
||||
assert.equal(t.categoryOf({ id: 'flappy_bend' }), 'game');
|
||||
assert.equal(t.categoryOf({ id: 'editor' }), 'tools');
|
||||
assert.equal(t.categoryOf({ id: 'unknown_x', type: 'visualization' }), 'creation');
|
||||
assert.equal(t.categoryOf({ id: 'unknown_x' }), 'other');
|
||||
assert.equal(t.categoryOf(null), 'other');
|
||||
});
|
||||
|
||||
test('thumbUrl: manifest icon routes through the asset endpoint; else default', () => {
|
||||
const { t } = loadPage();
|
||||
assert.equal(t.thumbUrl({ id: 'flappy_bend', icon: 'assets/thumb.png' }),
|
||||
'/api/plugins/flappy_bend/assets/thumb.png');
|
||||
// Leading assets/ is stripped (route path is relative to assets/).
|
||||
assert.equal(t.thumbUrl({ id: 'x', icon: 'assets/img/p.svg' }), '/api/plugins/x/assets/img/p.svg');
|
||||
assert.equal(t.thumbUrl({ id: 'x' }), '/static/v3/pedal-default.svg');
|
||||
assert.equal(t.thumbUrl({ id: 'x', icon: '' }), '/static/v3/pedal-default.svg');
|
||||
});
|
||||
|
||||
test('settingsTarget: settings > screen > none', () => {
|
||||
const { t } = loadPage();
|
||||
assert.deepEqual(t.settingsTarget({ id: 'a', has_settings: true, nav: true }), { kind: 'settings', 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' });
|
||||
assert.deepEqual(t.settingsTarget({ id: 'd' }), { kind: 'none', id: 'd' });
|
||||
});
|
||||
|
||||
test('clampToBoard: x clamped to [0, boardW-pedalW]; y floored at 0', () => {
|
||||
const { t } = loadPage();
|
||||
assert.deepEqual(t.clampToBoard({ x: 999, y: 50 }, 800, 150), { x: 650, y: 50 });
|
||||
assert.deepEqual(t.clampToBoard({ x: -40, y: -10 }, 800, 150), { x: 0, y: 0 });
|
||||
// Board narrower than a pedal → maxX floors at 0.
|
||||
assert.deepEqual(t.clampToBoard({ x: 30, y: 5 }, 100, 150), { x: 0, y: 5 });
|
||||
});
|
||||
|
||||
test('defaultSlot: padding origin; column count adapts to board width', () => {
|
||||
const { t } = loadPage();
|
||||
const s0 = t.defaultSlot(0, 1200);
|
||||
assert.equal(s0.x, 24);
|
||||
assert.equal(s0.y, 24);
|
||||
// 2nd pedal is to the right on the same row (≥2 columns at this width).
|
||||
const s1 = t.defaultSlot(1, 1200);
|
||||
assert.ok(s1.x > s0.x);
|
||||
assert.equal(s1.y, s0.y);
|
||||
// Number of columns = index where the row first wraps. Wider board → more.
|
||||
const cols = (bw) => { const y0 = t.defaultSlot(0, bw).y; let i = 1; while (i < 50 && t.defaultSlot(i, bw).y === y0) i++; return i; };
|
||||
assert.ok(cols(2200) > cols(700), 'more columns on a wider board');
|
||||
assert.ok(cols(700) >= 1);
|
||||
});
|
||||
|
||||
test('loadLayout/saveLayout: round-trip + corruption tolerance', () => {
|
||||
const { t, store } = loadPage();
|
||||
assert.deepEqual(t.loadLayout(), {});
|
||||
t.saveLayout({ audio: { nam_tone: { x: 10, y: 20 } } });
|
||||
assert.deepEqual(t.loadLayout(), { audio: { nam_tone: { x: 10, y: 20 } } });
|
||||
assert.ok(store[t.LS_KEY]);
|
||||
// Corrupt JSON → empty object, no throw.
|
||||
store[t.LS_KEY] = '{not json';
|
||||
assert.deepEqual(t.loadLayout(), {});
|
||||
});
|
||||
|
||||
test('saveLayout: swallows storage exceptions (quota / private mode)', () => {
|
||||
const { t } = loadPage({ throwOnSet: true });
|
||||
assert.doesNotThrow(() => t.saveLayout({ a: 1 }));
|
||||
});
|
||||
|
||||
test('frameFor: assigns a pool skin, persists it, and sticks across calls', () => {
|
||||
const { t } = loadPage();
|
||||
const frames = {};
|
||||
const f1 = t.frameFor('alpha', frames);
|
||||
assert.ok(t.PEDAL_FRAMES.includes(f1), 'assigned skin is from the pool');
|
||||
assert.equal(frames.alpha, f1, 'assignment recorded for persistence');
|
||||
assert.equal(t.frameFor('alpha', frames), f1, 'same plugin keeps the same skin');
|
||||
// A stale/invalid saved skin (e.g. removed from the pool) is re-picked.
|
||||
frames.beta = 'gone.png';
|
||||
const f3 = t.frameFor('beta', frames);
|
||||
assert.ok(t.PEDAL_FRAMES.includes(f3), 're-picked a valid skin');
|
||||
assert.ok(t.frameUrl('pedal-002.png').startsWith('/static/v3/pedals/pedal-002.png'));
|
||||
});
|
||||
|
||||
test('loadCollapsed/saveCollapsed: round-trip + corruption tolerance', () => {
|
||||
const { t, store } = loadPage();
|
||||
assert.deepEqual(t.loadCollapsed(), {});
|
||||
t.saveCollapsed({ audio: true });
|
||||
assert.deepEqual(t.loadCollapsed(), { audio: true });
|
||||
store[t.COLLAPSE_KEY] = '{bad';
|
||||
assert.deepEqual(t.loadCollapsed(), {});
|
||||
});
|
||||
|
||||
test('DRAG_THRESHOLD is a small positive pixel budget', () => {
|
||||
const { t } = loadPage();
|
||||
assert.equal(typeof t.DRAG_THRESHOLD, 'number');
|
||||
assert.ok(t.DRAG_THRESHOLD > 0 && t.DRAG_THRESHOLD < 20);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
// Lightweight contract tests for v3 Progress calibration retry/success modals.
|
||||
|
||||
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 PROGRESS_JS = path.join(ROOT, 'static', 'v3', 'progress.js');
|
||||
|
||||
function createProgressWindow(progressionState) {
|
||||
class CustomEvent {
|
||||
constructor(type, init = {}) {
|
||||
this.type = type;
|
||||
this.detail = init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Map();
|
||||
const byId = new Map();
|
||||
|
||||
function wireElement(el) {
|
||||
if (el.id) byId.set(el.id, el);
|
||||
el.querySelector = (sel) => {
|
||||
const m = sel.match(/^\[data-([^\]]+)\]$/);
|
||||
if (!m) return null;
|
||||
const attr = `data-${m[1]}`;
|
||||
const walk = (node) => {
|
||||
if (node.getAttribute && node.getAttribute(attr) != null) return node;
|
||||
for (const child of (node.children || [])) {
|
||||
const hit = walk(child);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(el);
|
||||
};
|
||||
el.querySelectorAll = (sel) => {
|
||||
const one = el.querySelector(sel);
|
||||
return one ? [one] : [];
|
||||
};
|
||||
el.addEventListener = (type, handler) => {
|
||||
const list = el.__handlers || (el.__handlers = {});
|
||||
(list[type] || (list[type] = [])).push(handler);
|
||||
};
|
||||
el.remove = () => {
|
||||
if (el.id) byId.delete(el.id);
|
||||
const idx = bodyChildren.indexOf(el);
|
||||
if (idx !== -1) bodyChildren.splice(idx, 1);
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const bodyChildren = [];
|
||||
const body = wireElement({
|
||||
id: '',
|
||||
tagName: 'BODY',
|
||||
children: bodyChildren,
|
||||
appendChild(child) {
|
||||
wireElement(child);
|
||||
bodyChildren.push(child);
|
||||
child.parentNode = body;
|
||||
return child;
|
||||
},
|
||||
});
|
||||
|
||||
const window = {
|
||||
console,
|
||||
CustomEvent,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
performance: { now: () => Date.now() },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
body,
|
||||
getElementById(id) { return byId.get(id) || null; },
|
||||
createElement(tag) {
|
||||
const el = {
|
||||
id: '',
|
||||
tagName: String(tag || '').toUpperCase(),
|
||||
children: [],
|
||||
_html: '',
|
||||
className: '',
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() { return el._html; },
|
||||
set(html) {
|
||||
el._html = html;
|
||||
el.children.length = 0;
|
||||
const btnRe = /<button[^>]*data-([^=\s]+)[^>]*>/g;
|
||||
let m;
|
||||
while ((m = btnRe.exec(html))) {
|
||||
const attr = m[1];
|
||||
const btn = wireElement({
|
||||
tagName: 'BUTTON',
|
||||
getAttribute(name) {
|
||||
if (name === `data-${attr}`) return '';
|
||||
return null;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
const list = this.__handlers || (this.__handlers = {});
|
||||
(list[type] || (list[type] = [])).push(handler);
|
||||
},
|
||||
click() {
|
||||
for (const h of (this.__handlers || {}).click || []) h();
|
||||
},
|
||||
disabled: false,
|
||||
});
|
||||
el.children.push(btn);
|
||||
}
|
||||
},
|
||||
appendChild(child) {
|
||||
wireElement(child);
|
||||
el.children.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
return wireElement(el);
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
list.push(handler);
|
||||
listeners.set(type, list);
|
||||
},
|
||||
},
|
||||
v3Progression: {
|
||||
get() { return progressionState; },
|
||||
refresh() { return Promise.resolve(progressionState); },
|
||||
},
|
||||
playSong() {},
|
||||
slopsmith: {
|
||||
on(type, handler) {
|
||||
const list = listeners.get(type) || [];
|
||||
list.push(handler);
|
||||
listeners.set(type, list);
|
||||
},
|
||||
emit(type, detail) {
|
||||
for (const handler of (listeners.get(type) || []).slice()) {
|
||||
handler({ detail });
|
||||
}
|
||||
},
|
||||
},
|
||||
showScreen() {},
|
||||
__listeners: listeners,
|
||||
__byId: byId,
|
||||
__bodyChildren: bodyChildren,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
|
||||
const progressRoot = window.document.createElement('div');
|
||||
progressRoot.id = 'v3-progress';
|
||||
window.document.body.appendChild(progressRoot);
|
||||
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(PROGRESS_JS, 'utf8'), context, { filename: PROGRESS_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('progression:calibration-attempt with 0.92 shows retry overlay and So close!', () => {
|
||||
const win = createProgressWindow({
|
||||
mastery_rank: 0,
|
||||
onboarding: {
|
||||
calibration_status: 'pending',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-attempt', { accuracy: 0.92 });
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-retry');
|
||||
assert.ok(overlay, 'retry overlay should exist');
|
||||
assert.match(overlay.innerHTML, /So close!/);
|
||||
assert.match(overlay.innerHTML, /92%/);
|
||||
assert.equal(win.document.getElementById('v3-calibration-success'), null);
|
||||
});
|
||||
|
||||
test('progression:calibration-completed shows success overlay and Setup verified!', () => {
|
||||
const win = createProgressWindow({
|
||||
mastery_rank: 0,
|
||||
onboarding: {
|
||||
calibration_status: 'pending',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-success');
|
||||
assert.ok(overlay, 'success overlay should exist');
|
||||
assert.match(overlay.innerHTML, /Setup verified!/);
|
||||
assert.match(overlay.innerHTML, /Mastery Rank 1 is ready/);
|
||||
assert.equal(win.document.getElementById('v3-calibration-retry'), null);
|
||||
});
|
||||
|
||||
test('success overlay for skipped state does not claim rank-up', () => {
|
||||
const win = createProgressWindow({
|
||||
mastery_rank: 1,
|
||||
onboarding: {
|
||||
calibration_status: 'skipped',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlay = win.document.getElementById('v3-calibration-success');
|
||||
assert.ok(overlay);
|
||||
assert.match(overlay.innerHTML, /Your input and note detection setup is verified/);
|
||||
assert.doesNotMatch(overlay.innerHTML, /Mastery Rank 1/);
|
||||
assert.match(overlay.innerHTML, /Play again/);
|
||||
});
|
||||
|
||||
test('calibration-completed does not stack duplicate success overlays', () => {
|
||||
const win = createProgressWindow({
|
||||
mastery_rank: 1,
|
||||
onboarding: {
|
||||
calibration_status: 'skipped',
|
||||
diagnostic_filename: 'diagnostics-builtin/slopsmith-diagnostic-basic-guitar.sloppak',
|
||||
},
|
||||
paths: [],
|
||||
wallet: { balance: 0, lifetime_db: 0 },
|
||||
quests: {},
|
||||
});
|
||||
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
win.slopsmith.emit('progression:calibration-completed', {});
|
||||
|
||||
const overlays = win.__bodyChildren.filter((el) => el.id === 'v3-calibration-success');
|
||||
assert.equal(overlays.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// Verify the Settings-dropdown autosave path in static/app.js:
|
||||
// persistSetting() must funnel one-field POSTs through a single chain so
|
||||
// they hit the server one at a time, in call order, and a failed save
|
||||
// must not poison the chain for later saves.
|
||||
//
|
||||
// Same isolation strategy as loop_api.test.js — extract the relevant
|
||||
// functions by brace-matching and run them in a vm sandbox with a
|
||||
// controllable fetch stub.
|
||||
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
// Drain enough microtask hops for the promise chain (persistSetting →
|
||||
// _settingSaveChain.then → _postSetting → await fetch → await resp.json)
|
||||
// to settle. vm-sandbox promises share the host V8 microtask queue, so
|
||||
// awaiting here advances them too.
|
||||
async function flush() {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
function buildSandbox() {
|
||||
// Every fetch() call parks here as { body, resolve, reject } so the
|
||||
// test controls exactly when each request settles.
|
||||
const pending = [];
|
||||
const status = { textContent: '' };
|
||||
const sandbox = {
|
||||
pending,
|
||||
status,
|
||||
document: {
|
||||
getElementById: () => status,
|
||||
},
|
||||
fetch: (url, opts) => new Promise((resolve, reject) => {
|
||||
pending.push({ body: JSON.parse(opts.body), resolve, reject });
|
||||
}),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadFunctions(sandbox, src) {
|
||||
const code = `
|
||||
var _settingSaveChain = Promise.resolve();
|
||||
${extractFunction(src, 'function persistSetting(')}
|
||||
${extractFunction(src, 'async function _postSetting(')}
|
||||
globalThis.__persistSetting = persistSetting;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
// Resolve a parked fetch as a successful /api/settings response.
|
||||
function ok(entry, message = 'Settings saved') {
|
||||
entry.resolve({ json: async () => ({ message }) });
|
||||
}
|
||||
|
||||
test('persistSetting sends one POST at a time, in call order', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
sandbox.__persistSetting('default_arrangement', 'Lead');
|
||||
sandbox.__persistSetting('demucs_server_url', 'http://example:7865');
|
||||
await flush();
|
||||
|
||||
// The second POST must not be in flight until the first resolves.
|
||||
assert.equal(sandbox.pending.length, 1, 'only the first POST should be in flight');
|
||||
assert.deepEqual(sandbox.pending[0].body, { default_arrangement: 'Lead' });
|
||||
|
||||
ok(sandbox.pending[0]);
|
||||
await flush();
|
||||
|
||||
assert.equal(sandbox.pending.length, 2, 'second POST runs after the first settles');
|
||||
assert.deepEqual(sandbox.pending[1].body, { demucs_server_url: 'http://example:7865' });
|
||||
|
||||
ok(sandbox.pending[1]);
|
||||
await flush();
|
||||
});
|
||||
|
||||
test('a failed save does not block later saves on the chain', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
sandbox.__persistSetting('default_arrangement', 'Bass');
|
||||
sandbox.__persistSetting('demucs_server_url', 'http://example:9000');
|
||||
await flush();
|
||||
|
||||
assert.equal(sandbox.pending.length, 1);
|
||||
// First request fails outright (network error).
|
||||
sandbox.pending[0].reject(new Error('network down'));
|
||||
await flush();
|
||||
|
||||
assert.equal(sandbox.pending.length, 2, 'second save still proceeds after the first fails');
|
||||
assert.deepEqual(sandbox.pending[1].body, { demucs_server_url: 'http://example:9000' });
|
||||
assert.match(sandbox.status.textContent, /Save failed/, 'failure surfaces in the status line');
|
||||
|
||||
ok(sandbox.pending[1]);
|
||||
await flush();
|
||||
assert.equal(sandbox.status.textContent, 'Settings saved', 'later save still reports success');
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Verify closeCurrentSong() exits via origin-aware showScreen without
|
||||
// restart, seek, playSong reload, or direct audio mutation.
|
||||
//
|
||||
// Same isolation strategy as song_restart.test.js — extract the function
|
||||
// from app.js by brace-matching and run it 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 vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function buildSandbox({ playerOriginScreen = 'home' } = {}) {
|
||||
const sandbox = {
|
||||
_playerOriginScreen: playerOriginScreen,
|
||||
__showScreenCalls: [],
|
||||
__restartCalls: 0,
|
||||
__seekCalls: 0,
|
||||
__playSongCalls: 0,
|
||||
__clearLoopCalls: 0,
|
||||
__audioCurrentTimeSets: [],
|
||||
audio: {
|
||||
_t: 42,
|
||||
get currentTime() { return sandbox.audio._t; },
|
||||
set currentTime(v) { sandbox.__audioCurrentTimeSets.push(v); sandbox.audio._t = v; },
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadClose(sandbox, src) {
|
||||
const closeSrc = extractFunction(src, 'function closeCurrentSong(');
|
||||
const code = `
|
||||
var _playerOriginScreen = ${JSON.stringify(sandbox._playerOriginScreen)};
|
||||
globalThis.__showScreenCalls = [];
|
||||
globalThis.__restartCalls = 0;
|
||||
globalThis.__seekCalls = 0;
|
||||
globalThis.__playSongCalls = 0;
|
||||
globalThis.__clearLoopCalls = 0;
|
||||
globalThis.__audioCurrentTimeSets = [];
|
||||
var audio = {
|
||||
_t: 42,
|
||||
get currentTime() { return this._t; },
|
||||
set currentTime(v) { globalThis.__audioCurrentTimeSets.push(v); this._t = v; }
|
||||
};
|
||||
function showScreen(id) {
|
||||
globalThis.__showScreenCalls.push(id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
function restartCurrentSong() { globalThis.__restartCalls++; }
|
||||
async function _audioSeek() { globalThis.__seekCalls++; }
|
||||
function playSong() { globalThis.__playSongCalls++; }
|
||||
function clearLoop() { globalThis.__clearLoopCalls++; }
|
||||
${closeSrc}
|
||||
globalThis.__closeCurrentSong = closeCurrentSong;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('closeCurrentSong is exported on window and window.slopsmith', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /window\.closeCurrentSong\s*=\s*closeCurrentSong/);
|
||||
assert.match(src, /window\.slopsmith\.closeCurrentSong\s*=\s*closeCurrentSong/);
|
||||
});
|
||||
|
||||
test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ playerOriginScreen: 'favorites' });
|
||||
loadClose(sandbox, src);
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
|
||||
assert.equal(sandbox.__restartCalls, 0);
|
||||
assert.equal(sandbox.__seekCalls, 0);
|
||||
assert.equal(sandbox.__playSongCalls, 0);
|
||||
assert.equal(sandbox.__clearLoopCalls, 0);
|
||||
assert.equal(sandbox.__audioCurrentTimeSets.length, 0);
|
||||
});
|
||||
|
||||
test('closeCurrentSong falls back to home when origin missing', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ playerOriginScreen: null });
|
||||
loadClose(sandbox, src);
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'home');
|
||||
});
|
||||
|
||||
test('closeCurrentSong falls back to home when origin is empty string', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ playerOriginScreen: '' });
|
||||
loadClose(sandbox, src);
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'home');
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Verify song:play / song:pause / song:ended carry the enriched
|
||||
// payload { time, audioT, chartT, perfNow } so plugins can anchor
|
||||
// their own clocks without a follow-up highway.getTime() call.
|
||||
//
|
||||
// Same isolation strategy as the other plugin-API tests — extract
|
||||
// `_songEventPayload` and `_audioTime` from app.js and run them 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 vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildSandbox({ juceMode = false, audioT = 12.5, chartT = 11.8, juceT } = {}) {
|
||||
// When juceT is omitted in JUCE mode, derive a value distinct from
|
||||
// audioT so the JUCE-mode test actually proves _audioTime() reads
|
||||
// from jucePlayer rather than the html5 audio element.
|
||||
const jt = juceT !== undefined ? juceT : (juceMode ? audioT + 100 : audioT);
|
||||
const sandbox = {
|
||||
audio: { currentTime: audioT },
|
||||
jucePlayer: { currentTime: jt, duration: 200 },
|
||||
window: { _juceMode: juceMode },
|
||||
highway: {
|
||||
getTime: () => chartT,
|
||||
},
|
||||
performance: { now: () => 1000.123 },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadFunctions(sandbox, src) {
|
||||
const code = `
|
||||
${extractFunction(src, 'function _audioTime()')}
|
||||
${extractFunction(src, 'function _audioDuration()')}
|
||||
${extractFunction(src, 'function _songEventPayload()')}
|
||||
globalThis.__payload = _songEventPayload;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('_songEventPayload returns { time, audioT, chartT, perfNow } (HTML5)', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, audioT: 12.5, chartT: 11.8 });
|
||||
loadFunctions(sandbox, src);
|
||||
const p = sandbox.__payload();
|
||||
assert.equal(p.audioT, 12.5);
|
||||
assert.equal(p.chartT, 11.8);
|
||||
assert.equal(p.perfNow, 1000.123);
|
||||
assert.equal(p.time, 12.5, 'time must be an alias for audioT');
|
||||
assert.equal(Object.keys(p).length, 4);
|
||||
});
|
||||
|
||||
test('_songEventPayload reads from JUCE in juce mode', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// audioT (audio.currentTime) and juceT (jucePlayer.currentTime) are
|
||||
// distinct so the assertion proves we read from JUCE, not from the
|
||||
// html5 audio element.
|
||||
const sandbox = buildSandbox({ juceMode: true, audioT: 5, juceT: 42, chartT: 41 });
|
||||
loadFunctions(sandbox, src);
|
||||
const p = sandbox.__payload();
|
||||
assert.equal(p.audioT, 42, 'JUCE mode must read jucePlayer.currentTime, not audio.currentTime');
|
||||
assert.equal(p.time, 42);
|
||||
assert.equal(p.chartT, 41);
|
||||
});
|
||||
|
||||
test('time and audioT are the same number (not duplicated computation)', () => {
|
||||
// Cache invariant: audioT is read once and assigned to both fields.
|
||||
// If the implementation read _audioTime() twice and the underlying
|
||||
// value drifted between reads, time !== audioT. Guard the cache.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
let reads = 0;
|
||||
const sandbox = {
|
||||
audio: { get currentTime() { reads++; return 5 + reads * 0.001; } },
|
||||
jucePlayer: { currentTime: 0 },
|
||||
window: { _juceMode: false },
|
||||
highway: { getTime: () => 4.9 },
|
||||
performance: { now: () => 1000 },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
loadFunctions(sandbox, src);
|
||||
const p = sandbox.__payload();
|
||||
assert.equal(p.time, p.audioT, 'time must equal audioT');
|
||||
});
|
||||
|
||||
test('every song:play/pause/ended emit uses _songEventPayload', () => {
|
||||
// Source-level guard: catch a future contributor adding a new emit
|
||||
// site with a literal { time: x } payload — that would silently drop
|
||||
// chartT/perfNow and break plugins that depend on the enriched shape.
|
||||
// Accepts either a direct _songEventPayload() call or a captured
|
||||
// `payload` var (used by JUCE teardown sites that snapshot before
|
||||
// jucePlayer.stop() resets _pos to 0).
|
||||
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)['"]/;
|
||||
const okRe = /_songEventPayload\(\)|,\s*payload\s*\)/;
|
||||
const offending = [];
|
||||
for (const line of lines) {
|
||||
if (!emitRe.test(line)) continue;
|
||||
if (!okRe.test(line)) {
|
||||
offending.push(line.trim());
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
offending.length,
|
||||
0,
|
||||
`song:* emits not using _songEventPayload():\n${offending.join('\n')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('there are at least 8 song:* emit sites threaded through the helper', () => {
|
||||
// Sanity-check that the helper actually got wired everywhere. If the
|
||||
// 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) || [];
|
||||
assert.ok(
|
||||
matches.length >= 8,
|
||||
`expected ≥8 song:* emits, found ${matches.length}`,
|
||||
);
|
||||
// Same dual-form acceptance as the per-line check: either a direct
|
||||
// _songEventPayload() call or a captured `payload` var.
|
||||
for (const m of matches) {
|
||||
assert.match(m, /_songEventPayload\(\)|,\s*payload\s*\)/, `emit not using helper: ${m}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Verify restartCurrentSong() uses the canonical _audioSeek / togglePlay /
|
||||
// startCountIn paths without clearing loops or reloading the song.
|
||||
//
|
||||
// Same isolation strategy as song_seek.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 V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
const sandbox = {
|
||||
loopA,
|
||||
loopB,
|
||||
isPlaying,
|
||||
__cancelCountInCalls: 0,
|
||||
__seekCalls: [],
|
||||
__startCountInCalls: [],
|
||||
__togglePlayCalls: 0,
|
||||
__clearLoopCalls: 0,
|
||||
window: {
|
||||
slopsmith: {
|
||||
getLoop() {
|
||||
return { loopA: sandbox.loopA, loopB: sandbox.loopB };
|
||||
},
|
||||
},
|
||||
},
|
||||
__audioSeek(s, reason) {
|
||||
sandbox.__seekCalls.push({ s, reason });
|
||||
return Promise.resolve({ completed: true, from: 30, to: s });
|
||||
},
|
||||
__startCountIn(opts) {
|
||||
sandbox.__startCountInCalls.push(opts);
|
||||
return Promise.resolve();
|
||||
},
|
||||
__togglePlay() {
|
||||
sandbox.__togglePlayCalls++;
|
||||
sandbox.isPlaying = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
||||
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
|
||||
const code = `
|
||||
var isPlaying = ${sandbox.isPlaying};
|
||||
function _cancelCountIn() { __cancelCountInCalls++; }
|
||||
async function _audioSeek(s, reason) {
|
||||
return (${audioSeekImpl || '__audioSeek'})(s, reason);
|
||||
}
|
||||
async function startCountIn(opts) { return __startCountIn(opts); }
|
||||
async function togglePlay() { return __togglePlay(); }
|
||||
function clearLoop() { __clearLoopCalls++; }
|
||||
${restartSrc}
|
||||
globalThis.__restartCurrentSong = restartCurrentSong;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('restartCurrentSong is exported on window and window.slopsmith', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /window\.restartCurrentSong\s*=\s*restartCurrentSong/);
|
||||
assert.match(src, /window\.slopsmith\.restartCurrentSong\s*=\s*restartCurrentSong/);
|
||||
});
|
||||
|
||||
test('no loop: seeks to 0 with song-restart and starts playback when stopped', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ isPlaying: false });
|
||||
loadRestart(sandbox, src);
|
||||
|
||||
const ok = await sandbox.__restartCurrentSong();
|
||||
assert.equal(ok, true);
|
||||
assert.equal(sandbox.__cancelCountInCalls, 1);
|
||||
assert.equal(sandbox.__seekCalls.length, 1);
|
||||
assert.equal(sandbox.__seekCalls[0].s, 0);
|
||||
assert.equal(sandbox.__seekCalls[0].reason, 'song-restart');
|
||||
assert.equal(sandbox.__togglePlayCalls, 1);
|
||||
assert.equal(sandbox.__startCountInCalls.length, 0);
|
||||
assert.equal(sandbox.__clearLoopCalls, 0);
|
||||
});
|
||||
|
||||
test('already playing, no loop: seeks to 0 and does not toggle play', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ isPlaying: true });
|
||||
loadRestart(sandbox, src);
|
||||
|
||||
const ok = await sandbox.__restartCurrentSong();
|
||||
assert.equal(ok, true);
|
||||
assert.equal(sandbox.__seekCalls[0].s, 0);
|
||||
assert.equal(sandbox.__togglePlayCalls, 0);
|
||||
assert.equal(sandbox.__clearLoopCalls, 0);
|
||||
});
|
||||
|
||||
test('loop armed: seeks to loopA, preserves loop, re-enters via startCountIn immediate', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ loopA: 12.5, loopB: 48, isPlaying: false });
|
||||
loadRestart(sandbox, src);
|
||||
|
||||
const ok = await sandbox.__restartCurrentSong();
|
||||
assert.equal(ok, true);
|
||||
assert.equal(sandbox.__seekCalls.length, 1);
|
||||
assert.equal(sandbox.__seekCalls[0].s, 12.5);
|
||||
assert.equal(sandbox.__seekCalls[0].reason, 'song-restart');
|
||||
assert.equal(sandbox.__startCountInCalls.length, 1);
|
||||
assert.equal(sandbox.__startCountInCalls[0].immediate, true);
|
||||
assert.equal(sandbox.__togglePlayCalls, 0);
|
||||
assert.equal(sandbox.__clearLoopCalls, 0);
|
||||
assert.equal(sandbox.loopB, 48, 'loopB must be preserved');
|
||||
});
|
||||
|
||||
test('failed/incomplete seek: does not start playback or count-in', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ isPlaying: false });
|
||||
loadRestart(sandbox, src, {
|
||||
audioSeekImpl: '(s, reason) => Promise.resolve({ completed: false, from: NaN, to: NaN })',
|
||||
});
|
||||
|
||||
const ok = await sandbox.__restartCurrentSong();
|
||||
assert.equal(ok, false);
|
||||
assert.equal(sandbox.__togglePlayCalls, 0);
|
||||
assert.equal(sandbox.__startCountInCalls.length, 0);
|
||||
});
|
||||
|
||||
test('V3 transport restart button exists with correct attributes', () => {
|
||||
const html = fs.readFileSync(V3_HTML, 'utf8');
|
||||
assert.match(html, /v3-transport-mid[\s\S]*onclick="restartCurrentSong\(\)"/);
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
|
||||
test('V2 transport restart button exists with correct attributes', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /#player-controls|player-controls[\s\S]*onclick="restartCurrentSong\(\)"/);
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// Verify static/app.js emits `song:seek` for every audio repositioning,
|
||||
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
|
||||
// suppression during seek transients) consume this contract.
|
||||
//
|
||||
// Same isolation strategy as loop_restart.test.js — extract the relevant
|
||||
// functions from app.js by brace-matching and run them 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 vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildSandbox({ juceMode = false, currentTime = 10, duration = Infinity } = {}) {
|
||||
const emitCalls = [];
|
||||
const audio = {
|
||||
duration,
|
||||
get currentTime() { return audio._t; },
|
||||
// Clamp to [0, duration] like a real <audio> element so the
|
||||
// post-seek readback for `to` reflects the landed position
|
||||
// (the browser snaps out-of-range writes to the seekable range).
|
||||
set currentTime(v) {
|
||||
audio._t = Math.max(0, Math.min(v, audio.duration));
|
||||
},
|
||||
_t: currentTime,
|
||||
};
|
||||
const jucePlayer = {
|
||||
currentTime: currentTime,
|
||||
seek(s) {
|
||||
// Async like the real one; mutate currentTime so subsequent
|
||||
// _audioTime() reads see the new value.
|
||||
return Promise.resolve().then(() => { jucePlayer.currentTime = s; });
|
||||
},
|
||||
};
|
||||
const sandbox = {
|
||||
audio,
|
||||
jucePlayer,
|
||||
window: {
|
||||
_juceMode: juceMode,
|
||||
slopsmith: {
|
||||
emit(event, detail) { emitCalls.push({ event, detail }); },
|
||||
},
|
||||
},
|
||||
__emitCalls: emitCalls,
|
||||
// _juceSeekWithTimeout uses setTimeout for its Promise.race;
|
||||
// expose Node's setTimeout to the vm context.
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
Promise,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function loadFunctions(sandbox, src) {
|
||||
const code = `
|
||||
let _audioSeekChain = Promise.resolve();
|
||||
let _audioSeekGen = 0;
|
||||
// _audioSeek now syncs the jump-fix tracker so far seeks don't
|
||||
// trigger an immediate revert; declare it here so the sandbox
|
||||
// assignment lands on a real binding rather than an implicit global.
|
||||
let lastAudioTime = 0;
|
||||
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
|
||||
// helper + constant. Tests can override jucePlayer.seek to vary
|
||||
// behavior; the timeout (2 s) is well above any test setTimeout.
|
||||
const _JUCE_SEEK_TIMEOUT_MS = 2000;
|
||||
${extractFunction(src, 'function _juceSeekWithTimeout(')}
|
||||
${extractFunction(src, 'function _audioTime()')}
|
||||
${extractFunction(src, 'function _audioDuration()')}
|
||||
${extractFunction(src, 'async function _audioSeek(')}
|
||||
${extractFunction(src, 'async function seekBy(')}
|
||||
globalThis.__audioSeek = _audioSeek;
|
||||
globalThis.__seekBy = seekBy;
|
||||
// Mirror _resetAudioSeekState exactly: bump only — chain stays so
|
||||
// new seeks queue behind in-flight ones and don't race the IPC.
|
||||
globalThis.__bumpGen = () => { _audioSeekGen++; };
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('_audioSeek emits song:seek with from/to/reason (HTML5 path)', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, currentTime: 10 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__audioSeek(42, 'unit-test');
|
||||
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 1);
|
||||
assert.equal(seeks[0].detail.from, 10);
|
||||
assert.equal(seeks[0].detail.to, 42);
|
||||
assert.equal(seeks[0].detail.reason, 'unit-test');
|
||||
assert.equal(sandbox.audio._t, 42, 'HTML5 audio.currentTime must be assigned');
|
||||
});
|
||||
|
||||
test('_audioSeek emits song:seek (JUCE path) after seek promise resolves', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__audioSeek(99, 'juce-test');
|
||||
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 1);
|
||||
assert.equal(seeks[0].detail.from, 5);
|
||||
assert.equal(seeks[0].detail.to, 99);
|
||||
assert.equal(seeks[0].detail.reason, 'juce-test');
|
||||
assert.equal(sandbox.jucePlayer.currentTime, 99, 'JUCE player position must be advanced');
|
||||
});
|
||||
|
||||
test('concurrent _audioSeek calls serialize and capture from atomically', async () => {
|
||||
// Without serialization, two overlapping JUCE seeks would both read
|
||||
// `from` before either resolved, making the second emit's `from` stale.
|
||||
// The chain ensures each call's from/to bracket only its own seek.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: true, currentTime: 0 });
|
||||
// Make jucePlayer.seek slow so the second call genuinely overlaps the
|
||||
// first if there's no serialization.
|
||||
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
|
||||
sandbox.jucePlayer.currentTime = s;
|
||||
resolve();
|
||||
}, 5));
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
// Fire two seeks back-to-back without awaiting the first.
|
||||
const p1 = sandbox.__audioSeek(10, 'first');
|
||||
const p2 = sandbox.__audioSeek(20, 'second');
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 2);
|
||||
// First seek: from=0 (initial), to=10
|
||||
assert.equal(seeks[0].detail.from, 0);
|
||||
assert.equal(seeks[0].detail.to, 10);
|
||||
assert.equal(seeks[0].detail.reason, 'first');
|
||||
// Second seek: from=10 (post-first, captured INSIDE the chain), to=20
|
||||
assert.equal(seeks[1].detail.from, 10, 'second seek must capture from after first resolved');
|
||||
assert.equal(seeks[1].detail.to, 20);
|
||||
assert.equal(seeks[1].detail.reason, 'second');
|
||||
});
|
||||
|
||||
test('queued seeks cancel cleanly when generation bumps mid-flight', async () => {
|
||||
// Simulates song teardown: a seek is enqueued, then the generation
|
||||
// bumps before the seek's chain callback runs. The pending callback
|
||||
// must bail out — no song:seek emit, no mutation of the new session's
|
||||
// currentTime.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
|
||||
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
|
||||
sandbox.jucePlayer.currentTime = s;
|
||||
resolve();
|
||||
}, 5));
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
// Enqueue a seek but bump the generation before the chain's microtask runs.
|
||||
const p = sandbox.__audioSeek(99, 'cancel-test');
|
||||
sandbox.__bumpGen();
|
||||
const result = await p;
|
||||
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 0, 'cancelled seek must not emit song:seek');
|
||||
assert.equal(sandbox.jucePlayer.currentTime, 5, 'cancelled seek must not advance currentTime');
|
||||
assert.equal(result.completed, false, 'cancelled seek must resolve to {completed: false} so callers can bail');
|
||||
});
|
||||
|
||||
test('queued seek bails when generation bumps DURING the JUCE seek', async () => {
|
||||
// Covers the second gen-check (the one after `await jucePlayer.seek`).
|
||||
// The previous test bumps before the chain callback starts; this one
|
||||
// lets the callback enter, reach the seek await, and then bumps so the
|
||||
// post-await guard is what catches the cancellation.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: true, currentTime: 5 });
|
||||
let bumpedDuringSeek = false;
|
||||
sandbox.jucePlayer.seek = (s) => new Promise((resolve) => setTimeout(() => {
|
||||
sandbox.jucePlayer.currentTime = s;
|
||||
// Bump while the seek is mid-flight: we're past the first guard
|
||||
// (it ran when the chain callback entered), but before the second.
|
||||
if (!bumpedDuringSeek) { sandbox.__bumpGen(); bumpedDuringSeek = true; }
|
||||
resolve();
|
||||
}, 5));
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
const result = await sandbox.__audioSeek(99, 'mid-seek-cancel');
|
||||
|
||||
assert.equal(bumpedDuringSeek, true, 'sanity: bump must have fired inside the seek');
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 0, 'mid-seek cancel must not emit song:seek');
|
||||
assert.equal(result.completed, false, 'mid-seek cancel must resolve to {completed: false}');
|
||||
});
|
||||
|
||||
test('_audioSeek resolves to {completed, from, to} on a successful run', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, currentTime: 5 });
|
||||
loadFunctions(sandbox, src);
|
||||
const result = await sandbox.__audioSeek(10, 'success-test');
|
||||
assert.equal(result.completed, true, 'completed seek must resolve to completed:true');
|
||||
assert.equal(result.from, 5, 'from must be the pre-seek clock');
|
||||
assert.equal(result.to, 10, 'to must be the verified post-seek clock');
|
||||
});
|
||||
|
||||
test('_audioSeek emits the landed clock when HTML5 clamps to duration', async () => {
|
||||
// Regression: the HTML5 path's `to` must reflect the actual landed
|
||||
// position (clamped to seekable range), not the requested target.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, currentTime: 10, duration: 30 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__audioSeek(99, 'html5-clamp');
|
||||
|
||||
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
|
||||
assert.equal(seek.detail.from, 10);
|
||||
assert.equal(seek.detail.to, 30, 'to must be the clamped landed position, not the requested target');
|
||||
});
|
||||
|
||||
test('_audioSeek emits the verified post-seek clock when JUCE rolls back', async () => {
|
||||
// Regression: `to` must reflect the actual position after seek, not
|
||||
// the requested `s`. JUCE may clamp or no-op a seek (engine state
|
||||
// mismatch, end-of-track, etc.); plugins that act on `to` would
|
||||
// otherwise see a phantom jump.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: true, currentTime: 7 });
|
||||
sandbox.jucePlayer.seek = (s) => Promise.resolve(); // no-op: currentTime stays at 7
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__audioSeek(42, 'rollback-test');
|
||||
|
||||
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
|
||||
assert.equal(seek.detail.from, 7);
|
||||
assert.equal(seek.detail.to, 7, 'to must equal post-seek clock, not requested s');
|
||||
assert.equal(seek.detail.reason, 'rollback-test');
|
||||
});
|
||||
|
||||
test('_audioSeek without reason emits reason: null', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__audioSeek(20);
|
||||
|
||||
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
|
||||
assert.equal(seek.detail.reason, null);
|
||||
});
|
||||
|
||||
test('seekBy routes through _audioSeek with reason "seek-by"', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, currentTime: 10 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__seekBy(5);
|
||||
|
||||
const seeks = sandbox.__emitCalls.filter((c) => c.event === 'song:seek');
|
||||
assert.equal(seeks.length, 1, 'seekBy must trigger exactly one song:seek emit');
|
||||
assert.equal(seeks[0].detail.from, 10);
|
||||
assert.equal(seeks[0].detail.to, 15);
|
||||
assert.equal(seeks[0].detail.reason, 'seek-by');
|
||||
});
|
||||
|
||||
test('seekBy floors at zero (does not seek to negative time)', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox({ juceMode: false, currentTime: 2 });
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
await sandbox.__seekBy(-10);
|
||||
|
||||
const seek = sandbox.__emitCalls.find((c) => c.event === 'song:seek');
|
||||
assert.equal(seek.detail.to, 0);
|
||||
});
|
||||
|
||||
test('every documented seek callsite passes a reason', () => {
|
||||
// Source-order assertion: every _audioSeek call outside the
|
||||
// implementation must pass a kebab-case reason string. Catches a
|
||||
// future contributor adding a new seek path without threading the
|
||||
// reason. Line-based — regex argument capture can't balance parens
|
||||
// through Math.max/_audioTime calls.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fnSrc = extractFunction(src, 'async function _audioSeek(');
|
||||
const withoutImpl = src.replace(fnSrc, '');
|
||||
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));
|
||||
assert.ok(callLines.length >= 5, `expected ≥5 _audioSeek call lines, found ${callLines.length}`);
|
||||
for (const line of callLines) {
|
||||
assert.match(
|
||||
line,
|
||||
/['"][a-z]+(?:-[a-z]+)+['"]/,
|
||||
`_audioSeek call missing kebab-case reason arg: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
scan++;
|
||||
while (scan < src.length && parenDepth > 0) {
|
||||
const ch = src[scan];
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') parenDepth--;
|
||||
scan++;
|
||||
}
|
||||
}
|
||||
const openBrace = src.indexOf('{', scan);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function buildSandbox({ juceMode = false } = {}) {
|
||||
const elements = new Map();
|
||||
const makeElement = (id) => ({
|
||||
id,
|
||||
value: id === 'speed-slider' ? 135 : '',
|
||||
textContent: id === 'speed-label' ? '1.35x' : '',
|
||||
style: {},
|
||||
});
|
||||
for (const id of ['speed-slider', 'speed-label', 'quality-select', 'highway']) {
|
||||
elements.set(id, makeElement(id));
|
||||
}
|
||||
elements.set('speed-presets', {
|
||||
id: 'speed-presets',
|
||||
querySelectorAll() { return []; },
|
||||
});
|
||||
const backingCalls = [];
|
||||
const sliderInputs = [];
|
||||
const audio = {
|
||||
playbackRate: 1.35,
|
||||
pause() {},
|
||||
};
|
||||
const jucePlayer = {
|
||||
_speed: 1.35,
|
||||
setRate(rate) { this._speed = rate; },
|
||||
stop() { return Promise.resolve(); },
|
||||
};
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
audio,
|
||||
jucePlayer,
|
||||
highway: {
|
||||
stop() {},
|
||||
init() {},
|
||||
connect(url) { sandbox.__connectedUrl = url; },
|
||||
getRenderScale: () => 1,
|
||||
},
|
||||
window: {
|
||||
_juceMode: juceMode,
|
||||
_juceAudioUrl: juceMode ? '/audio/old-song.ogg' : null,
|
||||
_currentSongAudio: { url: '/audio/old-song.ogg' },
|
||||
_clearJuceRerouteMemo() {},
|
||||
slopsmith: {
|
||||
isPlaying: true,
|
||||
emit() {},
|
||||
},
|
||||
slopsmithDesktop: {
|
||||
audio: {
|
||||
setBackingSpeed(rate) {
|
||||
backingCalls.push(['setBackingSpeed', rate]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
setBackingPreservePitch(value) {
|
||||
backingCalls.push(['setBackingPreservePitch', value]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
document: {
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, makeElement(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '.screen.active') return { id: 'home' };
|
||||
return null;
|
||||
},
|
||||
},
|
||||
location: {
|
||||
protocol: 'http:',
|
||||
host: 'localhost:9999',
|
||||
},
|
||||
URLSearchParams,
|
||||
setTimeout(fn) { fn(); return 0; },
|
||||
clearTimeout() {},
|
||||
Promise,
|
||||
decodeURIComponent,
|
||||
__elements: elements,
|
||||
__backingCalls: backingCalls,
|
||||
__sliderInputs: sliderInputs,
|
||||
};
|
||||
sandbox.window.jucePlayer = jucePlayer;
|
||||
sandbox.handleSliderInput = (el) => {
|
||||
if (el) sliderInputs.push(el.id);
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function extractConstLine(src, name) {
|
||||
const match = src.match(new RegExp(`const ${name} = [^;]+;`));
|
||||
if (!match) throw new Error(`extractConstLine: '${name}' not found in app.js`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function loadPlaySong(sandbox) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
|
||||
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
|
||||
: '';
|
||||
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
|
||||
? `
|
||||
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
|
||||
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
|
||||
${extractFunction(src, 'function _speedPresetPctFromActive')}
|
||||
${extractFunction(src, 'function _updateSpeedPresetButtons')}
|
||||
`
|
||||
: '';
|
||||
const code = `
|
||||
var artAbortController = null;
|
||||
var isPlaying = true;
|
||||
var currentFilename = null;
|
||||
var _playerOriginScreen = null;
|
||||
function _recordPlaybackBridge() {}
|
||||
function _cancelCountIn() {}
|
||||
function _resetJuceAudioShimChain() {}
|
||||
function _resetAudioSeekState() {}
|
||||
function setPlayButtonState() {}
|
||||
function clearLoop() {}
|
||||
function _resetSectionPracticeLog() {}
|
||||
function _hideSectionPracticeBar() {}
|
||||
function showScreen() {}
|
||||
function _getArrangementNamingMode() { return 'default'; }
|
||||
function _scheduleSectionPracticeRetries() {}
|
||||
function loadSavedLoops() {}
|
||||
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
|
||||
${extractFunction(src, 'function setSpeed')}
|
||||
${speedPresetHelpers}
|
||||
${resetHelper}
|
||||
${extractFunction(src, 'async function playSong')}
|
||||
globalThis.__playSong = playSong;
|
||||
`;
|
||||
vm.runInContext(code, sandbox);
|
||||
}
|
||||
|
||||
test('new song load resets the HTML audio rate, not only the visible speed controls', async () => {
|
||||
const sandbox = buildSandbox();
|
||||
loadPlaySong(sandbox);
|
||||
|
||||
await sandbox.__playSong('next-song.psarc');
|
||||
|
||||
assert.equal(sandbox.__elements.get('speed-slider').value, 100);
|
||||
assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/);
|
||||
assert.equal(sandbox.audio.playbackRate, 1);
|
||||
assert.deepEqual(sandbox.__sliderInputs, ['speed-slider']);
|
||||
});
|
||||
|
||||
test('new song load resets the desktop backing rate when the API is available', async () => {
|
||||
const sandbox = buildSandbox({ juceMode: true });
|
||||
loadPlaySong(sandbox);
|
||||
|
||||
await sandbox.__playSong('next-song.psarc');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(sandbox.jucePlayer._speed, 1);
|
||||
assert.deepEqual(sandbox.__backingCalls, [
|
||||
['setBackingSpeed', 1],
|
||||
['setBackingPreservePitch', true],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// Shared test helpers for the brace-matching app.js extraction strategy used
|
||||
// by song_restart / song_close (and friends): pull a single top-level function
|
||||
// out of app.js by name so it can run in an isolated vm sandbox with stubbed
|
||||
// deps, without executing the whole module.
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
scan++;
|
||||
while (scan < src.length && parenDepth > 0) {
|
||||
const ch = src[scan];
|
||||
if (ch === '(') parenDepth++;
|
||||
else if (ch === ')') parenDepth--;
|
||||
scan++;
|
||||
}
|
||||
}
|
||||
const openBrace = src.indexOf('{', scan);
|
||||
if (openBrace === -1) throw new Error(`extractFunction: no '{' after '${signature}'`);
|
||||
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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
module.exports = { extractFunction };
|
||||
@@ -0,0 +1,172 @@
|
||||
// Source-level guards for the consolidated tour menu (slopsmith#272).
|
||||
// The engine lives in a DOMContentLoaded handler that wires window.slopsmith,
|
||||
// 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,
|
||||
// dedup, etc.) instead, so regressions land as failed assertions rather
|
||||
// than silently-broken UX.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const tourJs = path.join(__dirname, '..', '..', 'static', 'tour-engine.js');
|
||||
const SRC = fs.readFileSync(tourJs, 'utf8');
|
||||
|
||||
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('viz tours on player are gated on the currently active viz', () => {
|
||||
const fn = extractBlock(SRC, 'function _isRelevant(pluginId, screenId, activeVizId)');
|
||||
// Gate must check is_viz on player, not just screen membership.
|
||||
assert.match(fn, /meta\.is_viz/, '_isRelevant must read meta.is_viz');
|
||||
assert.match(fn, /screenId\s*===\s*'player'/, '_isRelevant must gate the viz check on the player screen');
|
||||
assert.match(fn, /activeVizId\s*===\s*pluginId/, '_isRelevant must compare activeVizId to pluginId');
|
||||
});
|
||||
|
||||
test('_relevantPlugins computes the active viz id once per refresh', () => {
|
||||
const fn = extractBlock(SRC, 'function _relevantPlugins(screenId)');
|
||||
// _currentVizPluginId must be called exactly once at the top, not
|
||||
// inside the per-plugin filter callback.
|
||||
const callMatches = fn.match(/_currentVizPluginId\(\)/g) || [];
|
||||
assert.equal(callMatches.length, 1, '_currentVizPluginId() must be called exactly once per refresh');
|
||||
// And gated on the player screen — no need to evaluate matchesArrangement
|
||||
// for irrelevant screens.
|
||||
assert.match(fn, /screenId\s*===\s*'player'/, 'active viz lookup must be gated on the player screen');
|
||||
});
|
||||
|
||||
test('_currentVizPluginId reads #viz-picker before localStorage', () => {
|
||||
const fn = extractBlock(SRC, 'function _currentVizPluginId()');
|
||||
const pickerIdx = fn.search(/getElementById\(\s*['"]viz-picker['"]/);
|
||||
const lsIdx = fn.search(/localStorage\.getItem\(\s*['"]vizSelection['"]/);
|
||||
assert.ok(pickerIdx !== -1, 'must read #viz-picker');
|
||||
assert.ok(lsIdx !== -1, 'must read localStorage.vizSelection');
|
||||
assert.ok(pickerIdx < lsIdx, '#viz-picker must be consulted before localStorage (app.js treats picker as source of truth)');
|
||||
});
|
||||
|
||||
test('tour completion → markSeen, cancel → markDismissed', () => {
|
||||
const fn = extractBlock(SRC, 'async function start(pluginId)');
|
||||
// complete handler must call _markSeen, cancel handler must call _markDismissed.
|
||||
// The two paths must not collapse into a single shared cleanup (else cancel
|
||||
// would silently mark the tour as completed, mis-labeling the badge).
|
||||
assert.match(fn, /tour\.on\(\s*['"]complete['"][\s\S]*?_markSeen\(\s*pluginId\s*\)/,
|
||||
'complete handler must _markSeen');
|
||||
assert.match(fn, /tour\.on\(\s*['"]cancel['"][\s\S]*?_markDismissed\(\s*pluginId\s*\)/,
|
||||
'cancel handler must _markDismissed (not _markSeen)');
|
||||
});
|
||||
|
||||
test('register() ignores legacy injectTriggerInto / injectTriggerOpts with a deduped warning', () => {
|
||||
const fn = extractBlock(SRC, 'function register(pluginId, opts)');
|
||||
assert.match(fn, /'injectTriggerInto'\s+in\s+opts/, 'must detect legacy injectTriggerInto');
|
||||
assert.match(fn, /'injectTriggerOpts'\s+in\s+opts/, 'must detect legacy injectTriggerOpts');
|
||||
assert.match(fn, /_deprecationWarned\.has\(pluginId\)/, 'must check dedup Set before warning');
|
||||
assert.match(fn, /_deprecationWarned\.add\(pluginId\)/, 'must add to dedup Set so we warn once per plugin');
|
||||
assert.match(fn, /console\.warn/, 'must emit a console.warn');
|
||||
// The deprecated options must NOT be re-introduced anywhere — they were
|
||||
// explicitly dropped from _registry storage and from injectTrigger calls.
|
||||
assert.doesNotMatch(fn, /injectTriggerInto\s*:/, 'register() must not re-introduce injectTriggerInto storage');
|
||||
});
|
||||
|
||||
test('waitFor validates string + try/catch protects querySelector', () => {
|
||||
// Find the step-mapping block that handles waitFor.
|
||||
const map = extractBlock(SRC, 'function _mapSteps(rawSteps, tourInstance)');
|
||||
assert.match(map, /typeof\s+raw\.waitFor\s*===\s*['"]string['"]/, 'must validate waitFor is a string');
|
||||
assert.match(map, /raw\.waitFor/, 'must reference raw.waitFor');
|
||||
// The selector must be probed inside a try/catch before beforeShowPromise
|
||||
// is installed — a malformed selector should warn + skip the wait, not
|
||||
// hang the tour.
|
||||
assert.match(map, /try\s*\{[^}]*document\.querySelector\(\s*sel\s*\)[^}]*\}\s*catch/,
|
||||
'must try/catch the upfront querySelector probe');
|
||||
assert.match(map, /_WAIT_FOR_TIMEOUT_MS/, 'must use the timeout constant');
|
||||
});
|
||||
|
||||
test('_maybeShowToast guards against active tour and open popover', () => {
|
||||
const fn = extractBlock(SRC, 'function _maybeShowToast()');
|
||||
assert.match(fn, /if\s*\(_activeTour\)\s*return/, 'must early-return when a tour is running');
|
||||
assert.match(fn, /_menuPopover\.style\.display\s*!==\s*'none'/,
|
||||
'must early-return when the popover is already visible');
|
||||
});
|
||||
|
||||
test('_updateMenuVisibility dismisses orphan toast when relevance drops to zero', () => {
|
||||
const fn = extractBlock(SRC, 'function _updateMenuVisibility()');
|
||||
// When plugins.length === 0 we hide the button AND must dismiss any
|
||||
// active toast (otherwise it'd float at the now-vacant button anchor).
|
||||
assert.match(fn, /_hideMenu\(\)[\s\S]*_dismissToast\(\)/,
|
||||
'must call _dismissToast() alongside _hideMenu() when relevance drops to zero');
|
||||
// And rebuild the open popover when relevance is still non-zero, so
|
||||
// NEW/✓ badges flip live without a close-and-reopen.
|
||||
assert.match(fn, /_rebuildMenuItems\(\)/, 'must rebuild open popover on visibility refresh');
|
||||
});
|
||||
|
||||
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['"]/,
|
||||
'trigger must wire aria-controls to the popover id');
|
||||
assert.match(fn, /_menuPopover\.id\s*=\s*['"]slopsmith-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)');
|
||||
});
|
||||
|
||||
test('toast Yes handler defers persistence to start() — no double-marking', () => {
|
||||
const fn = extractBlock(SRC, 'function _maybeShowToast()');
|
||||
// Find just the yesBtn click handler within the toast. We must NOT
|
||||
// see _markDismissed or _markSeen inside the Yes path — start()'s
|
||||
// own Shepherd handlers own that state transition. Calling
|
||||
// _markDismissed here would falsely flip hasDismissed() to true
|
||||
// while the tour is still running and after a successful complete.
|
||||
const yesIdx = fn.search(/yesBtn\.addEventListener/);
|
||||
const noIdx = fn.search(/noBtn\.addEventListener/);
|
||||
assert.ok(yesIdx !== -1 && noIdx !== -1, 'must find both yes and no handlers');
|
||||
const yesBlock = fn.slice(yesIdx, noIdx);
|
||||
assert.doesNotMatch(yesBlock, /_markDismissed\s*\(/,
|
||||
'Yes handler must not call _markDismissed (start() does it on cancel)');
|
||||
assert.doesNotMatch(yesBlock, /_markSeen\s*\(/,
|
||||
'Yes handler must not call _markSeen (start() does it on complete)');
|
||||
});
|
||||
|
||||
test('_hideMenu skips focus return when the trigger button is hidden', () => {
|
||||
const fn = extractBlock(SRC, 'function _hideMenu()');
|
||||
// The refocus path must check _menuBtn.style.display !== 'none'
|
||||
// so we don't try to focus a hidden trigger (no-op, leaves focus
|
||||
// stuck on the about-to-be-hidden popover).
|
||||
assert.match(fn, /_menuBtn\.style\.display\s*!==\s*['"]none['"]/,
|
||||
'_hideMenu must skip focus return when the button itself is hidden');
|
||||
});
|
||||
|
||||
test('_showMenu moves focus into the dialog; _hideMenu returns it to the trigger', () => {
|
||||
const show = extractBlock(SRC, 'function _showMenu()');
|
||||
assert.match(show, /\.tour-menu-item['"]?\s*\)?[\s\S]*\.focus\(\)/,
|
||||
'_showMenu must focus the first tour item');
|
||||
const hide = extractBlock(SRC, 'function _hideMenu()');
|
||||
assert.match(hide, /_menuBtn\.focus\(\)/, '_hideMenu must return focus to the trigger button');
|
||||
// The return-focus path must be gated on focus actually being inside
|
||||
// the dialog, so a programmatic _hideMenu doesn't steal focus from
|
||||
// elsewhere on the page.
|
||||
assert.match(hide, /_menuPopover\.contains\(\s*document\.activeElement\s*\)/,
|
||||
'_hideMenu must only return focus when focus was inside the dialog');
|
||||
});
|
||||
|
||||
test('esc() actually HTML-escapes — Shepherd renders title via innerHTML', () => {
|
||||
const fn = extractBlock(SRC, 'function esc(s)');
|
||||
// The pre-existing String() coercion was a no-op; the live esc() must
|
||||
// map &<>"' to entities.
|
||||
assert.match(fn, /replace\(/, 'esc() must call replace() to escape characters');
|
||||
assert.match(SRC, /_ESC_MAP\s*=\s*\{[^}]*'&':\s*'&'[^}]*'<':\s*'<'[^}]*'>':\s*'>'[^}]*'"':\s*'"'[^}]*"'":\s*'''/,
|
||||
'must map all five HTML-significant characters');
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
'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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.slopsmith.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: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.slopsmith;
|
||||
}
|
||||
|
||||
const slopsmithHelpers = loadTuningHelpers();
|
||||
|
||||
function createTunerSandbox() {
|
||||
const enableCalls = [];
|
||||
let playerActive = true;
|
||||
let songInfo = null;
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
Promise,
|
||||
queueMicrotask,
|
||||
setTimeout(fn) { fn(); return 0; },
|
||||
clearTimeout() {},
|
||||
fetch(url) {
|
||||
if (String(url).includes('/config')) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
showFloatingButton: true,
|
||||
visualizationMode: 'default',
|
||||
audioInputMode: 'auto',
|
||||
lastInstrument: 'guitar-6',
|
||||
lastTuning: 'Standard',
|
||||
freeTune: false,
|
||||
disabledTunings: [],
|
||||
customTunings: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
tunings: { 'guitar-6': { Standard: [82.41, 110, 146.83, 196, 246.94, 329.63] } },
|
||||
referencePitch: 440,
|
||||
}),
|
||||
});
|
||||
},
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem() {},
|
||||
},
|
||||
document: {
|
||||
getElementById(id) {
|
||||
if (id === 'player') {
|
||||
return { classList: { contains: () => playerActive } };
|
||||
}
|
||||
if (id === 'v3-tuner-wrap') return null;
|
||||
return null;
|
||||
},
|
||||
querySelector() { return null; },
|
||||
createElement(tag) {
|
||||
const el = {
|
||||
tagName: tag.toUpperCase(),
|
||||
src: '',
|
||||
classList: { add() {}, remove() {}, contains: () => false },
|
||||
className: '',
|
||||
style: {},
|
||||
appendChild() {},
|
||||
remove() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector: () => null,
|
||||
setAttribute() {},
|
||||
onload: null,
|
||||
onerror: null,
|
||||
};
|
||||
if (tag === 'script') {
|
||||
queueMicrotask(() => { if (el.onload) el.onload(); });
|
||||
}
|
||||
return el;
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
body: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
__setPlayerActive(v) { playerActive = v; },
|
||||
__setSongInfo(info) {
|
||||
songInfo = info;
|
||||
sandbox.window.slopsmith.currentSong = info ? {
|
||||
filename: info.filename || 'song.sloppak',
|
||||
arrangementIndex: info.arrangement_index,
|
||||
tuning: info.tuning,
|
||||
} : null;
|
||||
},
|
||||
__enableCalls: enableCalls,
|
||||
};
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.slopsmith = {
|
||||
...slopsmithHelpers,
|
||||
on() {},
|
||||
off() {},
|
||||
currentSong: null,
|
||||
};
|
||||
sandbox.window.highway = {
|
||||
getSongInfo: () => songInfo,
|
||||
};
|
||||
sandbox.window._tunerUtils = {
|
||||
preferFlats: () => false,
|
||||
offsetsToFreqs: (offsets) => offsets.map((o, i) => 80 + i * 10),
|
||||
freqToMidi: () => 40,
|
||||
midiToNote: () => 'E',
|
||||
};
|
||||
sandbox.window._tunerUI = () => ({
|
||||
addButton() {},
|
||||
initUI() {},
|
||||
renderInstrumentOptions() {},
|
||||
renderTuningOptions() {},
|
||||
renderStringNotes() {},
|
||||
updateSaveAsCustomVisibility() {},
|
||||
updateFreeTuneUI() {},
|
||||
updateFloatingButton() {},
|
||||
updatePlayerButton() {},
|
||||
updateFloatingButtonVisibility() {},
|
||||
updateInstrumentDisplay() {},
|
||||
positionPanel() {},
|
||||
updateUI() {},
|
||||
});
|
||||
sandbox.window._tunerAudio = {
|
||||
start: async () => {},
|
||||
stop() {},
|
||||
restart: async () => {},
|
||||
};
|
||||
sandbox.window._tunerViz_default = () => ({
|
||||
update() {},
|
||||
destroy() {},
|
||||
});
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fs.readFileSync(TUNER_SCREEN_JS, 'utf8'), sandbox);
|
||||
|
||||
const realEnable = sandbox.window.tuner.enable.bind(sandbox.window.tuner);
|
||||
sandbox.window.tuner.enable = async () => {
|
||||
enableCalls.push(1);
|
||||
return realEnable();
|
||||
};
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const CUSTOM_GUITAR = {
|
||||
filename: 'amnesia.sloppak',
|
||||
arrangement: 'Lead',
|
||||
arrangement_index: 0,
|
||||
stringCount: 6,
|
||||
tuning: [-2, 0, 0, 0, -2, -2],
|
||||
};
|
||||
|
||||
const E_STANDARD = {
|
||||
filename: 'standard.sloppak',
|
||||
arrangement: 'Lead',
|
||||
arrangement_index: 0,
|
||||
stringCount: 6,
|
||||
tuning: [0, 0, 0, 0, 0, 0],
|
||||
};
|
||||
|
||||
const DROP_D = {
|
||||
filename: 'dropd.sloppak',
|
||||
arrangement: 'Lead',
|
||||
arrangement_index: 0,
|
||||
stringCount: 6,
|
||||
tuning: [-2, 0, 0, 0, 0, 0],
|
||||
};
|
||||
|
||||
const BASS_EADG = {
|
||||
filename: 'bass.sloppak',
|
||||
arrangement: 'Bass',
|
||||
arrangement_index: 0,
|
||||
stringCount: 4,
|
||||
tuning: [0, 0, 0, 0],
|
||||
};
|
||||
|
||||
async function ready(sandbox, song) {
|
||||
sandbox.__setSongInfo(song);
|
||||
await sandbox.window._tunerAutoOpen.maybeAutoOpenOnTuningChange();
|
||||
}
|
||||
|
||||
test('tuning identity: same effective tuning returns same key', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
const key = sandbox.window._tunerAutoOpen.tuningIdentityKey(CUSTOM_GUITAR);
|
||||
assert.equal(key, sandbox.window._tunerAutoOpen.tuningIdentityKey({ ...CUSTOM_GUITAR }));
|
||||
assert.match(key, /^g:6:-2,0,0,0,-2,-2$/);
|
||||
});
|
||||
|
||||
test('tuning identity: DADGAD custom vs E Standard differ', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
const custom = sandbox.window._tunerAutoOpen.tuningIdentityKey(CUSTOM_GUITAR);
|
||||
const standard = sandbox.window._tunerAutoOpen.tuningIdentityKey(E_STANDARD);
|
||||
assert.notEqual(custom, standard);
|
||||
});
|
||||
|
||||
test('tuning identity: E Standard vs Drop D differ', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
const standard = sandbox.window._tunerAutoOpen.tuningIdentityKey(E_STANDARD);
|
||||
const dropD = sandbox.window._tunerAutoOpen.tuningIdentityKey(DROP_D);
|
||||
assert.notEqual(standard, dropD);
|
||||
});
|
||||
|
||||
test('tuning identity: bass 4-string vs guitar 6-string differ', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
const bass = sandbox.window._tunerAutoOpen.tuningIdentityKey(BASS_EADG);
|
||||
const guitar = sandbox.window._tunerAutoOpen.tuningIdentityKey({
|
||||
...BASS_EADG,
|
||||
arrangement: 'Lead',
|
||||
stringCount: 6,
|
||||
tuning: [0, 0, 0, 0, 0, 0],
|
||||
});
|
||||
assert.notEqual(bass, guitar);
|
||||
assert.match(bass, /^b:4:/);
|
||||
assert.match(guitar, /^g:6:/);
|
||||
});
|
||||
|
||||
test('tuning identity: missing tuning returns null', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
assert.equal(sandbox.window._tunerAutoOpen.tuningIdentityKey(null), null);
|
||||
assert.equal(sandbox.window._tunerAutoOpen.tuningIdentityKey({ tuning: [] }), null);
|
||||
});
|
||||
|
||||
test('first song load sets lastTuningKey but does not auto-open', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, 0);
|
||||
assert.equal(sandbox.window._tunerAutoOpen.getState().lastTuningKey, 'g:6:0,0,0,0,0,0');
|
||||
});
|
||||
|
||||
test('custom tuning then E Standard triggers one auto-open', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('E Standard then Drop D triggers one auto-open', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, E_STANDARD);
|
||||
await ready(sandbox, DROP_D);
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('same tuning twice does not auto-open', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, E_STANDARD);
|
||||
await ready(sandbox, { ...E_STANDARD, filename: 'other.sloppak' });
|
||||
assert.equal(sandbox.__enableCalls.length, 0);
|
||||
});
|
||||
|
||||
test('duplicate song:ready for same tuning does not auto-open repeatedly', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('if tuner already enabled, no duplicate enable call', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
sandbox.window._tunerAutoOpen.setEnabledForTests(true);
|
||||
const before = sandbox.__enableCalls.length;
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, before);
|
||||
});
|
||||
|
||||
test('user dismiss prevents reopen for same session', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, { ...E_STANDARD, filename: 'amnesia.sloppak', arrangement_index: 0 });
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
sandbox.window._tunerAutoOpen.setEnabledForTests(true);
|
||||
sandbox.window.tuner.disable();
|
||||
await ready(sandbox, { ...DROP_D, filename: 'amnesia.sloppak', arrangement_index: 0 });
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('song:loading clears dismiss state for next load', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
sandbox.window.tuner.disable();
|
||||
sandbox.window._tunerAutoOpen.onSongLoading();
|
||||
await ready(sandbox, DROP_D);
|
||||
assert.equal(sandbox.__enableCalls.length, 2);
|
||||
});
|
||||
|
||||
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, /function _tuningIdentityKey/);
|
||||
assert.doesNotMatch(src, /restartCurrentSong/);
|
||||
});
|
||||
|
||||
test('auto-open does not require app.js changes', () => {
|
||||
const appSrc = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const tunerCore = require('../../static/v3/tuner-core.js');
|
||||
|
||||
test('freqToNote maps A4 at 440 reference', () => {
|
||||
const n = tunerCore.freqToNote(440, 440);
|
||||
assert.strictEqual(n.name, 'A');
|
||||
assert.strictEqual(n.octave, 4);
|
||||
assert.strictEqual(n.cents, 0);
|
||||
assert.strictEqual(n.note, 'A4');
|
||||
});
|
||||
|
||||
test('freqToNote maps middle C', () => {
|
||||
const n = tunerCore.freqToNote(261.63, 440);
|
||||
assert.strictEqual(n.note, 'C4');
|
||||
assert.ok(Math.abs(n.cents) <= 2);
|
||||
});
|
||||
|
||||
test('freqToNote reports sharp/flat cents', () => {
|
||||
// A4 + 20 cents up.
|
||||
const sharp = tunerCore.freqToNote(440 * Math.pow(2, 20 / 1200), 440);
|
||||
assert.strictEqual(sharp.name, 'A');
|
||||
assert.ok(sharp.cents >= 18 && sharp.cents <= 22, 'cents ~+20, got ' + sharp.cents);
|
||||
const flat = tunerCore.freqToNote(440 * Math.pow(2, -20 / 1200), 440);
|
||||
assert.ok(flat.cents <= -18 && flat.cents >= -22, 'cents ~-20, got ' + flat.cents);
|
||||
});
|
||||
|
||||
test('freqToNote honors a non-440 reference pitch', () => {
|
||||
// At A=442, a 442 Hz tone is exactly A4 (0 cents).
|
||||
const n = tunerCore.freqToNote(442, 442);
|
||||
assert.strictEqual(n.note, 'A4');
|
||||
assert.strictEqual(n.cents, 0);
|
||||
});
|
||||
|
||||
test('freqToNote returns null for non-positive input', () => {
|
||||
assert.strictEqual(tunerCore.freqToNote(0, 440), null);
|
||||
assert.strictEqual(tunerCore.freqToNote(-5, 440), null);
|
||||
});
|
||||
|
||||
test('yinDetect recovers the pitch of a synthetic sine', () => {
|
||||
const sampleRate = 44100;
|
||||
const freq = 220; // A3
|
||||
const n = 4096;
|
||||
const buf = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) buf[i] = Math.sin(2 * Math.PI * freq * i / sampleRate);
|
||||
const res = tunerCore.yinDetect(buf, sampleRate);
|
||||
assert.ok(Math.abs(res.frequency - freq) < 2, 'detected ' + res.frequency + ' Hz');
|
||||
assert.ok(res.confidence > 0.8, 'confidence ' + res.confidence);
|
||||
assert.strictEqual(tunerCore.freqToNote(res.frequency, 440).note, 'A3');
|
||||
});
|
||||
|
||||
test('yinDetect returns zero for silence', () => {
|
||||
const res = tunerCore.yinDetect(new Float32Array(2048), 44100);
|
||||
assert.strictEqual(res.frequency, 0);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
'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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`extractBlock: '${startMarker}' 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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function loadTuningDisplayHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const block = [
|
||||
extractBlock(src, 'function _looksLikeRawTuningOffsets('),
|
||||
extractBlock(src, 'function _tuningNameFromOffsets('),
|
||||
extractBlock(src, 'function parseRawTuningOffsets('),
|
||||
extractBlock(src, 'function displayTuningName('),
|
||||
].join('\n');
|
||||
const sandbox = { window: { slopsmith: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(block + '\nexports.displayTuningName = displayTuningName;', sandbox);
|
||||
return sandbox.exports;
|
||||
}
|
||||
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
const { displayTuningName } = loadTuningDisplayHelpers();
|
||||
|
||||
test('displayTuningName passes through known labels', () => {
|
||||
assert.equal(displayTuningName('E Standard'), 'E Standard');
|
||||
assert.equal(displayTuningName('Drop D'), 'Drop D');
|
||||
assert.equal(displayTuningName('Eb Standard'), 'Eb Standard');
|
||||
});
|
||||
|
||||
test('displayTuningName sanitizes raw offset strings to Custom Tuning', () => {
|
||||
assert.equal(displayTuningName('-2000-2'), 'Custom Tuning');
|
||||
assert.equal(displayTuningName('-2 0 0 0 -2'), 'Custom Tuning');
|
||||
assert.equal(displayTuningName('-3,-1,0,1,2,3'), 'Custom Tuning');
|
||||
});
|
||||
|
||||
test('displayTuningName names a known raw offset string (slopsmith#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');
|
||||
assert.equal(displayTuningName('-2 0 0 0 0 0'), 'Drop D');
|
||||
assert.equal(displayTuningName('-2,0,0,0,-2,0'), 'DADGAD');
|
||||
// Genuinely custom offsets still read Custom Tuning.
|
||||
assert.equal(displayTuningName('-2 0 0 0 -2 1'), 'Custom Tuning');
|
||||
});
|
||||
|
||||
test('displayTuningName recognizes 4/5-string uniform standard (slopsmith#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');
|
||||
assert.equal(displayTuningName('0 0 0 0'), 'E Standard');
|
||||
});
|
||||
|
||||
test('displayTuningName derives readable names from offsets when value missing', () => {
|
||||
assert.equal(displayTuningName(null, [0, 0, 0, 0, 0, 0]), 'E Standard');
|
||||
assert.equal(displayTuningName('', [0, 0, 0, 0, 0, 0]), 'E Standard');
|
||||
assert.equal(displayTuningName(undefined, [-2, 0, 0, 0, 0, 0]), 'Drop D');
|
||||
});
|
||||
|
||||
test('displayTuningName returns Custom Tuning for unknown offsets', () => {
|
||||
assert.equal(displayTuningName(null, [-2, 0, 0, 0, -2]), 'Custom Tuning');
|
||||
assert.equal(displayTuningName('-2 0 0 0 -2', [-2, 0, 0, 0, -2]), 'Custom Tuning');
|
||||
});
|
||||
|
||||
test('displayTuningName returns empty when nothing usable', () => {
|
||||
assert.equal(displayTuningName(''), '');
|
||||
assert.equal(displayTuningName(null), '');
|
||||
assert.equal(displayTuningName('Unknown'), '');
|
||||
assert.equal(displayTuningName(null, []), '');
|
||||
});
|
||||
|
||||
test('V3 index.html defines hud-tuning', () => {
|
||||
const html = fs.readFileSync(V3_HTML, 'utf8');
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('V2 index.html defines hud-tuning', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('highway.js updates hud-tuning from song_info tuning offsets', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(src, /getElementById\('hud-tuning'\)/);
|
||||
assert.match(src, /displayTuningName\(null, msg\.tuning\)/);
|
||||
assert.match(src, /Tuning: /);
|
||||
});
|
||||
|
||||
test('V3 index.html defines hud-tuning-targets', () => {
|
||||
const html = fs.readFileSync(V3_HTML, 'utf8');
|
||||
assert.match(html, /id="hud-tuning-targets"/);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
'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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
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 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: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||
+ 'exports.effectiveStringCount = effectiveStringCount;',
|
||||
sandbox
|
||||
);
|
||||
return sandbox.exports;
|
||||
}
|
||||
|
||||
const { displayTuningTargets, displayTuningTargetDetails, isBassArrangement, effectiveStringCount } = loadTuningHelpers();
|
||||
|
||||
const LEAD_CTX = { arrangement: 'Lead', stringCount: 4, tuningName: 'Custom Tuning' };
|
||||
const BASS_CTX = { arrangement: 'Bass', stringCount: 4, isBass: true, tuningName: 'Custom Tuning' };
|
||||
|
||||
test('effectiveStringCount: Lead with bad stringCount=4 uses six guitar strings', () => {
|
||||
assert.equal(effectiveStringCount([-2, 0, 0, 0, -2, 0], LEAD_CTX), 6);
|
||||
});
|
||||
|
||||
test('isBassArrangement: Lead is not bass', () => {
|
||||
assert.equal(isBassArrangement({ arrangement: 'Lead' }), false);
|
||||
assert.equal(isBassArrangement({ arrangement: 'Bass' }), true);
|
||||
});
|
||||
|
||||
test('displayTuningTargets: custom guitar uses low-to-high note names only', () => {
|
||||
const targets = displayTuningTargets([-2, 0, 0, 0, -2, -2], LEAD_CTX);
|
||||
assert.equal(targets, 'D A D G A D');
|
||||
assert.doesNotMatch(targets, /6:|5:|D2|A2|D3/);
|
||||
});
|
||||
|
||||
test('displayTuningTargets: E Standard guitar is low-to-high notes', () => {
|
||||
const targets = displayTuningTargets([0, 0, 0, 0, 0, 0], { stringCount: 6, arrangement: 'Lead' });
|
||||
assert.equal(targets, 'E A D G B E');
|
||||
});
|
||||
|
||||
test('displayTuningTargets: bass 4-string is low-to-high notes', () => {
|
||||
const targets = displayTuningTargets([0, 0, 0, 0], BASS_CTX);
|
||||
assert.equal(targets, 'E A D G');
|
||||
});
|
||||
|
||||
test('displayTuningTargetDetails: includes string number and octave in titles', () => {
|
||||
const details = displayTuningTargetDetails([-2, 0, 0, 0, -2, -2], LEAD_CTX);
|
||||
assert.equal(details.length, 6);
|
||||
assert.equal(details[0].note, 'D');
|
||||
assert.equal(details[0].octaveNote, 'D2');
|
||||
assert.equal(details[0].title, '6th string: D2');
|
||||
assert.equal(details[5].title, '1st string: D4');
|
||||
});
|
||||
|
||||
test('displayTuningTargets: missing offsets returns empty string', () => {
|
||||
assert.equal(displayTuningTargets(null), '');
|
||||
assert.equal(displayTuningTargets([]), '');
|
||||
});
|
||||
|
||||
test('highway.js shows targets only for Custom Tuning', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(src, /getElementById\('hud-tuning-targets'\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /tuningLabel === 'Custom Tuning'/);
|
||||
assert.match(src, /Targets: /);
|
||||
});
|
||||
|
||||
test('tuner string buttons show note-only labels for Current Song', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
assert.match(src, /function _stringButtonLabel/);
|
||||
assert.match(src, /selectedTuningName === '_current'/);
|
||||
assert.match(src, /text: note/);
|
||||
assert.match(src, /title: _stringOrdinal/);
|
||||
assert.doesNotMatch(src, /text: stringNum \+ ' ' \+ note/);
|
||||
});
|
||||
|
||||
test('tuner panel shows visible low-to-high string order helper for Current Song', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
const helpBlock = src.slice(
|
||||
src.indexOf('function _syncStringOrderHelp'),
|
||||
src.indexOf('function renderStringNotes')
|
||||
);
|
||||
assert.match(src, /function _syncStringOrderHelp/);
|
||||
assert.match(src, /stringOrderHelpContainer/);
|
||||
assert.match(src, /Tune low-to-high:/);
|
||||
assert.match(src, /string → 1st string/);
|
||||
assert.match(helpBlock, /selectedTuningName === '_current'/);
|
||||
assert.doesNotMatch(helpBlock, /!state\.freeTune/);
|
||||
});
|
||||
|
||||
test('player-active tuner placement shifts left of LIVE HUD', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
const playerBranch = src.slice(
|
||||
src.indexOf('if (isPlayer && playerEl)'),
|
||||
src.indexOf('if (!wrap)')
|
||||
);
|
||||
assert.match(playerBranch, /top:5rem;right:11rem/);
|
||||
assert.doesNotMatch(playerBranch, /top:5rem;right:1\.25rem/);
|
||||
});
|
||||
|
||||
test('non-player tuner placement remains bottom-right', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
assert.match(src, /bottom:5rem;right:1\.25rem/);
|
||||
});
|
||||
|
||||
test('bass Current Song helper uses 4th string to 1st string label', () => {
|
||||
const details = displayTuningTargetDetails([0, 0, 0, 0], BASS_CTX);
|
||||
assert.equal(details.length, 4);
|
||||
assert.equal(details[0].title, '4th string: E1');
|
||||
assert.equal(details[3].title, '1st string: G2');
|
||||
});
|
||||
|
||||
test('Current Song helper hidden when no tuning targets exist', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
const helpBlock = src.slice(
|
||||
src.indexOf('function _syncStringOrderHelp'),
|
||||
src.indexOf('function renderStringNotes')
|
||||
);
|
||||
assert.match(helpBlock, /selectedTuning\.length > 0/);
|
||||
assert.match(helpBlock, /classList\.add\('hidden'\)/);
|
||||
});
|
||||
|
||||
test('tuner player button calls window.tuner.toggle', () => {
|
||||
const src = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
assert.match(src, /btn\.id = 'btn-tuner-player'/);
|
||||
assert.match(src, /btn\.onclick = window\.tuner\.toggle/);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Mirror of static/v3/songs.js buildLibraryStateHash — keep in sync.
|
||||
function buildLibraryStateHash(st) {
|
||||
const f = (st && st.filters) || {};
|
||||
return JSON.stringify({
|
||||
view: st.view || 'grid',
|
||||
q: st.q || '',
|
||||
sort: st.sort || 'artist',
|
||||
provider: st.provider || 'local',
|
||||
format: st.format || '',
|
||||
artist: st.artist || '',
|
||||
album: st.album || '',
|
||||
filters: {
|
||||
arr_has: [...(f.arr_has || [])].sort(),
|
||||
arr_lacks: [...(f.arr_lacks || [])].sort(),
|
||||
stem_has: [...(f.stem_has || [])].sort(),
|
||||
stem_lacks: [...(f.stem_lacks || [])].sort(),
|
||||
lyrics: f.lyrics || '',
|
||||
tunings: [...(f.tunings || [])].sort(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SCROLL_STATE_KEY = 'v3:songs-scroll-state';
|
||||
|
||||
function makeStore() {
|
||||
const data = new Map();
|
||||
return {
|
||||
getItem(k) { return data.has(k) ? data.get(k) : null; },
|
||||
setItem(k, v) { data.set(k, v); },
|
||||
removeItem(k) { data.delete(k); },
|
||||
clear() { data.clear(); },
|
||||
};
|
||||
}
|
||||
|
||||
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
|
||||
const snap = {
|
||||
hash: buildLibraryStateHash(state),
|
||||
scrollTop,
|
||||
view: state.view,
|
||||
page,
|
||||
loadedCount,
|
||||
};
|
||||
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
|
||||
}
|
||||
|
||||
function readSnapshot(storage) {
|
||||
const raw = storage.getItem(SCROLL_STATE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
const baseState = {
|
||||
view: 'grid',
|
||||
q: '',
|
||||
sort: 'artist',
|
||||
provider: 'local',
|
||||
format: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [] },
|
||||
};
|
||||
|
||||
test('buildLibraryStateHash changes when sort changes', () => {
|
||||
const a = buildLibraryStateHash(baseState);
|
||||
const b = buildLibraryStateHash({ ...baseState, sort: 'title' });
|
||||
assert.notStrictEqual(a, b);
|
||||
});
|
||||
|
||||
test('buildLibraryStateHash changes when filter provider changes', () => {
|
||||
const a = buildLibraryStateHash(baseState);
|
||||
const b = buildLibraryStateHash({ ...baseState, provider: 'remote:x' });
|
||||
assert.notStrictEqual(a, b);
|
||||
});
|
||||
|
||||
test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
|
||||
const s1 = {
|
||||
...baseState,
|
||||
filters: { ...baseState.filters, arr_has: ['Lead', 'Bass'], tunings: ['Drop D', 'E Standard'] },
|
||||
};
|
||||
const s2 = {
|
||||
...baseState,
|
||||
filters: { ...baseState.filters, arr_has: ['Bass', 'Lead'], tunings: ['E Standard', 'Drop D'] },
|
||||
};
|
||||
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
|
||||
});
|
||||
|
||||
test('snapshot stores scrollTop and page', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 1840, 3, 96);
|
||||
const snap = readSnapshot(storage);
|
||||
assert.strictEqual(snap.scrollTop, 1840);
|
||||
assert.strictEqual(snap.page, 3);
|
||||
assert.strictEqual(snap.loadedCount, 96);
|
||||
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
|
||||
});
|
||||
|
||||
test('stale snapshot is detected when filters change', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 500, 1, 48);
|
||||
const snap = readSnapshot(storage);
|
||||
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
|
||||
assert.notStrictEqual(snap.hash, changed);
|
||||
});
|
||||
|
||||
test('state hash changes when artist or album changes', () => {
|
||||
const a = buildLibraryStateHash(baseState);
|
||||
const b = buildLibraryStateHash({ ...baseState, artist: 'A Band' });
|
||||
const c = buildLibraryStateHash({ ...baseState, artist: 'A Band', album: 'A Band - LP' });
|
||||
assert.notStrictEqual(a, b);
|
||||
assert.notStrictEqual(b, c);
|
||||
});
|
||||
|
||||
test('clearing artist should use different hash than album-only selection', () => {
|
||||
const withArtist = buildLibraryStateHash({ ...baseState, artist: 'A Band', album: 'A Band - LP' });
|
||||
const cleared = buildLibraryStateHash({ ...baseState, artist: '', album: '' });
|
||||
assert.notStrictEqual(withArtist, cleared);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
'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');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`extractBlock: '${startMarker}' 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++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
||||
const endMarker = 'window.slopsmith.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
||||
const sandbox = { window: { slopsmith: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
||||
sandbox
|
||||
);
|
||||
return sandbox.exports;
|
||||
}
|
||||
|
||||
function renderSongCardBadge(song, helpers) {
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const tuningLabel = helpers.displayTuningName(song.tuning_name || song.tuning);
|
||||
if (!tuningLabel) return '';
|
||||
const rawOffsets = helpers.parseRawTuningOffsets(song.tuning_offsets)
|
||||
|| helpers.parseRawTuningOffsets(song.tuning_name || song.tuning);
|
||||
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets)
|
||||
? helpers.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
|
||||
: '';
|
||||
if (targetNotes) {
|
||||
return '<span class="absolute top-2 left-2 bg-fb-mid text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center">'
|
||||
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
|
||||
}
|
||||
return '<span class="absolute top-2 left-2 bg-fb-mid text-black text-[10px] font-bold px-1.5 py-0.5 rounded-sm">' + esc(tuningLabel) + '</span>';
|
||||
}
|
||||
|
||||
const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
test('raw offset tuning_name does not appear in rendered card HTML', () => {
|
||||
const html = renderSongCardBadge({ tuning_name: '-2 0 0 0 -2' }, helpers);
|
||||
assert.doesNotMatch(html, /-2 0 0 0 -2/);
|
||||
assert.match(html, /Custom Tuning/);
|
||||
});
|
||||
|
||||
test('custom tuning card shows low-to-high note sequence when offsets available', () => {
|
||||
const html = renderSongCardBadge({
|
||||
tuning_name: 'Custom Tuning',
|
||||
tuning_offsets: [-2, 0, 0, 0, -2, -2],
|
||||
}, helpers);
|
||||
assert.match(html, /Custom Tuning/);
|
||||
assert.match(html, /D A D G A D/);
|
||||
assert.doesNotMatch(html, /6:|D2|5th/);
|
||||
});
|
||||
|
||||
test('known tuning appears unchanged in card HTML', () => {
|
||||
const html = renderSongCardBadge({ tuning_name: 'E Standard' }, helpers);
|
||||
assert.match(html, /E Standard/);
|
||||
assert.doesNotMatch(html, /<br>/);
|
||||
});
|
||||
|
||||
test('missing tuning hides badge', () => {
|
||||
const html = renderSongCardBadge({ tuning_name: '' }, helpers);
|
||||
assert.equal(html, '');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||
|
||||
test('resolveVenueInstrumentPov maps guitar arrangements', () => {
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Lead'), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Rhythm'), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('guitar'), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Combo'), 'guitar');
|
||||
});
|
||||
|
||||
test('resolveVenueInstrumentPov maps bass drums and piano', () => {
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Bass'), 'bass');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Drums'), 'drums');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('drum'), 'drums');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Piano'), 'piano');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Keys'), 'piano');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('keyboard'), 'piano');
|
||||
});
|
||||
|
||||
test('resolveVenueInstrumentPov maps vocals and karaoke labels', () => {
|
||||
assert.equal(pov.resolveVenueInstrumentPov('Vocals'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('karaoke'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('vocal'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('lyrics'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('lyric'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('sing'), 'vocals');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('singing'), 'vocals');
|
||||
assert.equal(pov.isVocalsKaraokeArrangement('Vocals'), true);
|
||||
});
|
||||
|
||||
test('resolveVenueInstrumentPov defaults unknown to guitar', () => {
|
||||
assert.equal(pov.resolveVenueInstrumentPov(''), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov(null), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('ShowLights'), 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov('BasslineKeys'), 'guitar');
|
||||
});
|
||||
|
||||
test('POV_IDS lists supported venue POVs including vocals', () => {
|
||||
assert.deepEqual(pov.POV_IDS, ['guitar', 'bass', 'drums', 'piano', 'vocals']);
|
||||
});
|
||||
|
||||
test('integrate-venue-pov-plate.sh accepts vocals POV', () => {
|
||||
const script = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'scripts', 'integrate-venue-pov-plate.sh'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(script, /vocals/);
|
||||
assert.match(script, /guitar bass drums piano vocals/);
|
||||
assert.match(script, /\$\{POV\}-pov-bg\.png/);
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
|
||||
|
||||
test('normalizeVenueMoodSetting preserves off/subtle/full and defaults invalid', () => {
|
||||
assert.equal(venue.normalizeVenueMoodSetting('off'), 'off');
|
||||
assert.equal(venue.normalizeVenueMoodSetting('subtle'), 'subtle');
|
||||
assert.equal(venue.normalizeVenueMoodSetting('full'), 'full');
|
||||
assert.equal(venue.normalizeVenueMoodSetting('bogus'), 'subtle');
|
||||
assert.equal(venue.normalizeVenueMoodSetting(undefined), 'subtle');
|
||||
});
|
||||
|
||||
test('venueMoodClassForState maps performance states', () => {
|
||||
assert.equal(venue.venueMoodClassForState('fire'), 'venue-mood-state-fire');
|
||||
assert.equal(venue.venueMoodClassForState('smoke'), 'venue-mood-state-smoke');
|
||||
assert.equal(venue.venueMoodClassForState('unknown'), 'venue-mood-state-idle');
|
||||
});
|
||||
|
||||
test('setting persistence key is slopsmith-venue-mood-fx', () => {
|
||||
assert.equal(venue.KEY, 'slopsmith-venue-mood-fx');
|
||||
});
|
||||
|
||||
test('venue motion setting key is slopsmith-venue-motion', () => {
|
||||
assert.equal(venue.MOTION_KEY, 'slopsmith-venue-motion');
|
||||
assert.equal(venue.MOTION_DEFAULT, 'subtle');
|
||||
});
|
||||
|
||||
test('normalizeVenueMotionSetting preserves off/subtle/full and defaults invalid', () => {
|
||||
assert.equal(venue.normalizeVenueMotionSetting('off'), 'off');
|
||||
assert.equal(venue.normalizeVenueMotionSetting('subtle'), 'subtle');
|
||||
assert.equal(venue.normalizeVenueMotionSetting('full'), 'full');
|
||||
assert.equal(venue.normalizeVenueMotionSetting('bogus'), 'subtle');
|
||||
assert.equal(venue.normalizeVenueMotionSetting(undefined), 'subtle');
|
||||
});
|
||||
|
||||
test('venueMotionProfile returns zero motion for off and bounded nonzero for subtle/full', () => {
|
||||
const off = venue.venueMotionProfile('off');
|
||||
assert.equal(off.breathe, 0);
|
||||
assert.equal(off.parallax, 0);
|
||||
assert.equal(off.hazeDrift, 0);
|
||||
assert.equal(off.warmthPulse, 0);
|
||||
assert.equal(off.shimmer, 0);
|
||||
assert.equal(venue.venueMotionIntensity('off'), 0);
|
||||
|
||||
const subtle = venue.venueMotionProfile('subtle');
|
||||
const full = venue.venueMotionProfile('full');
|
||||
assert.ok(subtle.breathe > 0 && subtle.breathe < 0.02);
|
||||
assert.ok(subtle.parallax > 0 && subtle.parallax < 0.02);
|
||||
assert.ok(subtle.hazeDrift > 0 && subtle.hazeDrift < 0.05);
|
||||
assert.ok(venue.venueMotionIntensity('subtle') > 0);
|
||||
assert.ok(venue.venueMotionIntensity('subtle') < 0.05);
|
||||
|
||||
assert.ok(full.breathe > subtle.breathe);
|
||||
assert.ok(full.parallax > subtle.parallax);
|
||||
assert.ok(full.hazeDrift > subtle.hazeDrift);
|
||||
assert.ok(venue.venueMotionIntensity('full') > venue.venueMotionIntensity('subtle'));
|
||||
assert.ok(venue.venueMotionIntensity('full') < 0.1);
|
||||
});
|
||||
|
||||
test('prefersReducedMotion is a boolean helper', () => {
|
||||
assert.equal(typeof venue.prefersReducedMotion(), 'boolean');
|
||||
});
|
||||
|
||||
test('index.html contains venue motion select separate from mood fx', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="venue-motion-select"/);
|
||||
assert.match(html, /Venue Motion/);
|
||||
assert.match(html, /id="venue-mood-fx-select"/);
|
||||
assert.match(html, /Venue Motion adds subtle background parallax only/);
|
||||
});
|
||||
|
||||
test('STRIP_OVERLAY_ENABLED is false until real venue scene assets ship', () => {
|
||||
assert.equal(venue.STRIP_OVERLAY_ENABLED, false);
|
||||
});
|
||||
|
||||
test('shouldShowStripOverlay is false while strip overlay is disabled', () => {
|
||||
assert.equal(venue.shouldShowStripOverlay('full', 'venue', true, true), false);
|
||||
assert.equal(venue.shouldShowStripOverlay('full', 'default', false, true), false);
|
||||
});
|
||||
test('shouldEnableVenueMood respects off and highway_3d', () => {
|
||||
assert.equal(venue.shouldEnableVenueMood('off', 'default', false), false);
|
||||
assert.equal(venue.shouldEnableVenueMood('subtle', 'highway_3d', false), false);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'default', false), true);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'venue', true), true);
|
||||
assert.equal(venue.shouldEnableVenueMood('subtle', 'auto', true), false);
|
||||
assert.equal(venue.shouldEnableVenueMood('subtle', 'auto', false), true);
|
||||
});
|
||||
|
||||
test('isSuppressedBy3d only when plain 3D viz is active', () => {
|
||||
assert.equal(venue.isSuppressedBy3d('full', 'highway_3d', false), true);
|
||||
assert.equal(venue.isSuppressedBy3d('full', 'venue', true), false);
|
||||
assert.equal(venue.isSuppressedBy3d('full', 'default', true), false);
|
||||
assert.equal(venue.isSuppressedBy3d('off', 'highway_3d', false), false);
|
||||
});
|
||||
|
||||
test('isElementDisplayed ignores display:none overlays', () => {
|
||||
assert.equal(venue.isElementDisplayed({ style: { display: 'none' } }), false);
|
||||
assert.equal(venue.isElementDisplayed({ style: { display: 'block' } }), true);
|
||||
assert.equal(venue.isElementDisplayed(null), false);
|
||||
});
|
||||
|
||||
test('auto mode stays enabled when stale hidden 3D wrap exists in DOM', () => {
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'auto', false), true);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'auto', true), false);
|
||||
});
|
||||
|
||||
test('index.html contains venue markup and script order', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="v3-venue-mood-fx"/);
|
||||
assert.match(html, /class="venue-mood-lights"/);
|
||||
assert.match(html, /class="venue-mood-crowd"/);
|
||||
assert.match(html, /class="venue-mood-haze"/);
|
||||
assert.match(html, /id="venue-mood-fx-select"/);
|
||||
assert.match(html, /id="venue-motion-select"/);
|
||||
assert.match(html, /id="venue-viz-mode-hint"/);
|
||||
assert.match(html, /id="venue-mood-fx-3d-hint"/);
|
||||
assert.match(html, /id="v3-venue-mode-badge"/);
|
||||
assert.match(html, /id="v3-venue-scene-wash"/);
|
||||
assert.match(html, /venue-viz\.js/);
|
||||
const hudIdx = html.indexOf('live-performance-hud.js');
|
||||
const venueIdx = html.indexOf('venue-mood-fx.js');
|
||||
assert.ok(hudIdx !== -1 && venueIdx !== -1 && venueIdx > hudIdx);
|
||||
});
|
||||
|
||||
test('CSS disables bottom strip and keeps transport above overlays', () => {
|
||||
const css = fs.readFileSync(V3_CSS, 'utf8');
|
||||
assert.match(css, /\.venue-mood-fx[\s\S]*display:\s*none/);
|
||||
assert.match(css, /\.venue-mood-fx[\s\S]*pointer-events:\s*none/);
|
||||
assert.match(css, /\.v3-venue-scene-wash[\s\S]*z-index:\s*3/);
|
||||
assert.match(css, /\.v3-venue-mode-badge[\s\S]*z-index:\s*18/);
|
||||
assert.match(css, /#player \.v3-transport[\s\S]*z-index:\s*20/);
|
||||
});
|
||||
|
||||
test('CSS includes reduced-motion rule for venue animations', () => {
|
||||
const css = fs.readFileSync(V3_CSS, 'utf8');
|
||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.venue-mood-fx/);
|
||||
});
|
||||
|
||||
test('off setting hides venue layer via applyClasses', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
layer.classList.add('hidden');
|
||||
venue.applyClasses(player, layer, 'off', 'fire', false);
|
||||
assert.match(player.className, /venue-mood-off/);
|
||||
assert.match(player.className, /venue-mood-state-fire/);
|
||||
assert.match(layer.className, /hidden/);
|
||||
});
|
||||
|
||||
test('applyClasses can still unhide layer when strip overlay is explicitly enabled', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
layer.classList.add('hidden');
|
||||
venue.applyClasses(player, layer, 'full', 'idle', true);
|
||||
assert.match(player.className, /venue-mood-full/);
|
||||
assert.equal(layer.className.includes('hidden'), false);
|
||||
});
|
||||
|
||||
test('event integration still applies mood state classes without showing strip', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
emit(event, detail) {
|
||||
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
|
||||
},
|
||||
};
|
||||
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
const runtime = venue.bindRuntime(sm, { player, layer });
|
||||
runtime.beginSession();
|
||||
|
||||
sm.emit('v3:live-performance-state', {
|
||||
hits: 95,
|
||||
misses: 5,
|
||||
judged: 100,
|
||||
streak: 12,
|
||||
bestStreak: 12,
|
||||
accuracyPct: 95,
|
||||
state: 'fire',
|
||||
});
|
||||
|
||||
assert.match(player.className, /venue-mood-state-fire/);
|
||||
assert.match(layer.className, /venue-mood-state-fire/);
|
||||
assert.equal(layer.className.includes('hidden'), true);
|
||||
assert.equal(runtime.getCurrentState(), 'fire');
|
||||
});
|
||||
|
||||
test('smoke state applies smoke classes on layer', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
emit(event, detail) {
|
||||
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
|
||||
},
|
||||
};
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
const runtime = venue.bindRuntime(sm, { player, layer });
|
||||
runtime.beginSession();
|
||||
sm.emit('v3:live-performance-state', { state: 'smoke', hits: 1, misses: 9, judged: 10, streak: 0, bestStreak: 0, accuracyPct: 10 });
|
||||
assert.match(player.className, /venue-mood-state-smoke/);
|
||||
assert.match(layer.className, /venue-mood-state-smoke/);
|
||||
});
|
||||
|
||||
test('3D suppression clears when viz mode returns to default or venue', () => {
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'highway_3d', false), false);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'default', false), true);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'venue', true), true);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'auto', false), true);
|
||||
});
|
||||
|
||||
test('venue visualization session hides strip and marks scene pending', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; this.value = 'venue'; this.dataset = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
toggle: (c, force) => {
|
||||
const has = this.className.split(/\s+/).includes(c);
|
||||
const on = force === undefined ? !has : !!force;
|
||||
if (on && !has) this.classList.add(c);
|
||||
else if (!on && has) this.classList.remove(c);
|
||||
},
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
addEventListener() {}
|
||||
}
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
emit(event, detail) {
|
||||
(listeners.get(event) || []).forEach((fn) => fn({ detail }));
|
||||
},
|
||||
};
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
const hintVenue = new El();
|
||||
hintVenue.classList.add('hidden');
|
||||
const hint3d = new El();
|
||||
hint3d.classList.add('hidden');
|
||||
const badge = new El();
|
||||
badge.classList.add('hidden');
|
||||
const sceneWash = new El();
|
||||
sceneWash.classList.add('hidden');
|
||||
const vizPicker = new El();
|
||||
vizPicker.value = 'venue';
|
||||
const storage = new Map([['slopsmith-venue-mood-fx', 'full']]);
|
||||
const origDocument = global.document;
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (id === 'viz-picker') return vizPicker;
|
||||
if (id === 'venue-mood-fx-select') return null;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [{ style: { display: 'block' } }];
|
||||
},
|
||||
};
|
||||
global.localStorage = {
|
||||
getItem(k) { return storage.has(k) ? storage.get(k) : null; },
|
||||
setItem(k, v) { storage.set(k, String(v)); },
|
||||
};
|
||||
try {
|
||||
const runtime = venue.bindRuntime(sm, { player, layer, hintVenue, hint3d, badge, sceneWash });
|
||||
runtime.beginSession();
|
||||
const st = runtime.getState();
|
||||
assert.equal(st.isVenueVisualization, true);
|
||||
assert.equal(st.visible, false);
|
||||
assert.equal(st.venueScenePending, true);
|
||||
assert.equal(layer.className.includes('hidden'), true);
|
||||
assert.match(player.className, /is-venue-visualization/);
|
||||
assert.match(player.className, /venue-scene-pending/);
|
||||
assert.equal(hintVenue.className.includes('hidden'), false);
|
||||
// V2: no DOM placeholder badge during Venue playback
|
||||
assert.equal(badge.className.includes('hidden'), true);
|
||||
assert.equal(sceneWash.className.includes('hidden'), true);
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('plain 3D highway does not show venue placeholder', () => {
|
||||
class El {
|
||||
constructor() { this.className = 'hidden'; this.value = 'highway_3d'; this.dataset = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
toggle: (c, force) => {
|
||||
const has = this.className.split(/\s+/).includes(c);
|
||||
const on = force === undefined ? !has : !!force;
|
||||
if (on && !has) this.classList.add(c);
|
||||
else if (!on && has) this.classList.remove(c);
|
||||
},
|
||||
};
|
||||
setAttribute() {}
|
||||
addEventListener() {}
|
||||
}
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
};
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
const badge = new El();
|
||||
badge.classList.add('hidden');
|
||||
const sceneWash = new El();
|
||||
sceneWash.classList.add('hidden');
|
||||
const vizPicker = new El();
|
||||
vizPicker.value = 'highway_3d';
|
||||
const origDocument = global.document;
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (id === 'viz-picker') return vizPicker;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
global.localStorage = {
|
||||
getItem() { return 'full'; },
|
||||
setItem() {},
|
||||
};
|
||||
try {
|
||||
const runtime = venue.bindRuntime(sm, { player, layer, badge, sceneWash });
|
||||
runtime.refreshVisibility();
|
||||
assert.equal(player.className.includes('is-venue-visualization'), false);
|
||||
assert.match(badge.className, /hidden/);
|
||||
assert.match(sceneWash.className, /hidden/);
|
||||
assert.equal(runtime.getState().isVenueVisualization, false);
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime listens for viz renderer events to refresh visibility', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-mood-fx.js'), 'utf8');
|
||||
assert.match(source, /sm\.on\('viz:renderer:ready', refreshVisibility\)/);
|
||||
assert.match(source, /sm\.on\('viz:reverted', refreshVisibility\)/);
|
||||
});
|
||||
|
||||
test('window.v3VenueMoodFx exposes getState with venue flag', () => {
|
||||
assert.equal(typeof venue.getState, 'function');
|
||||
const st = venue.getState();
|
||||
assert.equal(typeof st.setting, 'string');
|
||||
assert.equal(typeof st.enabled, 'boolean');
|
||||
assert.equal(typeof st.suppressedBy3d, 'boolean');
|
||||
assert.equal(typeof st.isVenueVisualization, 'boolean');
|
||||
});
|
||||
|
||||
test('setMotion persists slopsmith-venue-motion and syncs renderer', () => {
|
||||
const storage = new Map();
|
||||
let synced = null;
|
||||
global.localStorage = {
|
||||
getItem(k) { return storage.has(k) ? storage.get(k) : null; },
|
||||
setItem(k, v) { storage.set(k, String(v)); },
|
||||
};
|
||||
globalThis.h3dVenueSceneSetMotionMode = (mode) => { synced = mode; };
|
||||
const origDocument = global.document;
|
||||
global.document = { getElementById: () => null };
|
||||
try {
|
||||
assert.equal(venue.setMotion('full'), 'full');
|
||||
assert.equal(storage.get('slopsmith-venue-motion'), 'full');
|
||||
assert.equal(synced, 'full');
|
||||
assert.equal(venue.setMotion('nope'), 'subtle');
|
||||
assert.equal(synced, 'subtle');
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
delete globalThis.h3dVenueSceneSetMotionMode;
|
||||
}
|
||||
});
|
||||
|
||||
test('bindRuntime wires venue motion select', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; this.value = ''; this.dataset = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute() {}
|
||||
addEventListener(_ev, fn) { this._fn = fn; }
|
||||
}
|
||||
const listeners = new Map();
|
||||
const sm = { on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
} };
|
||||
const motionSel = new El();
|
||||
const origDocument = global.document;
|
||||
let synced = null;
|
||||
globalThis.h3dVenueSceneSetMotionMode = (mode) => { synced = mode; };
|
||||
global.localStorage = {
|
||||
getItem(k) { return k === 'slopsmith-venue-motion' ? 'subtle' : null; },
|
||||
setItem() {},
|
||||
};
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (id === 'venue-motion-select') return motionSel;
|
||||
if (id === 'venue-mood-fx-select') return null;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
try {
|
||||
venue.bindRuntime(sm, { player: new El(), layer: new El() });
|
||||
assert.equal(motionSel.dataset.venueMotionBound, '1');
|
||||
assert.equal(motionSel.value, 'subtle');
|
||||
assert.equal(synced, 'subtle');
|
||||
motionSel.value = 'off';
|
||||
motionSel._fn();
|
||||
assert.equal(synced, 'off');
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
delete globalThis.h3dVenueSceneSetMotionMode;
|
||||
}
|
||||
});
|
||||
|
||||
test('song stop ends session and hides venue layer', () => {
|
||||
class El {
|
||||
constructor() { this.className = ''; this.attrs = {}; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
};
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
}
|
||||
|
||||
const listeners = new Map();
|
||||
const sm = {
|
||||
on(event, fn) {
|
||||
const list = listeners.get(event) || [];
|
||||
list.push(fn);
|
||||
listeners.set(event, list);
|
||||
},
|
||||
};
|
||||
|
||||
const player = new El();
|
||||
const layer = new El();
|
||||
const runtime = venue.bindRuntime(sm, { player, layer });
|
||||
runtime.beginSession();
|
||||
assert.equal(runtime.getSessionActive(), true);
|
||||
|
||||
runtime.endSession();
|
||||
assert.equal(runtime.getSessionActive(), false);
|
||||
assert.match(layer.className, /hidden/);
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const venueScene = require('../../static/v3/venue-scene-3d.js');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
|
||||
|
||||
test('small-club venue scene asset files exist', () => {
|
||||
assert.ok(fs.existsSync(path.join(ASSET_DIR, 'manifest.json')));
|
||||
assert.ok(fs.existsSync(path.join(ASSET_DIR, 'bg-plate.png')));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ASSET_DIR, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.id, 'small-club');
|
||||
assert.equal(manifest.name, 'Small Club');
|
||||
assert.equal(manifest.type, 'generated-original');
|
||||
assert.equal(manifest.version, 10);
|
||||
assert.equal(manifest.assets.bgPlate, 'bg-plate.webp');
|
||||
assert.equal(manifest.assets.fallbackBgPlate, 'bg-plate.png');
|
||||
assert.equal(manifest.instrumentPlates.guitar.png, 'guitar-pov-bg.png');
|
||||
assert.equal(manifest.instrumentPlates.bass.webp, 'bass-pov-bg.webp');
|
||||
assert.equal(manifest.instrumentPlates.drums.png, 'drums-pov-bg.png');
|
||||
assert.equal(manifest.instrumentPlates.piano.png, 'piano-pov-bg.png');
|
||||
assert.equal(manifest.instrumentPlates.vocals.png, 'vocals-pov-bg.png');
|
||||
assert.equal(manifest.instrumentPlates.vocals.webp, 'vocals-pov-bg.webp');
|
||||
for (const optionalVocals of ['vocals-pov-bg.png', 'vocals-pov-bg.webp']) {
|
||||
const vocalsPath = path.join(ASSET_DIR, optionalVocals);
|
||||
if (fs.existsSync(vocalsPath)) {
|
||||
const st = fs.statSync(vocalsPath);
|
||||
assert.ok(st.isFile() && st.size > 0, `${optionalVocals} must be a non-empty file when installed`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('crowd SVG uses connected head-and-shoulder silhouettes', () => {
|
||||
const svg = fs.readFileSync(path.join(ASSET_DIR, 'crowd-silhouette.svg'), 'utf8');
|
||||
assert.match(svg, /crowd-silhouette/);
|
||||
assert.match(svg, /id="crowd-front-row"/);
|
||||
assert.match(svg, /id="crowd-rear-row"/);
|
||||
assert.match(svg, /class="crowd-hand"/);
|
||||
assert.doesNotMatch(svg, /<ellipse[^>]*rx=/);
|
||||
assert.doesNotMatch(svg, /class="crowd-arm"/);
|
||||
assert.doesNotMatch(svg, /class="crowd-person"/);
|
||||
});
|
||||
|
||||
test('backdrop SVG includes club stage elements', () => {
|
||||
const svg = fs.readFileSync(path.join(ASSET_DIR, 'venue-backdrop.svg'), 'utf8');
|
||||
assert.match(svg, /id="stage-curtain"/);
|
||||
assert.match(svg, /class="speaker-stack"/);
|
||||
assert.match(svg, /id="stage-platform"/);
|
||||
assert.match(svg, /id="lighting-truss"/);
|
||||
assert.match(svg, /id="side-wall-left"/);
|
||||
assert.match(svg, /id="side-wall-right"/);
|
||||
});
|
||||
|
||||
test('stage lights SVG uses beam shapes not soft circles', () => {
|
||||
const svg = fs.readFileSync(path.join(ASSET_DIR, 'stage-lights.svg'), 'utf8');
|
||||
assert.match(svg, /spot-beam/);
|
||||
assert.match(svg, /id="spot-beam-center"/);
|
||||
assert.doesNotMatch(svg, /<ellipse[^>]*rx=/);
|
||||
});
|
||||
|
||||
test('highway_3d venue plate chain falls back to generic bg-plate', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
const chainFn = src.match(/function _venuePlateUrlChain\(pov\)\s*\{[\s\S]*?\n\s*\}/);
|
||||
assert.ok(chainFn, '_venuePlateUrlChain missing');
|
||||
assert.match(chainFn[0], /plate\.webp/);
|
||||
assert.match(chainFn[0], /plate\.png/);
|
||||
assert.match(chainFn[0], /VENUE_BG_PLATE_WEBP/);
|
||||
assert.match(chainFn[0], /VENUE_BG_PLATE_PNG/);
|
||||
});
|
||||
|
||||
test('highway_3d venue style loads instrument POV plates with fallback', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
assert.match(src, /VENUE_SCENE_ASSET_BASE\s*=\s*'\/static\/assets\/venue\/themes\/small-club\/'/);
|
||||
assert.match(src, /VENUE_INSTRUMENT_PLATES/);
|
||||
assert.match(src, /guitar-pov-bg\.png/);
|
||||
assert.match(src, /bass-pov-bg\.png/);
|
||||
assert.match(src, /drums-pov-bg\.png/);
|
||||
assert.match(src, /piano-pov-bg\.png/);
|
||||
assert.match(src, /vocals-pov-bg\.png/);
|
||||
assert.match(src, /karaoke|vocal|vocals/);
|
||||
assert.match(src, /VENUE_BG_PLATE_PNG\s*=\s*'bg-plate\.png'/);
|
||||
assert.match(src, /VENUE_BG_PLATE_WEBP\s*=\s*'bg-plate\.webp'/);
|
||||
assert.match(src, /_venuePlateUrlChain/);
|
||||
assert.match(src, /_venueLoadPlateForPov/);
|
||||
assert.match(src, /_venueTextureCache/);
|
||||
assert.match(src, /h3dVenueSceneSetInstrumentPov/);
|
||||
assert.match(src, /venue:\s*\{/);
|
||||
assert.match(src, /h3dVenueSceneSetActive/);
|
||||
assert.match(src, /h3dVenueSceneSetMood/);
|
||||
assert.match(src, /_bgEffectiveStyleId/);
|
||||
const venueBlock = src.match(/venue:\s*\{[\s\S]*?teardown\(s\)\s*\{[\s\S]*?\},\s*\n\s*\},/);
|
||||
assert.ok(venueBlock, 'venue style block missing');
|
||||
assert.match(venueBlock[0], /_venueLoadPlateForPov/);
|
||||
assert.doesNotMatch(venueBlock[0], /venue-backdrop\.svg/);
|
||||
assert.match(src, /VENUE_HAZE_STEADY\s*=\s*0\.008/);
|
||||
});
|
||||
|
||||
test('plain 3D image style does not load venue POV plate', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
const imageBlock = src.match(/image:\s*\{[\s\S]*?teardown\(s\)/);
|
||||
assert.ok(imageBlock, 'image style block missing');
|
||||
assert.doesNotMatch(imageBlock[0], /guitar-pov-bg/);
|
||||
assert.doesNotMatch(imageBlock[0], /_venueLoadPlateForPov/);
|
||||
});
|
||||
|
||||
test('venue-scene-3d syncs instrument POV from arrangement signal', () => {
|
||||
global.h3dVenueSceneSetActive = () => {};
|
||||
global.h3dVenueSceneSetMood = () => {};
|
||||
global.h3dVenueSceneSetInstrumentPov = (input) => { global._venuePovInput = input; };
|
||||
global.h3dVenueSceneGetState = () => ({});
|
||||
global.highway = { getSongInfo: () => ({ arrangement: 'Bass' }) };
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.slopsmith = { on() {} };
|
||||
try {
|
||||
venueScene.activate();
|
||||
assert.equal(global._venuePovInput, 'Bass');
|
||||
assert.equal(venueScene.getState().instrumentPov, 'bass');
|
||||
global.highway.getSongInfo = () => ({ arrangement: 'Drums' });
|
||||
venueScene.syncInstrumentPov();
|
||||
assert.equal(global._venuePovInput, 'Drums');
|
||||
global.highway.getSongInfo = () => ({ arrangement: 'Vocals' });
|
||||
venueScene.syncInstrumentPov();
|
||||
assert.equal(global._venuePovInput, 'Vocals');
|
||||
assert.equal(venueScene.getState().instrumentPov, 'vocals');
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
delete global.h3dVenueSceneSetInstrumentPov;
|
||||
delete global.h3dVenueSceneGetState;
|
||||
delete global.highway;
|
||||
delete global.v3VenueViz;
|
||||
delete global.v3VenueInstrumentPov;
|
||||
delete global.slopsmith;
|
||||
delete global._venuePovInput;
|
||||
}
|
||||
});
|
||||
|
||||
test('lyrics visibility during guitar practice does not force vocals POV', () => {
|
||||
global.h3dVenueSceneSetActive = () => {};
|
||||
global.h3dVenueSceneSetMood = () => {};
|
||||
global.h3dVenueSceneSetInstrumentPov = (input) => { global._venuePovInput = input; };
|
||||
global.h3dVenueSceneGetState = () => ({});
|
||||
global.highway = {
|
||||
getSongInfo: () => ({ arrangement: 'Lead' }),
|
||||
getLyricsVisible: () => true,
|
||||
};
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.slopsmith = { on() {} };
|
||||
try {
|
||||
venueScene.activate();
|
||||
assert.equal(global._venuePovInput, 'Lead');
|
||||
assert.equal(venueScene.getState().instrumentPov, 'guitar');
|
||||
assert.equal(pov.resolveVenueInstrumentPov(global._venuePovInput), 'guitar');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js'), 'utf8');
|
||||
const codeOnly = src.replace(/\/\/[^\n]*/g, '');
|
||||
assert.doesNotMatch(codeOnly, /\.getLyricsVisible\s*\(/);
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
delete global.h3dVenueSceneSetInstrumentPov;
|
||||
delete global.h3dVenueSceneGetState;
|
||||
delete global.highway;
|
||||
delete global.v3VenueViz;
|
||||
delete global.v3VenueInstrumentPov;
|
||||
delete global.slopsmith;
|
||||
delete global._venuePovInput;
|
||||
}
|
||||
});
|
||||
|
||||
test('venue-scene-3d exports bg plate asset ids', () => {
|
||||
assert.equal(venueScene.BG_PLATE, 'bg-plate.png');
|
||||
assert.equal(venueScene.BG_PLATE_WEBP, 'bg-plate.webp');
|
||||
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
|
||||
});
|
||||
|
||||
test('app.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
||||
});
|
||||
|
||||
test('index.html loads venue deps before venue-scene-3d', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
const povIdx = html.indexOf('venue-instrument-pov.js');
|
||||
const vizIdx = html.indexOf('venue-viz.js');
|
||||
const sceneIdx = html.indexOf('venue-scene-3d.js');
|
||||
const moodIdx = html.indexOf('venue-mood-fx.js');
|
||||
assert.ok(povIdx !== -1 && sceneIdx !== -1);
|
||||
assert.ok(povIdx < sceneIdx);
|
||||
// venue-scene-3d's boot reads window.v3VenueMoodFx.getMotion() synchronously,
|
||||
// so venue-viz and venue-mood-fx must both load before it; otherwise first
|
||||
// paint falls back to 'subtle' and ignores a saved 'off'/'full' motion pref.
|
||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||
});
|
||||
|
||||
test('syncViz activates only for venue visualization id', () => {
|
||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
global.h3dVenueSceneGetState = () => ({ active: !!global._h3dActive, assetsLoaded: false, loadFailed: false });
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.slopsmith = { on() {} };
|
||||
try {
|
||||
venueScene.deactivate();
|
||||
venueScene.syncViz('highway_3d');
|
||||
assert.equal(global._h3dActive, false);
|
||||
venueScene.syncViz('venue');
|
||||
assert.equal(global._h3dActive, true);
|
||||
assert.equal(venueScene.getState().active, true);
|
||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
delete global.h3dVenueSceneSetInstrumentPov;
|
||||
delete global.h3dVenueSceneGetState;
|
||||
delete global.v3VenueViz;
|
||||
delete global.v3VenueInstrumentPov;
|
||||
delete global.slopsmith;
|
||||
delete global._h3dActive;
|
||||
delete global._h3dMood;
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldShowDomPlaceholder stays false so badge is not shown', () => {
|
||||
global.v3VenueViz = {
|
||||
getSelectedVizId: () => 'venue',
|
||||
isVenueVisualization: (v) => v === 'venue',
|
||||
readVizSelection: () => 'venue',
|
||||
};
|
||||
try {
|
||||
venueScene.syncViz('venue');
|
||||
assert.equal(venueScene.shouldShowDomPlaceholder(), false);
|
||||
venueScene.onAssetsLoaded();
|
||||
assert.equal(venueScene.shouldShowDomPlaceholder(), false);
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.v3VenueViz;
|
||||
}
|
||||
});
|
||||
|
||||
test('live performance state forwards mood to highway venue scene API', () => {
|
||||
global.h3dVenueSceneSetActive = () => {};
|
||||
global.h3dVenueSceneSetMood = (s) => { global._mood = s; };
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
global.h3dVenueSceneGetState = () => ({});
|
||||
global.v3VenueViz = {
|
||||
getSelectedVizId: () => 'venue',
|
||||
isVenueVisualization: () => true,
|
||||
readVizSelection: () => 'venue',
|
||||
};
|
||||
global.slopsmith = { on() {} };
|
||||
try {
|
||||
venueScene.activate();
|
||||
venueScene.onPerformanceState({ detail: { state: 'fire' } });
|
||||
assert.equal(global._mood, 'fire');
|
||||
venueScene.onPerformanceState({ detail: { state: 'smoke' } });
|
||||
assert.equal(global._mood, 'smoke');
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
delete global.h3dVenueSceneSetInstrumentPov;
|
||||
delete global.h3dVenueSceneGetState;
|
||||
delete global.v3VenueViz;
|
||||
delete global.slopsmith;
|
||||
delete global._mood;
|
||||
}
|
||||
});
|
||||
|
||||
test('venue viz still maps renderer to highway_3d', () => {
|
||||
assert.equal(venueViz.resolveRendererVizId('venue'), 'highway_3d');
|
||||
assert.equal(venueViz.isVenueVisualization('highway_3d'), false);
|
||||
});
|
||||
|
||||
test('STRIP_OVERLAY_ENABLED remains false in venue mood fx', () => {
|
||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||
assert.equal(venue.STRIP_OVERLAY_ENABLED, false);
|
||||
});
|
||||
|
||||
test('highway_3d venue style exposes motion APIs and background-only motion', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
assert.match(src, /h3dVenueSceneSetMotionMode/);
|
||||
assert.match(src, /h3dVenueSceneGetState/);
|
||||
assert.match(src, /motionMode/);
|
||||
assert.match(src, /motionEffective/);
|
||||
assert.match(src, /motionEnabled/);
|
||||
assert.match(src, /motionIntensity/);
|
||||
assert.match(src, /_venueApplyFakeDepthMotion/);
|
||||
assert.match(src, /_venueEffectiveMotionMode/);
|
||||
assert.match(src, /_venuePrefersReducedMotion/);
|
||||
assert.match(src, /function _venueApplyFakeDepthMotion[\s\S]*?return motion;\s*\n\s*\}/);
|
||||
assert.match(src, /function _venueApplyFakeDepthMotion[\s\S]*?s\.backdrop[\s\S]*?s\.haze/);
|
||||
const venueUpdate = src.match(/venue:\s*\{[\s\S]*?update\(s, bands, dt, t\)\s*\{[\s\S]*?\},\s*\n\s*teardown/);
|
||||
assert.ok(venueUpdate, 'venue update block missing');
|
||||
assert.match(venueUpdate[0], /_venueApplyFakeDepthMotion/);
|
||||
assert.doesNotMatch(venueUpdate[0], /STRIP_OVERLAY/);
|
||||
});
|
||||
|
||||
test('off motion mode zeros profile and venue inactive forces effective off', () => {
|
||||
const src = fs.readFileSync(H3D_JS, 'utf8');
|
||||
assert.match(src, /function _venueMotionProfile[\s\S]*breathe:\s*0,\s*parallax:\s*0/);
|
||||
assert.match(src, /function _venueEffectiveMotionMode\(\)[\s\S]*!_venueSceneOverride[\s\S]*return 'off'/);
|
||||
});
|
||||
|
||||
test('venue-scene-3d syncs motion on activate', () => {
|
||||
global.h3dVenueSceneSetActive = () => {};
|
||||
global.h3dVenueSceneSetMood = () => {};
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
global.h3dVenueSceneSetMotionMode = (mode) => { global._venueMotion = mode; };
|
||||
global.h3dVenueSceneGetState = () => ({});
|
||||
global.v3VenueMoodFx = { getMotion: () => 'full' };
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.slopsmith = { on() {} };
|
||||
try {
|
||||
venueScene.activate();
|
||||
assert.equal(global._venueMotion, 'full');
|
||||
global.v3VenueMoodFx.getMotion = () => 'off';
|
||||
venueScene.syncVenueMotion();
|
||||
assert.equal(global._venueMotion, 'off');
|
||||
} finally {
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
delete global.h3dVenueSceneSetInstrumentPov;
|
||||
delete global.h3dVenueSceneSetMotionMode;
|
||||
delete global.h3dVenueSceneGetState;
|
||||
delete global.v3VenueMoodFx;
|
||||
delete global.v3VenueViz;
|
||||
delete global.v3VenueInstrumentPov;
|
||||
delete global.slopsmith;
|
||||
delete global._venueMotion;
|
||||
}
|
||||
});
|
||||
|
||||
test('full motion intensity is stronger than subtle in profile table', () => {
|
||||
const venueMood = require('../../static/v3/venue-mood-fx.js');
|
||||
assert.ok(venueMood.venueMotionIntensity('full') > venueMood.venueMotionIntensity('subtle'));
|
||||
assert.equal(venueMood.venueMotionIntensity('off'), 0);
|
||||
});
|
||||
|
||||
test('runtime safety: motion pass does not touch scoring detection timing or audio', () => {
|
||||
const venueMood = require('../../static/v3/venue-mood-fx.js');
|
||||
const forbidden = [
|
||||
'plugins/note_detection',
|
||||
'plugins/scoring',
|
||||
'static/audio',
|
||||
'AudioEngine',
|
||||
'slopsmith-desktop',
|
||||
];
|
||||
const allowedTouched = [
|
||||
'plugins/highway_3d/screen.js',
|
||||
'static/v3/venue-mood-fx.js',
|
||||
'static/v3/venue-scene-3d.js',
|
||||
'static/v3/index.html',
|
||||
];
|
||||
for (const rel of allowedTouched) {
|
||||
assert.ok(fs.existsSync(path.join(__dirname, '..', '..', rel)), rel);
|
||||
}
|
||||
const h3d = fs.readFileSync(H3D_JS, 'utf8');
|
||||
assert.doesNotMatch(h3d, /v3-venue-mode-badge.*remove\('hidden'\)/);
|
||||
assert.equal(venueMood.STRIP_OVERLAY_ENABLED, false);
|
||||
for (const token of forbidden) {
|
||||
assert.doesNotMatch(h3d, new RegExp(token.replace(/\//g, '\\/'), 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
test('controls z-index remains above venue scene wash in CSS', () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css'), 'utf8');
|
||||
assert.match(css, /\.v3-venue-scene-wash[\s\S]*z-index:\s*3/);
|
||||
assert.match(css, /#player \.v3-transport[\s\S]*z-index:\s*20/);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
|
||||
|
||||
test('venue-viz constants and renderer mapping', () => {
|
||||
assert.equal(venueViz.VENUE_VIZ_ID, 'venue');
|
||||
assert.equal(venueViz.RENDERER_VIZ_ID, 'highway_3d');
|
||||
assert.equal(venueViz.isVenueVisualization('venue'), true);
|
||||
assert.equal(venueViz.isVenueVisualization('highway_3d'), false);
|
||||
assert.equal(venueViz.resolveRendererVizId('venue'), 'highway_3d');
|
||||
assert.equal(venueViz.resolveRendererVizId('default'), 'default');
|
||||
});
|
||||
|
||||
test('setSelectedVizId preserves venue while renderer maps to highway_3d', () => {
|
||||
class El {
|
||||
constructor() { this.className = 'hidden'; this.id = ''; }
|
||||
classList = {
|
||||
add: (c) => { if (!this.className.includes(c)) this.className += (this.className ? ' ' : '') + c; },
|
||||
remove: (c) => { this.className = this.className.split(/\s+/).filter((x) => x && x !== c).join(' '); },
|
||||
toggle: (c, force) => {
|
||||
const has = this.className.split(/\s+/).includes(c);
|
||||
const on = force === undefined ? !has : !!force;
|
||||
if (on && !has) this.classList.add(c);
|
||||
else if (!on && has) this.classList.remove(c);
|
||||
},
|
||||
contains: (c) => this.className.split(/\s+/).includes(c),
|
||||
};
|
||||
}
|
||||
const player = new El();
|
||||
player.id = 'player';
|
||||
const badge = new El();
|
||||
badge.id = 'v3-venue-mode-badge';
|
||||
const wash = new El();
|
||||
wash.id = 'v3-venue-scene-wash';
|
||||
const picker = new El();
|
||||
picker.id = 'viz-picker';
|
||||
picker.value = 'venue';
|
||||
const storage = new Map([['vizSelection', 'venue']]);
|
||||
const origDocument = global.document;
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (id === 'player') return player;
|
||||
if (id === 'v3-venue-mode-badge') return badge;
|
||||
if (id === 'v3-venue-scene-wash') return wash;
|
||||
if (id === 'viz-picker') return picker;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
global.localStorage = {
|
||||
getItem(k) { return storage.has(k) ? storage.get(k) : null; },
|
||||
setItem(k, v) { storage.set(k, String(v)); },
|
||||
};
|
||||
try {
|
||||
venueViz.setSelectedVizId('venue');
|
||||
venueViz.notifyRendererInstalled('highway_3d');
|
||||
assert.equal(venueViz.getSelectedVizId(), 'venue');
|
||||
assert.equal(venueViz.resolveRendererVizId(venueViz.getSelectedVizId()), 'highway_3d');
|
||||
assert.match(player.className, /is-venue-visualization/);
|
||||
// V2: DOM placeholder badge never shown during Venue mode
|
||||
assert.equal(badge.className.includes('hidden'), true);
|
||||
assert.equal(wash.className.includes('hidden'), true);
|
||||
|
||||
venueViz.setSelectedVizId('highway_3d');
|
||||
venueViz.notifyRendererInstalled('highway_3d');
|
||||
assert.equal(venueViz.getSelectedVizId(), 'highway_3d');
|
||||
assert.equal(player.className.includes('is-venue-visualization'), false);
|
||||
assert.match(badge.className, /hidden/);
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
venueViz.setSelectedVizId(null);
|
||||
venueViz.notifyRendererInstalled(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('getState reports venue active with separate renderer id', () => {
|
||||
class El {
|
||||
constructor() { this.className = 'is-venue-visualization'; }
|
||||
classList = {
|
||||
contains: (c) => this.className.split(/\s+/).includes(c),
|
||||
};
|
||||
}
|
||||
const player = new El();
|
||||
const picker = { value: 'venue' };
|
||||
const storage = new Map([['vizSelection', 'venue']]);
|
||||
const origDocument = global.document;
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (id === 'player') return player;
|
||||
if (id === 'viz-picker') return picker;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
global.localStorage = {
|
||||
getItem(k) { return storage.has(k) ? storage.get(k) : null; },
|
||||
setItem(k, v) { storage.set(k, String(v)); },
|
||||
};
|
||||
try {
|
||||
venueViz.setSelectedVizId('venue');
|
||||
venueViz.notifyRendererInstalled('highway_3d');
|
||||
const st = venueViz.getState();
|
||||
assert.equal(st.selectedViz, 'venue');
|
||||
assert.equal(st.storedVizSelection, 'venue');
|
||||
assert.equal(st.activeRendererId, 'highway_3d');
|
||||
assert.equal(st.isVenueVisualization, true);
|
||||
assert.equal(st.playerHasVenueClass, true);
|
||||
assert.equal(st.hasVenueMoodApi, false);
|
||||
} finally {
|
||||
global.document = origDocument;
|
||||
delete global.localStorage;
|
||||
venueViz.setSelectedVizId(null);
|
||||
venueViz.notifyRendererInstalled(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('index.html loads venue-viz and venue-mood-fx before venue-scene-3d', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
const vizIdx = html.indexOf('venue-viz.js');
|
||||
const sceneIdx = html.indexOf('venue-scene-3d.js');
|
||||
const moodIdx = html.indexOf('venue-mood-fx.js');
|
||||
// scene-3d's boot reads v3VenueMoodFx.getMotion() synchronously, so both
|
||||
// venue-viz and venue-mood-fx must be defined before scene-3d loads.
|
||||
assert.ok(vizIdx !== -1 && sceneIdx !== -1 && moodIdx !== -1 && vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||
});
|
||||
|
||||
test('index.html contains in-player venue placeholder markup', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /id="v3-venue-mode-badge"/);
|
||||
assert.match(html, /Venue mode — 3D scene assets coming next/);
|
||||
assert.match(html, /id="v3-venue-scene-wash"/);
|
||||
});
|
||||
|
||||
test('app.js adds Venue visualization option and adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /function _ensureVenueVizOption/);
|
||||
assert.match(src, /opt\.value = 'venue'/);
|
||||
assert.match(src, /opt\.textContent = 'Venue'/);
|
||||
assert.match(src, /if \(id === 'venue'\)/);
|
||||
assert.match(src, /slopsmithViz_highway_3d/);
|
||||
assert.match(src, /localStorage\.setItem\('vizSelection', 'venue'\)/);
|
||||
assert.match(src, /_installVizRenderer\(venueRenderer, 'highway_3d'\)/);
|
||||
assert.match(src, /onVenueVisualizationSelected/);
|
||||
assert.match(src, /setSelectedVizId/);
|
||||
assert.match(src, /notifyRendererInstalled/);
|
||||
assert.match(src, /v3VenueScene3d\.syncViz/);
|
||||
});
|
||||
|
||||
test('venue mood enables for venue visualization, not plain 3D', () => {
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'venue', true), true);
|
||||
assert.equal(venue.isSuppressedBy3d('full', 'venue', true), false);
|
||||
assert.equal(venue.shouldEnableVenueMood('full', 'highway_3d', false), false);
|
||||
assert.equal(venue.isVenueVisualizationActive('venue'), true);
|
||||
});
|
||||
|
||||
test('onVenueVisualizationSelected defaults mood to full only on first selection', () => {
|
||||
const storage = new Map();
|
||||
global.localStorage = {
|
||||
getItem(k) { return storage.has(k) ? storage.get(k) : null; },
|
||||
setItem(k, v) { storage.set(k, String(v)); },
|
||||
removeItem(k) { storage.delete(k); },
|
||||
};
|
||||
try {
|
||||
// No stored preference yet → default the mood to FULL.
|
||||
storage.delete('slopsmith-venue-mood-fx');
|
||||
venue.onVenueVisualizationSelected();
|
||||
assert.equal(venue.get(), 'full');
|
||||
// An explicit 'subtle' choice must be preserved, not clobbered to full.
|
||||
storage.set('slopsmith-venue-mood-fx', 'subtle');
|
||||
venue.onVenueVisualizationSelected();
|
||||
assert.equal(venue.get(), 'subtle');
|
||||
// 'off' likewise preserved.
|
||||
storage.set('slopsmith-venue-mood-fx', 'off');
|
||||
venue.onVenueVisualizationSelected();
|
||||
assert.equal(venue.get(), 'off');
|
||||
} finally {
|
||||
delete global.localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('CSS defines visible venue placeholder above canvas', () => {
|
||||
const css = fs.readFileSync(V3_CSS, 'utf8');
|
||||
assert.match(css, /\.v3-venue-mode-badge/);
|
||||
assert.match(css, /\.v3-venue-scene-wash/);
|
||||
assert.match(css, /z-index:\s*18/);
|
||||
assert.match(css, /pointer-events:\s*none/);
|
||||
assert.match(css, /#player\.is-venue-visualization\.venue-scene-pending::before/);
|
||||
assert.match(css, /\.venue-mood-fx[\s\S]*display:\s*none/);
|
||||
});
|
||||
|
||||
test('builtin viz options remain in index.html', () => {
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
assert.match(html, /value="auto"/);
|
||||
assert.match(html, /Classic 2D Highway/);
|
||||
assert.match(html, /_populateVizPicker/);
|
||||
});
|
||||
|
||||
test('venue mood source documents strip overlay disabled', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-mood-fx.js'), 'utf8');
|
||||
assert.match(source, /STRIP_OVERLAY_ENABLED\s*=\s*false/);
|
||||
assert.match(source, /shouldShowStripOverlay/);
|
||||
assert.match(source, /v3-venue-mode-badge/);
|
||||
});
|
||||
|
||||
test('app.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /p\.type === 'visualization'/);
|
||||
assert.match(src, /slopsmithViz_/);
|
||||
assert.match(src, /BUILTIN_OPT_VALUES/);
|
||||
});
|
||||
|
||||
test('venue option remains distinct from highway_3d in app adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(src, /if \(id === 'venue'\)/);
|
||||
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
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 { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const VISUALIZATION_JS = path.join(ROOT, 'static', 'capabilities', 'visualization.js');
|
||||
|
||||
function loadVisualization(options = {}) {
|
||||
const window = createWindow(options);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(VISUALIZATION_JS, 'utf8'), context, { filename: VISUALIZATION_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
function captureEvents(api, eventNames) {
|
||||
const events = [];
|
||||
for (const name of eventNames) {
|
||||
api.subscribe(name, (detail) => events.push(detail));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
test('visualization domain registers an active provider-coordinator owner', () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const pipeline = api.inspect('visualization');
|
||||
assert.ok(pipeline, 'visualization pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.visualization');
|
||||
assert.ok(owner, 'core.visualization owner registered');
|
||||
assert.ok(owner.roles.includes('owner'));
|
||||
assert.deepEqual(
|
||||
[...owner.commands].sort(),
|
||||
['clear-renderer', 'inspect', 'list-providers', 'select-renderer'],
|
||||
);
|
||||
assert.equal(window.slopsmith.vizDomain.version, 1);
|
||||
});
|
||||
|
||||
test('visualization is no longer a reserved future domain', async () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const result = await api.dispatch({ capability: 'visualization', command: 'inspect', source: 'test' });
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(result.payload.current, 'default');
|
||||
});
|
||||
|
||||
test('refreshProviders registers participants with factory metadata', () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const highway3d = () => ({});
|
||||
highway3d.contextType = 'webgl2';
|
||||
highway3d.matchesArrangement = () => true;
|
||||
window.slopsmithViz_highway_3d = highway3d;
|
||||
window.slopsmithViz_piano = () => ({});
|
||||
|
||||
window.slopsmith.vizDomain.refreshProviders([
|
||||
{ id: 'highway_3d', label: '3D Highway' },
|
||||
{ id: 'piano', label: 'Piano' },
|
||||
{ id: 'auto', label: 'Auto' }, // built-ins are skipped
|
||||
{ id: 'default', label: 'Classic' },
|
||||
]);
|
||||
|
||||
const pipeline = api.inspect('visualization');
|
||||
const ids = (pipeline.participants || []).map(p => p.pluginId).sort();
|
||||
assert.ok(ids.includes('highway_3d') && ids.includes('piano'));
|
||||
assert.ok(!ids.includes('auto') && !ids.includes('default'));
|
||||
|
||||
const snapshot = window.slopsmith.vizDomain.snapshot();
|
||||
const h3d = snapshot.providers.find(p => p.id === 'highway_3d');
|
||||
assert.equal(h3d.contextType, 'webgl2');
|
||||
assert.equal(h3d.claims, true);
|
||||
const piano = snapshot.providers.find(p => p.id === 'piano');
|
||||
assert.equal(piano.contextType, '2d');
|
||||
assert.equal(piano.claims, false);
|
||||
});
|
||||
|
||||
test('refreshProviders surfaces declared per-instance settings in the snapshot', () => {
|
||||
const window = loadVisualization();
|
||||
const caps = window.slopsmith.capabilities;
|
||||
window.slopsmithViz_highway_3d = () => ({});
|
||||
window.slopsmithViz_piano = () => ({});
|
||||
|
||||
const settings = [
|
||||
{ key: 'palette', label: 'Palette', type: 'select', default: 'default',
|
||||
options: [{ id: 'neon', label: 'Neon' }] },
|
||||
{ key: 'cameraSmoothing', type: 'range', default: 0.5, min: 0, max: 1, step: 0.05 },
|
||||
// `default` is schema-unconstrained — exercise a nested object default.
|
||||
{ key: 'origin', type: 'select', default: { x: 0, y: 0 } },
|
||||
];
|
||||
// Settings flow through the generic participant model, not a side channel:
|
||||
// the provider declares them in its manifest capability, core registers +
|
||||
// normalizes them, and the host reads them back from the participant.
|
||||
caps.registerParticipant('highway_3d', { visualization: { roles: ['provider'], settings } });
|
||||
|
||||
window.slopsmith.vizDomain.refreshProviders([
|
||||
{ id: 'highway_3d', label: '3D Highway' },
|
||||
{ id: 'piano', label: 'Piano' }, // no settings declared
|
||||
]);
|
||||
|
||||
// inspect() — the generic surface — also carries the descriptors, not just
|
||||
// the visualization list-providers snapshot.
|
||||
const participant = (caps.inspect('visualization').participants || [])
|
||||
.find(p => p.pluginId === 'highway_3d');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(participant.settings)), settings);
|
||||
|
||||
// inspect() returns deep clones — mutating a returned descriptor (incl. the
|
||||
// schema-unconstrained nested `default`) must not leak into registry state.
|
||||
participant.settings.find(s => s.key === 'origin').default.x = 999;
|
||||
const reread = (caps.inspect('visualization').participants || [])
|
||||
.find(p => p.pluginId === 'highway_3d');
|
||||
assert.equal(reread.settings.find(s => s.key === 'origin').default.x, 0);
|
||||
|
||||
// list-providers carries the descriptors for consuming hosts; providers
|
||||
// without a declared settings list omit the field entirely.
|
||||
const snapshot = window.slopsmith.vizDomain.snapshot();
|
||||
const h3d = snapshot.providers.find(p => p.id === 'highway_3d');
|
||||
// Value-equal (the host deep-clones, so compare plain values, not refs).
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(h3d.settings)), settings);
|
||||
const piano = snapshot.providers.find(p => p.id === 'piano');
|
||||
assert.equal(piano.settings, undefined);
|
||||
|
||||
// Descriptors are deep-frozen so a snapshot consumer can't mutate domain
|
||||
// state — including nested values under the unconstrained `default`.
|
||||
assert.ok(Object.isFrozen(h3d.settings));
|
||||
assert.ok(Object.isFrozen(h3d.settings[0]));
|
||||
assert.ok(Object.isFrozen(h3d.settings[0].options[0]));
|
||||
const origin = h3d.settings.find(s => s.key === 'origin');
|
||||
assert.ok(Object.isFrozen(origin.default));
|
||||
// Null-prototype clones — manifest-controlled keys can't pollute prototypes.
|
||||
assert.equal(Object.getPrototypeOf(h3d.settings[0]), null);
|
||||
|
||||
// Ingestion is isolated too: mutating the caller's original input object
|
||||
// after registerParticipant() must not reach into registry state.
|
||||
settings.find(s => s.key === 'origin').default.x = 777;
|
||||
const afterCallerMutation = (caps.inspect('visualization').participants || [])
|
||||
.find(p => p.pluginId === 'highway_3d');
|
||||
assert.equal(afterCallerMutation.settings.find(s => s.key === 'origin').default.x, 0);
|
||||
});
|
||||
|
||||
test('refreshProviders unregisters providers that disappeared', () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithViz_gone = () => ({});
|
||||
window.slopsmith.vizDomain.refreshProviders([{ id: 'gone', label: 'Gone' }]);
|
||||
assert.ok(api.inspect('visualization').participants.some(p => p.pluginId === 'gone'));
|
||||
window.slopsmith.vizDomain.refreshProviders([]);
|
||||
assert.ok(!api.inspect('visualization').participants.some(p => p.pluginId === 'gone'));
|
||||
});
|
||||
|
||||
test('select-renderer degrades on unknown provider and missing picker surface', async () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const unknown = await api.dispatch({
|
||||
capability: 'visualization', command: 'select-renderer',
|
||||
source: 'test', payload: { providerId: 'nope' },
|
||||
});
|
||||
assert.equal(unknown.outcome, 'degraded');
|
||||
assert.match(unknown.reason, /Unknown visualization provider/);
|
||||
|
||||
window.slopsmithViz_piano = () => ({});
|
||||
window.slopsmith.vizDomain.refreshProviders([{ id: 'piano', label: 'Piano' }]);
|
||||
const noSurface = await api.dispatch({
|
||||
capability: 'visualization', command: 'select-renderer',
|
||||
source: 'test', payload: { providerId: 'piano' },
|
||||
});
|
||||
assert.equal(noSurface.outcome, 'degraded');
|
||||
assert.match(noSurface.reason, /selection surface unavailable/);
|
||||
});
|
||||
|
||||
test('select-renderer and clear-renderer delegate to the app picker', async () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const calls = [];
|
||||
window.setViz = (id) => calls.push(id);
|
||||
window.slopsmithViz_piano = () => ({});
|
||||
window.slopsmith.vizDomain.refreshProviders([{ id: 'piano', label: 'Piano' }]);
|
||||
|
||||
const select = await api.dispatch({
|
||||
capability: 'visualization', command: 'select-renderer',
|
||||
source: 'test', payload: { providerId: 'piano' },
|
||||
});
|
||||
assert.equal(select.outcome, 'handled');
|
||||
assert.equal(select.payload.selected, 'piano');
|
||||
|
||||
const clear = await api.dispatch({ capability: 'visualization', command: 'clear-renderer', source: 'test' });
|
||||
assert.equal(clear.outcome, 'handled');
|
||||
assert.deepEqual(calls, ['piano', 'default']);
|
||||
});
|
||||
|
||||
test('renderer change and failure are emitted as domain events', () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const events = captureEvents(api, [
|
||||
'visualization:renderer-changed',
|
||||
'visualization:renderer-failed',
|
||||
]);
|
||||
|
||||
window.slopsmith.vizDomain.notifyRendererChanged('highway_3d', 'auto-match');
|
||||
window.slopsmith.vizDomain.notifyRendererChanged('highway_3d', 'auto-match'); // same id — no event
|
||||
window.slopsmith.vizDomain.notifyRendererFailed('highway_3d', 'init threw');
|
||||
|
||||
const changed = events.filter(e => e.event === 'renderer-changed');
|
||||
assert.equal(changed.length, 1);
|
||||
assert.equal(changed[0].payload.from, 'default');
|
||||
assert.equal(changed[0].payload.to, 'highway_3d');
|
||||
assert.equal(changed[0].payload.source, 'auto-match');
|
||||
|
||||
const failed = events.filter(e => e.event === 'renderer-failed');
|
||||
assert.equal(failed.length, 1);
|
||||
assert.equal(failed[0].payload.providerId, 'highway_3d');
|
||||
});
|
||||
|
||||
test('legacy shim accounting appears in the diagnostics snapshot', () => {
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
window.slopsmithViz_piano = () => ({});
|
||||
window.slopsmith.vizDomain.refreshProviders([{ id: 'piano', label: 'Piano' }]);
|
||||
|
||||
const snapshot = api.snapshotDiagnostics();
|
||||
const shims = (snapshot.compatibilityShims || []).filter(s => s.capability === 'visualization');
|
||||
// JSON round-trip: vm-context arrays have foreign prototypes, which
|
||||
// assert.deepEqual (strict) rejects on reference identity.
|
||||
const ids = JSON.parse(JSON.stringify(shims.map(s => s.shimId).sort()));
|
||||
assert.deepEqual(ids, [
|
||||
'visualization:type-visualization-manifest',
|
||||
'visualization:window.slopsmithViz_*',
|
||||
]);
|
||||
const windowShim = shims.find(s => s.shimId === 'visualization:window.slopsmithViz_*');
|
||||
assert.equal(windowShim.status, 'used');
|
||||
assert.ok(windowShim.hitCount >= 1);
|
||||
});
|
||||
|
||||
test('diagnostics contribution is redaction-safe (no song identity)', () => {
|
||||
const window = loadVisualization();
|
||||
window.slopsmith.vizDomain.noteAutoMatch('piano', true);
|
||||
window.slopsmith.vizDomain.notifyRendererFailed('piano', 'draw threw');
|
||||
const contribution = window.__diagnosticsContributions.get('visualization-capability');
|
||||
assert.equal(contribution.schema, 'slopsmith.visualization_capability.v1');
|
||||
const serialized = JSON.stringify(contribution);
|
||||
assert.ok(!/filename|title|artist|\.sloppak|\.psarc/i.test(serialized), serialized);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(contribution.lastAutoMatch)),
|
||||
{ resolved: 'piano', matched: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('a still-reserved future domain rejects dispatch', async () => {
|
||||
// note-detection was promoted by the spec-009 slice; backend.routes is
|
||||
// the canary that the reserved list still guards unpromoted domains.
|
||||
const window = loadVisualization();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const result = await api.dispatch({ capability: 'backend.routes', command: 'inspect', source: 'test' });
|
||||
assert.notEqual(result.outcome, 'handled');
|
||||
});
|
||||
Reference in New Issue
Block a user