Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+144
View File
@@ -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'));
});
+63
View File
@@ -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');
});
+921
View File
@@ -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);
});
+22
View File
@@ -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 };
+72
View File
@@ -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")');
});
+222
View File
@@ -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);
});
+252
View File
@@ -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+/);
});
+754
View File
@@ -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');
});
+246
View File
@@ -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]);
});
+642
View File
@@ -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);
});
+121
View File
@@ -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);
});
+62
View File
@@ -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);
});
+193
View File
@@ -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,
};
+79
View File
@@ -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',
);
});
+65
View File
@@ -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);
});
+183
View File
@@ -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);
});
+80
View File
@@ -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');
});
+34
View File
@@ -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');
});
+79
View File
@@ -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 };
+24
View File
@@ -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');
});
+132
View File
@@ -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/);
});
+71
View File
@@ -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);
});
+68
View File
@@ -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',
);
});
+51
View File
@@ -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',
);
});
+138
View File
@@ -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',
);
});
+44
View File
@@ -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',
);
});
+82
View File
@@ -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',
);
});
+93
View File
@@ -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',
);
});
+178
View File
@@ -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);
});
+73
View File
@@ -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`);
}
});
+461
View File
@@ -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',
);
});
+62
View File
@@ -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');
});
+49
View File
@@ -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',
);
});
+49
View File
@@ -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',
);
});
+118
View File
@@ -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');
});
+39
View File
@@ -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');
});
+163
View File
@@ -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');
});
+56
View File
@@ -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',
);
});
+261
View File
@@ -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');
});
+129
View File
@@ -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');
});
+62
View File
@@ -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');
});
+49
View File
@@ -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',
);
});
+231
View File
@@ -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');
});
+192
View File
@@ -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',
);
});
+365
View File
@@ -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');
});
+122
View File
@@ -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'\)/);
});
+70
View File
@@ -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);
});
+54
View File
@@ -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&rsquo;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/);
});
+173
View File
@@ -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/);
});
+268
View File
@@ -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',
);
});
+243
View File
@@ -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()');
});
+395
View File
@@ -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');
});
+129
View File
@@ -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');
});
+88
View File
@@ -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*\}/);
});
+101
View File
@@ -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);
});
+117
View File
@@ -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/);
});
+237
View File
@@ -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');
});
+166
View File
@@ -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,
};
+230
View File
@@ -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);
});
+150
View File
@@ -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);
});
+241
View File
@@ -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);
});
+120
View File
@@ -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');
});
+101
View File
@@ -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');
});
+147
View File
@@ -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}`);
}
});
+147
View File
@@ -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"/);
});
+305
View File
@@ -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()}`,
);
}
});
+195
View File
@@ -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],
]);
});
+36
View File
@@ -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 };
+172
View File
@@ -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*'&amp;'[^}]*'<':\s*'&lt;'[^}]*'>':\s*'&gt;'[^}]*'"':\s*'&quot;'[^}]*"'":\s*'&#39;'/,
'must map all five HTML-significant characters');
});
+330
View File
@@ -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/);
});
+56
View File
@@ -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);
});
+115
View File
@@ -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"/);
});
+146
View File
@@ -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/);
});
+121
View File
@@ -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);
});
+97
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, '');
});
+55
View File
@@ -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/);
});
+505
View File
@@ -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/);
});
+386
View File
@@ -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/);
});
+224
View File
@@ -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'/);
});
+257
View File
@@ -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');
});