mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Add new chart-transform plugin capability (#1000)
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Byron Gamatos
parent
f7942f3689
commit
05be9ebdbe
@@ -0,0 +1,335 @@
|
||||
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 CHART_TRANSFORM_JS = path.join(ROOT, 'static', 'capabilities', 'chart-transform.js');
|
||||
|
||||
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
|
||||
const PLUGIN_ID = 'example_plugin';
|
||||
const PROVIDER_ID = 'example-transform';
|
||||
const PROVIDER_LABEL = 'Example Transform';
|
||||
|
||||
function makeFakeHighway() {
|
||||
const calls = { set: [], refresh: 0 };
|
||||
return {
|
||||
calls,
|
||||
setChartTransform(p) { calls.set.push(p); },
|
||||
refreshChartTransform() { calls.refresh += 1; },
|
||||
getChartTransform() { return calls.set.length ? calls.set[calls.set.length - 1] : null; },
|
||||
};
|
||||
}
|
||||
|
||||
function loadChartTransform(options = {}) {
|
||||
const window = createWindow(options);
|
||||
// The real bus provides feedBack.on; the harness only has emit →
|
||||
// dispatchEvent. Shim `on` the same way app.js implements it so the
|
||||
// module's bus mirroring (song:ready, chart-transform-failed) is live.
|
||||
window.feedBack.on = (type, handler) => window.addEventListener(type, handler);
|
||||
if (options.highway) window.highway = options.highway;
|
||||
if (options.persistedSelection) window.localStorage.setItem(STORAGE_KEY, options.persistedSelection);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(CHART_TRANSFORM_JS, 'utf8'), context, { filename: CHART_TRANSFORM_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 registerProvider(api, overrides = {}) {
|
||||
return api.dispatch({
|
||||
capability: 'chart-transform', command: 'register-provider',
|
||||
source: overrides.source || PLUGIN_ID,
|
||||
payload: {
|
||||
providerId: overrides.providerId || PROVIDER_ID,
|
||||
label: overrides.label || PROVIDER_LABEL,
|
||||
transform: overrides.transform || ((input) => ({ notes: input.notes })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('chart-transform domain registers a safe provider-coordinator owner', () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const pipeline = api.inspect('chart-transform');
|
||||
assert.ok(pipeline, 'chart-transform pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.chart-transform');
|
||||
assert.ok(owner, 'core.chart-transform owner registered');
|
||||
assert.equal(owner.safety, 'safe');
|
||||
assert.ok(owner.commands.includes('select-provider'));
|
||||
assert.ok(owner.commands.includes('refresh'));
|
||||
assert.equal(window.feedBack.chartTransformDomain.version, 1);
|
||||
});
|
||||
|
||||
test('register-provider requires a transform function', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'register-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(result.outcome, 'degraded');
|
||||
assert.match(result.reason, /transform\(input\) function/);
|
||||
});
|
||||
|
||||
test('register + select installs the provider on the highway and persists', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, [
|
||||
'chart-transform:provider-registered',
|
||||
'chart-transform:transform-changed',
|
||||
]);
|
||||
|
||||
const reg = await registerProvider(api);
|
||||
assert.equal(reg.outcome, 'handled');
|
||||
assert.ok(api.inspect('chart-transform').participants.some(p => p.pluginId === PLUGIN_ID));
|
||||
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(sel.payload.active, PROVIDER_ID);
|
||||
assert.equal(sel.payload.installed, true);
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(typeof highway.calls.set[0].transform, 'function');
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
|
||||
|
||||
const names = events.map(e => e.event);
|
||||
assert.ok(names.includes('provider-registered'));
|
||||
assert.ok(names.includes('transform-changed'));
|
||||
});
|
||||
|
||||
test('select-provider with an unknown id degrades', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: 'nope' },
|
||||
});
|
||||
assert.equal(result.outcome, 'degraded');
|
||||
assert.match(result.reason, /Unknown chart-transform provider/);
|
||||
});
|
||||
|
||||
test('selection without a highway is kept and installed on song:ready', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(sel.payload.installed, false, 'no highway yet');
|
||||
|
||||
const highway = makeFakeHighway();
|
||||
window.highway = highway;
|
||||
window.feedBack.emit('song:ready', {});
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().installed, true);
|
||||
});
|
||||
|
||||
test('a persisted selection restores when its provider registers', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway, persistedSelection: PROVIDER_ID });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.active, PROVIDER_ID);
|
||||
assert.equal(snapshot.activeSource, 'restore-selection');
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
});
|
||||
|
||||
test('unregister is registrant-only and detaches the active provider', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
|
||||
const denied = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: 'someone_else', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(denied.outcome, 'degraded');
|
||||
assert.match(denied.reason, /original registrant/);
|
||||
|
||||
const ok = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(ok.outcome, 'handled');
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.active, null);
|
||||
assert.equal(snapshot.providers.length, 0);
|
||||
// Detach = a trailing setChartTransform(null) on the highway.
|
||||
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
|
||||
// Persisted selection survives so re-registration re-activates.
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
|
||||
});
|
||||
|
||||
test('unregister keeps a participant while another provider still references it', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api, { providerId: 'provider-a', label: 'Provider A' });
|
||||
await registerProvider(api, { providerId: 'provider-b', label: 'Provider B' });
|
||||
|
||||
let participant = api.inspect('chart-transform').participants
|
||||
.find(p => p.pluginId === PLUGIN_ID);
|
||||
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a', 'provider-b']);
|
||||
assert.deepEqual(
|
||||
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
|
||||
[{ id: 'provider-a', label: 'Provider A' }, { id: 'provider-b', label: 'Provider B' }],
|
||||
);
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: 'provider-b' },
|
||||
});
|
||||
|
||||
participant = api.inspect('chart-transform').participants
|
||||
.find(p => p.pluginId === PLUGIN_ID);
|
||||
assert.ok(participant, 'the shared participant remains registered');
|
||||
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a']);
|
||||
assert.deepEqual(
|
||||
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
|
||||
[{ id: 'provider-a', label: 'Provider A' }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(window.feedBack.chartTransformDomain.snapshot().providers, p => p.id),
|
||||
['provider-a'],
|
||||
);
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: 'provider-a' },
|
||||
});
|
||||
assert.ok(!api.inspect('chart-transform').participants
|
||||
.some(p => p.pluginId === PLUGIN_ID), 'the final removal unregisters the participant');
|
||||
});
|
||||
|
||||
test('clear-provider clears the highway hook and the persisted selection', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui',
|
||||
});
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), null);
|
||||
});
|
||||
|
||||
test('refresh re-runs the installed transform', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
const result = await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(result.payload.refreshed, true);
|
||||
assert.equal(highway.calls.refresh, 1);
|
||||
});
|
||||
|
||||
test('announced highway instances (splitscreen panels) get the active transform', async () => {
|
||||
const primary = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway: primary });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 1);
|
||||
|
||||
// A splitscreen panel announces its own createHighway() instance.
|
||||
const panel = makeFakeHighway();
|
||||
window.feedBack.emit('highway:created', { highway: panel });
|
||||
assert.equal(panel.calls.set.length, 1, 'panel receives the active transform');
|
||||
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 2);
|
||||
|
||||
// Refresh reaches every surface.
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
|
||||
assert.equal(primary.calls.refresh, 1);
|
||||
assert.equal(panel.calls.refresh, 1);
|
||||
|
||||
// Clearing detaches every surface.
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui' });
|
||||
assert.equal(primary.calls.set[primary.calls.set.length - 1], null);
|
||||
assert.equal(panel.calls.set[panel.calls.set.length - 1], null);
|
||||
});
|
||||
|
||||
test('a panel announced before any selection installs on later select', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const panel = makeFakeHighway();
|
||||
window.feedBack.emit('highway:created', { highway: panel });
|
||||
await registerProvider(api);
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(panel.calls.set.length, 1);
|
||||
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
|
||||
});
|
||||
|
||||
test('highway failure events expose a fixed public reason', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, ['chart-transform:transform-failed']);
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
|
||||
window.feedBack.emit('highway:chart-transform-failed', {
|
||||
id: PROVIDER_ID,
|
||||
reason: 'token=secret https://example.test/private chart={notes:[...]}',
|
||||
});
|
||||
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.lastFailure.providerId, PROVIDER_ID);
|
||||
assert.equal(snapshot.lastFailure.reason, 'Chart transform provider failed');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].payload.reason, 'Chart transform provider failed');
|
||||
});
|
||||
|
||||
test('diagnostics contribution carries the schema and no song identity fields', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const contributions = window.feedBack.diagnostics.snapshotContributions();
|
||||
const diag = contributions['chart-transform-capability'];
|
||||
assert.ok(diag, 'diagnostics contributed');
|
||||
assert.equal(diag.schema, 'feedBack.chart_transform.diagnostics.v1');
|
||||
const flat = JSON.stringify(diag);
|
||||
assert.ok(!/filename|title|artist|arrangement/.test(flat), 'no song identity in diagnostics');
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
// Source-level coverage is used because createHighway's browser closure is too
|
||||
// large for the Node harness. Critical staging helpers are exercised directly.
|
||||
|
||||
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 highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highwayDrawJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js');
|
||||
|
||||
function extractBlock(src, marker) {
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start >= 0, `${marker} present`);
|
||||
const open = src.indexOf('{', start);
|
||||
assert.ok(open >= 0, `${marker} has a body`);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
assert.fail(`${marker} body is balanced`);
|
||||
}
|
||||
|
||||
test('highway public API exposes the chart-transform hook', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setChartTransform\s*\(\s*p\s*\)\s*\{/, 'setChartTransform exists');
|
||||
assert.match(src, /getChartTransform\s*\(\s*\)\s*\{[^}]*_xfProvider/, 'getChartTransform returns the provider');
|
||||
assert.match(src, /refreshChartTransform\s*\(\s*\)\s*\{[^}]*_restageChartTransform/, 'refreshChartTransform restages');
|
||||
});
|
||||
|
||||
test('restage runs at BOTH exits of _rebuildMasteryFilter (transform after difficulty)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fnStart = src.indexOf('function _rebuildMasteryFilter()');
|
||||
const fnEnd = src.indexOf('function _clearChartTransformStage');
|
||||
assert.ok(fnStart > -1 && fnEnd > fnStart, 'both functions present in order');
|
||||
const body = src.slice(fnStart, fnEnd);
|
||||
const calls = body.match(/_restageChartTransform\(\);/g) || [];
|
||||
assert.equal(calls.length, 2, 'restage at the early return and the normal exit');
|
||||
});
|
||||
|
||||
test('restage consumes the difficulty-filtered arrays, not the raw chart', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
assert.match(fn, /notes:\s*filterActive\s*\?\s*hwState\._filteredNotes\s*:\s*hwState\.notes/);
|
||||
assert.match(fn, /allNotes:\s*hwState\.notes/, 'full-difficulty views passed alongside');
|
||||
});
|
||||
|
||||
test('a throwing provider clears the stage and emits highway:chart-transform-failed', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
const report = extractBlock(src, 'function _reportChartTransformFailure(provider, error)');
|
||||
assert.match(fn, /catch\s*\(e\)\s*\{[\s\S]*_reportChartTransformFailure\(p, e\)[\s\S]*return;/);
|
||||
assert.match(fn, /^\s*_clearChartTransformStage\(\);/m, 'stage cleared before the provider runs');
|
||||
|
||||
// Execute the extracted reporter with a sentinel error: the emitted
|
||||
// payload must contain ONLY the approved field (id) — nothing derived
|
||||
// from the exception — while the raw error stays on the local console.
|
||||
const sandbox = { cleared: 0, emitted: [], logged: [] };
|
||||
vm.runInNewContext(`
|
||||
const _clearChartTransformStage = () => { cleared += 1; };
|
||||
const console = { error: (...args) => logged.push(args) };
|
||||
const window = { feedBack: { emit: (type, detail) => emitted.push({ type, detail }) } };
|
||||
${report}
|
||||
_reportChartTransformFailure({ id: 'prov-1' }, new Error('sentinel: /Users/someone/secret.sloppak'));
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.cleared, 1, 'failure clears the stage');
|
||||
assert.equal(sandbox.emitted.length, 1, 'exactly one failure event');
|
||||
assert.equal(sandbox.emitted[0].type, 'highway:chart-transform-failed');
|
||||
assert.deepEqual(Object.keys(sandbox.emitted[0].detail), ['id'],
|
||||
'payload carries only the approved field — no exception-derived fields');
|
||||
assert.equal(sandbox.emitted[0].detail.id, 'prov-1');
|
||||
assert.ok(!JSON.stringify(sandbox.emitted[0].detail).includes('sentinel'),
|
||||
'nothing exception-derived leaks into the event');
|
||||
assert.equal(sandbox.logged.length, 1, 'raw exception stays on the local console');
|
||||
assert.ok(sandbox.logged[0].some((arg) => String(arg).includes('sentinel')),
|
||||
'the local console received the actual error');
|
||||
});
|
||||
|
||||
test('restage is a pre-ready no-op: provider stays attached, ready path runs the first staging', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
const guardAt = fn.indexOf('if (!hwState.ready) return;');
|
||||
const invokeAt = fn.indexOf('p.transform(');
|
||||
assert.ok(guardAt > -1, 'ready guard present');
|
||||
assert.ok(invokeAt > guardAt, 'guard sits before the provider is invoked');
|
||||
// The ready handler must flip hwState.ready BEFORE rebuilding the
|
||||
// filter, or the guard would skip the first real staging.
|
||||
const readyCase = src.indexOf("case 'ready':");
|
||||
const readyFlip = src.indexOf('hwState.ready = true;', readyCase);
|
||||
const readyRebuild = src.indexOf('_rebuildMasteryFilter();', readyCase);
|
||||
assert.ok(readyCase > -1 && readyFlip > -1 && readyRebuild > readyFlip,
|
||||
'ready handler sets hwState.ready before the rebuild that restages');
|
||||
});
|
||||
|
||||
test('bundle assembly prefers the staged transform views', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /b\.notes = hwState\._xfNotes !== null \? hwState\._xfNotes/);
|
||||
assert.match(src, /b\.chords = hwState\._xfChords !== null \? hwState\._xfChords/);
|
||||
assert.match(src, /b\.anchors = hwState\._xfAnchors !== null \? hwState\._xfAnchors/);
|
||||
assert.match(src, /b\.chordTemplates = hwState\._xfChordTemplates !== null/);
|
||||
assert.match(src, /b\.stringCount = hwState\._xfStringCount !== null/);
|
||||
assert.match(src, /b\.tuning = hwState\._xfTuning !== null/);
|
||||
assert.match(src, /b\.capo = hwState\._xfCapo !== null/);
|
||||
assert.match(src, /b\.handShapes = hwState\._xfHandShapes !== null \? hwState\._xfHandShapes/);
|
||||
assert.match(src, /b\.centOffset = hwState\._xfCentOffset !== null/);
|
||||
});
|
||||
|
||||
test('transform input carries the effective handShapes; output stages handShapes/centOffset', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
assert.match(fn, /handShapes: \(hwState\._filteredHandShapes !== null && hwState\._phrasesHaveHandShapes\)/,
|
||||
'input handShapes uses the same effective selection as the bundle');
|
||||
assert.match(fn, /_sortedChartTransformArray\(out\.handShapes, 'start_time'\)/);
|
||||
assert.match(fn, /if \(Number\.isFinite\(out\.centOffset\)\) hwState\._xfCentOffset = out\.centOffset;/);
|
||||
});
|
||||
|
||||
test('unordered provider timelines are copied and normalized for searches and anchor scans', () => {
|
||||
const highwaySrc = fs.readFileSync(highwayJs, 'utf8');
|
||||
const drawSrc = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(highwaySrc, 'function _clearChartTransformStage()'),
|
||||
extractBlock(highwaySrc, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(highwaySrc, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(highwaySrc, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(highwaySrc, 'function _restageChartTransform()'),
|
||||
extractBlock(highwaySrc, 'function bsearchTime(arr, time)'),
|
||||
extractBlock(highwaySrc, 'function getAnchorAt(t)'),
|
||||
extractBlock(highwaySrc, 'function getMaxFretInWindow(t)'),
|
||||
extractBlock(drawSrc, 'export function bsearch(arr, time)').replace('export ', ''),
|
||||
].join('\n');
|
||||
const providerOutput = {
|
||||
notes: [{ t: 9 }, { t: 1 }, { t: 5 }],
|
||||
chords: [{ t: 8 }, { t: 2 }],
|
||||
anchors: [
|
||||
{ time: 10, fret: 20, width: 2 },
|
||||
{ time: 0, fret: 1, width: 3 },
|
||||
{ time: 5, fret: 10, width: 4 },
|
||||
],
|
||||
allNotes: [{ t: 7 }, { t: 0 }, { t: 3 }],
|
||||
allChords: [{ t: 6 }, { t: 4 }],
|
||||
handShapes: [{ start_time: 9 }, { start_time: 1 }],
|
||||
stringCount: 4,
|
||||
tuning: [-2, -2, -2, -2],
|
||||
capo: 2,
|
||||
centOffset: -12.5,
|
||||
};
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: { id: 'unordered', transform: () => providerOutput },
|
||||
_filteredNotes: [],
|
||||
_filteredChords: [],
|
||||
_filteredAnchors: [],
|
||||
_filteredHandShapes: [],
|
||||
_phrasesHaveHandShapes: true,
|
||||
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 6, songInfo: {},
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'VISIBLE_SECONDS', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform, bsearch, bsearchTime, getAnchorAt, getMaxFretInWindow };
|
||||
`)(hwState, {}, 3, { error() {} });
|
||||
|
||||
helpers._restageChartTransform();
|
||||
|
||||
assert.deepEqual(hwState._xfNotes.map(n => n.t), [1, 5, 9]);
|
||||
assert.deepEqual(hwState._xfChords.map(ch => ch.t), [2, 8]);
|
||||
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [0, 3, 7]);
|
||||
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [4, 6]);
|
||||
assert.deepEqual(hwState._xfAnchors.map(a => a.time), [0, 5, 10]);
|
||||
assert.deepEqual(hwState._xfHandShapes.map(h => h.start_time), [1, 9]);
|
||||
assert.equal(hwState._xfStringCount, 4);
|
||||
assert.deepEqual(hwState._xfTuning, [-2, -2, -2, -2]);
|
||||
assert.equal(hwState._xfCapo, 2);
|
||||
assert.equal(hwState._xfCentOffset, -12.5);
|
||||
assert.deepEqual(providerOutput.notes.map(n => n.t), [9, 1, 5], 'provider output is not mutated');
|
||||
providerOutput.tuning[0] = 99;
|
||||
assert.equal(hwState._xfTuning[0], -2, 'staged metadata is detached from provider output');
|
||||
assert.equal(helpers.bsearch(hwState._xfNotes, 5), 1);
|
||||
assert.equal(helpers.bsearchTime(hwState._xfAnchors, 5), 1);
|
||||
assert.equal(helpers.getAnchorAt(6).time, 5);
|
||||
assert.equal(helpers.getMaxFretInWindow(0), 14);
|
||||
|
||||
hwState._filteredNotes = null;
|
||||
hwState._filteredChords = null;
|
||||
hwState._xfProvider.transform = () => ({
|
||||
notes: [{ t: 4 }, { t: 2 }],
|
||||
chords: [{ t: 3 }, { t: 1 }],
|
||||
});
|
||||
helpers._restageChartTransform();
|
||||
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [2, 4], 'unfiltered notes still fall back');
|
||||
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [1, 3], 'unfiltered chords still fall back');
|
||||
});
|
||||
|
||||
test('provider inputs and staged outputs are isolated from provider mutation', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(src, 'function _clearChartTransformStage()'),
|
||||
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(src, 'function _restageChartTransform()'),
|
||||
].join('\n');
|
||||
const sourceNote = { t: 1, bendValues: [{ t: 0, v: 1 }] };
|
||||
const sourceInfo = { tuning: [0, 0], nested: { value: 1 } };
|
||||
const events = [];
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: null,
|
||||
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
|
||||
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
|
||||
notes: [sourceNote], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 2, songInfo: sourceInfo,
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform };
|
||||
`)(hwState, { feedBack: { emit(name, detail) { events.push({ name, detail }); } } }, { error() {} });
|
||||
|
||||
hwState._xfProvider = {
|
||||
id: 'mutating-provider',
|
||||
transform(input) {
|
||||
input.notes[0].t = 99;
|
||||
input.notes[0].bendValues[0].v = 7;
|
||||
input.songInfo.nested.value = 8;
|
||||
throw new Error('private provider detail');
|
||||
},
|
||||
};
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(sourceNote.t, 1);
|
||||
assert.equal(sourceNote.bendValues[0].v, 1);
|
||||
assert.equal(sourceInfo.nested.value, 1);
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.deepEqual(events.map(event => event.name), ['highway:chart-transform-failed']);
|
||||
|
||||
const output = { notes: [{ t: 2, nested: { value: 3 } }] };
|
||||
hwState._xfProvider = { id: 'stable-provider', transform: () => output };
|
||||
helpers._restageChartTransform();
|
||||
output.notes[0].t = 20;
|
||||
output.notes[0].nested.value = 30;
|
||||
assert.equal(hwState._xfNotes[0].t, 2);
|
||||
assert.equal(hwState._xfNotes[0].nested.value, 3);
|
||||
});
|
||||
|
||||
test('async and malformed provider outputs fail closed without a partial stage', async () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(src, 'function _clearChartTransformStage()'),
|
||||
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(src, 'function _restageChartTransform()'),
|
||||
].join('\n');
|
||||
const events = [];
|
||||
const errors = [];
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: { id: 'async-provider', transform: async () => { throw new Error('async detail'); } },
|
||||
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
|
||||
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
|
||||
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 6, songInfo: {},
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform };
|
||||
`)(hwState, { feedBack: { emit(name) { events.push(name); } } }, { error(...args) { errors.push(args); } });
|
||||
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.equal(events.length, 1);
|
||||
assert.match(String(errors[0][1]), /must return synchronously/);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.match(String(errors[1][1]), /async detail/, 'async rejection stays in the local console');
|
||||
|
||||
const output = { chords: [{ t: 1 }] };
|
||||
Object.defineProperty(output, 'notes', { enumerable: true, get() { throw new Error('bad getter'); } });
|
||||
hwState._xfProvider = { id: 'getter-provider', transform: () => output };
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.equal(hwState._xfChords, null);
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('createHighway announces each instance via highway:created', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /emit\('highway:created', \{ highway: api \}\)/,
|
||||
'factory emits highway:created with the api instance');
|
||||
});
|
||||
|
||||
test('public getters fall through transformed → filtered → raw', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getNotes\(\)\s*\{\s*return hwState\._xfNotesAll !== null/);
|
||||
assert.match(src, /getChords\(\)\s*\{\s*return hwState\._xfChordsAll !== null/);
|
||||
assert.match(src, /getFilteredNotes\(\)\s*\{\s*if \(hwState\._xfNotes !== null\) return hwState\._xfNotes;/);
|
||||
assert.match(src, /getFilteredChords\(\)\s*\{\s*if \(hwState\._xfChords !== null\) return hwState\._xfChords;/);
|
||||
assert.match(src, /getChordTemplates\(\)\s*\{\s*return hwState\._xfChordTemplates !== null/);
|
||||
assert.match(src, /getStringCount\(\)\s*\{\s*return hwState\._xfStringCount !== null/);
|
||||
assert.match(src, /getTuning\(\)\s*\{\s*return hwState\._xfTuning !== null/);
|
||||
assert.match(src, /getCapo\(\)\s*\{\s*return hwState\._xfCapo !== null/);
|
||||
assert.match(src, /getCentOffset\(\)\s*\{\s*return hwState\._xfCentOffset !== null/);
|
||||
assert.match(src, /getSongInfo\(\)\s*\{\s*return hwState\.songInfo;\s*\}/,
|
||||
'getSongInfo keeps the original chart metadata contract');
|
||||
});
|
||||
|
||||
test('anchor zoom helpers read the staged anchors first', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const anchorSites = src.match(/hwState\._xfAnchors !== null \? hwState\._xfAnchors\s*\n?\s*: hwState\._filteredAnchors !== null/g) || [];
|
||||
assert.ok(anchorSites.length >= 2, 'getAnchorAt and getMaxFretInWindow both staged-aware');
|
||||
});
|
||||
|
||||
test('init and reconnect clear the stage but keep the provider', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const initBody = extractBlock(src, 'init(canvasEl, container)');
|
||||
const reconnectBody = extractBlock(src, 'reconnect(filename, arrangement)');
|
||||
assert.match(initBody, /_clearChartTransformStage\(\);/, 'init clears the stage');
|
||||
assert.match(reconnectBody, /_clearChartTransformStage\(\);/, 'reconnect clears the stage');
|
||||
assert.ok(!/init\([\s\S]{0,2000}_xfProvider = null/.test(src.slice(src.indexOf('const api = {'))),
|
||||
'api reset paths never drop the installed provider');
|
||||
});
|
||||
|
||||
test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSustains)', () => {
|
||||
const src = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
const noteSites = src.match(/hwState\._xfNotes !== null \? hwState\._xfNotes/g) || [];
|
||||
assert.ok(noteSites.length >= 2, 'drawNotes and drawSustains staged-aware');
|
||||
assert.match(src, /hwState\._xfChords !== null \? hwState\._xfChords/, 'drawChords staged-aware');
|
||||
});
|
||||
|
||||
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
|
||||
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
|
||||
assert.match(src, /let cap = bundle\.capo;/,
|
||||
'label derivation reads bundle.capo first');
|
||||
// Both cache paths must key on the same bundle-first capo the labels
|
||||
// use (songInfo stays as the fallback branch of each ternary), and all
|
||||
// three sites share the same final fallback (0) so cache signatures
|
||||
// match rendered output.
|
||||
assert.match(src, /const capo =\s*\n\s*bundle && Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
|
||||
'label signature keys on bundle.capo first with a 0 fallback');
|
||||
assert.match(src, /const capo =\s*\n\s*Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
|
||||
'cheap-key fast path keys on bundle.capo first with a 0 fallback');
|
||||
});
|
||||
|
||||
test('chord template reads route through the effective-templates helper', () => {
|
||||
const src = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
assert.match(src, /export function _effChordTemplates\(hwState\)/);
|
||||
assert.ok(!/getChordTemplateInfo\([^)]*,\s*hwState\.chordTemplates\)/.test(src),
|
||||
'no direct hwState.chordTemplates read remains at template-info call sites');
|
||||
assert.match(src, /_chordRenderCacheTemplates !== effTemplates/, 'render cache keys on effective templates');
|
||||
});
|
||||
@@ -51,8 +51,10 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
);
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'effTemplates'),
|
||||
'cache must key on the effective chordTemplates (detected via !== for change-flag)');
|
||||
assert.match(src, /_effChordTemplates\(hwState\)\s*\{\s*\n?\s*return hwState\._xfChordTemplates !== null \? hwState\._xfChordTemplates : hwState\.chordTemplates;/,
|
||||
'effective templates must derive from hwState.chordTemplates');
|
||||
});
|
||||
|
||||
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
|
||||
|
||||
Reference in New Issue
Block a user