mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
* settings: add host instrument profiles Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * settings: add instrument pathway selection Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5 Five regressions from the instrument-profiles rework: 1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST froze default profiles into config.json (broke test_empty_post_preserves_all_existing_keys). Gate on the save touching instrument settings; GET already virtualizes profiles. 2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a no-op. reset_settings now resets pathway inside the persisted profiles too. 3. Per-profile tuning validation rejected provider/custom tunings (tuner plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to every built-in table while still rejecting a built-in misapplied to the wrong key. 4. First-migration overwrote an explicit active_instrument_profile with the legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use setdefault so an explicit request wins. 5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a 4-string tuning). Updated to the valid 'Drop A'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch Two partial-update follow-ups: - save_settings normalized a POSTed instrument_profiles by FILLING every omitted profile with defaults and replacing wholesale, so a one-profile update reset the others. Validate each PROVIDED profile individually and merge the partial over the persisted set inside the lock — /api/settings is partial-merge. - the string-count picker posted only string_count, so the backend silently reset a now-invalid tuning to Standard while the UI kept the old value (settings/tuner desync). Clamp + post the valid tuning too, mirroring the instrument-switch path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
10 KiB
JavaScript
208 lines
10 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');
|
|
|
|
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
|
|
const TUNING_TABLE = {
|
|
'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],
|
|
},
|
|
};
|
|
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
|
|
|
|
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': API_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': API_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');
|
|
});
|