feedBack/tests/js/working_tuning.test.js
ChrisBeWithYou 491039a12d
feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) (#658)
* feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1)

Introduce window.feedBack.workingTuning — the live, host-authoritative current
instrument tuning (offsets + string-count + reference pitch + assumed/verified
provenance), distinct from any one song's tuning and from a soft opt-in default.
It's the single source of truth the highway, library, and plugins (tuner,
Virtuoso, minigames) will read so a retune or instrument swap is reflected
app-wide instead of being re-derived per surface.

PER-INSTRUMENT: state is a map keyed by `${instrument}-${stringCount}` (e.g.
guitar-6 / bass-4, the selector's key) — your guitar's tuning and your bass's are
kept separately; get() returns the selected instrument's, and switching the
selector surfaces that instrument's own remembered tuning. You only ever deal
with the one you've picked.

Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API:
synchronous get(instrument?), set(state,{provenance,instrument}) mutator,
setCurrentInstrument(), resetToDefault(), and a `working-tuning-changed` event
that fires on change and once on hydration (carrying which instrument changed).
In-memory, seeded from /api/settings, reset-on-restart. Registered as a separate
`working-tuning` exclusive-owner capability (tuner = sole writer, others read).

Foundation only — pure plumbing, nothing writes to it yet and no behavior
changes. The tuner becomes the writer (and the gate's E->C# asymmetry is fixed)
in a later PR.

Frontend-only: new static/capabilities/working-tuning.js, loaded from
static/index.html + static/v3/index.html. Per-instrument state machine verified
by a stubbed node harness (separate guitar/bass slots, selector switch, isolated
writes, verified stamp, reset, defensive copies, capability registration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(working-tuning): resolve review P1/P2s + add behavioral test harness

Addresses the manual + Codex review of PR 1 (working-tuning foundation).

P1 — named tunings were dropped by the boot seed: /api/settings.tuning may be a
name ("Drop D") OR an offsets list, but the seed only handled the list and stored
offsets:null for names. The seed now resolves a name to per-string semitone offsets
via /api/tunings (ratio vs Standard; reference pitch cancels).

P1 — async seed could clobber state a consumer had already written: _seedFromSettings
resolves after boot and used to overwrite _currentKey/_byInstrument unconditionally.
It now bails when state was already _touched (and re-checks after the /api/tunings
leg), so an explicit set()/setCurrentInstrument()/resetToDefault() before hydration
wins. Hydration still fires.

P1/P2 — shallow copy leaked live nested arrays: get() and set() now clone offsets and
verifiedStrings on both ingress and egress, honouring the "readers can't mutate live
state" contract.

P2 — provenance/verification state machine made coherent by construction:
verified <=> verifiedStrings is an array AND verifiedAt is a finite number. A tuning
change invalidates prior verification unless a fresh bundle is supplied; a "verified"
claim with no strings or a null/absent timestamp is repaired (assumed / stamped now).

P2 — bare-instrument writes targeted a hard-coded default string count: _keyOfResolved()
resolves an omitted string count against the current selection (same instrument), so
set({instrument:'bass'}) / set({stringCount:5}) hit the selected bass-5, not bass-4.

Test — adds tests/js/working_tuning.test.js (the harness the PR described but did not
commit): 11 behavioral cases over a stubbed window — registration, per-instrument
isolation + selector switch, defensive copies, the verification invariant, bare-key
routing, named + offsets-list seeding, and the boot-race guard. Full tests/js suite:
no new failures (the 12 pre-existing branch failures are unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 08:05:56 +02:00

207 lines
9.9 KiB
JavaScript

// Behavioral harness for the host `window.feedBack.workingTuning` capability
// (static/capabilities/working-tuning.js) — the per-instrument, in-memory current
// tuning. Runs the real capability in a stubbed window (same strategy as
// midi_input_domain.test.js) with a controllable fetch, and asserts the per-instrument
// state machine: isolated guitar/bass slots, selector switch, defensive copies, the
// provenance/verification invariant, unambiguous key routing, named-tuning seeding, and
// the boot-race guard.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
const TUNINGS = {
'guitar-6': {
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
},
'bass-5': {
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
},
};
function deferred() {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { promise, resolve };
}
// `routes` maps a URL to: a plain JSON value (served as {ok:true}), a thenable that
// resolves to a full response object (for deferral/races), or nothing (served {ok:false}).
function loadWorkingTuning(routes = {}) {
const window = createWindow();
const changes = [];
window.fetch = function (url) {
const entry = routes[url];
if (entry && typeof entry.then === 'function') return entry;
if (entry !== undefined) return Promise.resolve({ ok: true, json: () => Promise.resolve(entry) });
return Promise.resolve({ ok: false, json: () => Promise.resolve(null) });
};
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(WORKING_TUNING_JS, 'utf8'), context, { filename: WORKING_TUNING_JS });
// capabilities.js replaces window.feedBack with an EventTarget bus — subscribe on it,
// not on window. Attaching after load still catches the async hydration event.
window.feedBack.on('working-tuning-changed', (ev) => changes.push(ev.detail));
return { window, wt: window.feedBack.workingTuning, changes };
}
// Rebase a possibly-vm-realm array into this realm so deepStrictEqual compares by value,
// not by (cross-realm) Array.prototype identity.
const nums = (a) => (a == null ? a : Array.from(a));
// Drain the seed's fetch/promise chain (settings -> tunings -> hydrate).
const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); };
test('registers a working-tuning exclusive-owner capability + versioned surface', () => {
const { window, wt } = loadWorkingTuning();
assert.equal(wt.version, 1);
const pipeline = window.feedBack.capabilities.inspect('working-tuning');
assert.ok(pipeline, 'working-tuning pipeline exists');
const owner = (pipeline.participants || []).find((p) => p.pluginId === 'core.working-tuning');
assert.ok(owner, 'core.working-tuning owner registered');
for (const op of ['get-working-tuning', 'set-working-tuning']) {
assert.ok(owner.operations.includes(op), `owner exposes ${op}`);
}
});
test('get() defaults to a synchronous guitar-6 assumed seed before hydration', () => {
const { wt } = loadWorkingTuning();
const s = wt.get();
assert.equal(s.instrument, 'guitar');
assert.equal(s.stringCount, 6);
assert.equal(s.provenance, 'assumed');
assert.equal(s.offsets, null);
});
test('per-instrument slots are isolated; the selector surfaces the right one', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
wt.set({ offsets: [0, 0, 0, 0] }, { instrument: 'bass-4' });
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, 0, 0, 0, 0, 0]);
assert.deepEqual(nums(wt.get('bass-4').offsets), [0, 0, 0, 0]);
// Selecting an instrument makes get() (no arg) return that instrument's own state.
wt.setCurrentInstrument('guitar', 6);
assert.deepEqual(nums(wt.get().offsets), [-2, 0, 0, 0, 0, 0]);
wt.setCurrentInstrument('bass', 4);
assert.deepEqual(nums(wt.get().offsets), [0, 0, 0, 0]);
});
test('defensive copies: readers and post-set callers cannot mutate live state', async () => {
const { wt } = loadWorkingTuning();
await flush();
const input = [-2, -2, -2, -2, -2, -2];
wt.set({ offsets: input }, { instrument: 'guitar-6' });
input[0] = 99; // mutate caller's array after set()
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'set() stored a copy');
const read = wt.get('guitar-6');
read.offsets[0] = 99; // mutate a returned copy
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'get() returned a copy');
});
test('verification invariant: verified <=> we hold verifiedStrings', async () => {
const { wt } = loadWorkingTuning();
await flush();
// A complete verified bundle stamps verified + a timestamp.
let s = wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.deepEqual(nums(s.verifiedStrings), [1, 1, 1, 1, 1, 1]);
assert.equal(typeof s.verifiedAt, 'number');
// Claiming verified on a tuning change WITHOUT fresh strings is impossible — it
// fails toward assumed and drops the metadata (no "verified with null strings").
s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
// verified always carries a real timestamp — an explicit verifiedAt:null is stamped now.
s = wt.set({ verifiedStrings: [1, 1, 1, 1, 1, 1], verifiedAt: null },
{ instrument: 'guitar-6', provenance: 'verified' });
assert.equal(s.provenance, 'verified');
assert.equal(typeof s.verifiedAt, 'number');
});
test('a tuning change invalidates a prior verification', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
{ instrument: 'guitar-6', provenance: 'verified' });
const s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.equal(s.verifiedAt, null);
});
test('bare-instrument writes target the current selection, not a hard-coded default', async () => {
const { wt } = loadWorkingTuning();
await flush();
wt.setCurrentInstrument('bass', 5); // a 5-string bass is selected
// A bare instrument string must write bass-5, not bass-4.
wt.set({ offsets: [0, 0, 0, 0, 0] }, { instrument: 'bass' });
assert.deepEqual(nums(wt.get('bass-5').offsets), [0, 0, 0, 0, 0]);
assert.equal(wt.get('bass-4').offsets, null, 'bass-4 slot untouched');
// A bare stringCount (no instrument) applies to the selected instrument.
const s = wt.set({ stringCount: 5, offsets: [-1, -1, -1, -1, -1] });
assert.equal(s.instrument, 'bass');
assert.equal(s.stringCount, 5);
});
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
const { wt, changes } = loadWorkingTuning({
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
'/api/tunings': TUNINGS,
});
await flush();
const s = wt.get('guitar-6');
assert.deepEqual(nums(s.offsets), [-2, 0, 0, 0, 0, 0], 'Drop D resolved to a -2 low string');
assert.equal(s.source, 'settings');
assert.equal(s.provenance, 'assumed');
// Hydration emitted once, carrying the seeded instrument.
const hydrations = changes.filter((c) => c.instrument === 'guitar');
assert.ok(hydrations.length >= 1, 'a working-tuning-changed fired for the seeded instrument');
});
test('seed accepts an offsets-list tuning directly', async () => {
const { wt } = loadWorkingTuning({
'/api/settings': { instrument: 'bass', string_count: 4, tuning: [-2, 0, 0, 0] },
});
await flush();
assert.deepEqual(nums(wt.get('bass-4').offsets), [-2, 0, 0, 0]);
});
test('boot race: an explicit set() before settings resolve is not clobbered by the seed', async () => {
const settings = deferred();
const { wt } = loadWorkingTuning({
'/api/settings': settings.promise, // held open
'/api/tunings': TUNINGS,
});
// A consumer writes before the seed lands.
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
// Now the seed resolves with a DIFFERENT tuning.
settings.resolve({ ok: true, json: () => Promise.resolve({ instrument: 'guitar', string_count: 6, tuning: 'Drop D' }) });
await flush();
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-5, -5, -5, -5, -5, -5], 'explicit write survived the seed');
});
test('resetToDefault clears a slot back to its baseline and emits', async () => {
const { wt, changes } = loadWorkingTuning();
await flush();
wt.set({ offsets: [-2, -2, -2, -2, -2, -2] }, { instrument: 'guitar-6', provenance: 'verified', verifiedStrings: [1, 1, 1, 1, 1, 1] });
const before = changes.length;
const s = wt.resetToDefault('guitar-6');
assert.equal(s.offsets, null);
assert.equal(s.provenance, 'assumed');
assert.equal(s.verifiedStrings, null);
assert.ok(changes.length > before, 'reset emitted working-tuning-changed');
});