mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
feat(core): working-tuning lifecycle — launch default, verified decay, idempotent re-injection + tests (working-tuning PR 9a) (#669)
Hardens the host workingTuning capability (PR 1) with the "polish & safety"
lifecycle:
- Idempotent re-injection: a second load of the module no longer replaces the live
state with a fresh (empty) one — it early-returns once registered.
- Opt-in "launch tuning" default (setLaunchDefault/getLaunchDefault/clearLaunchDefault):
a per-instrument, localStorage-backed seed the player can opt into ("start me in
THIS tuning on app open"). Boot seeds from it when set, else /api/settings as before.
Off by default — a SEED only; the live tuning still resets on restart.
- Verified decay: on song:loading the current instrument's 'verified' provenance
decays to 'assumed' (offsets kept) — a per-string mic check is only trustworthy for
the context it was done in, so a stale 'verified' can never suppress a needed prompt.
Adds a state-machine smoke suite (tests/js/working_tuning_capability.test.js, 12/12):
defaults, per-instrument isolation, both-directions, verified-invalidation-on-retune,
decay-on-song-load, resetToDefault, launch-default set/seed/clear, idempotent
re-injection, the change event.
The opt-in UI + the mic-verify writer land with the tuner (PR 9b).
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7173007412
commit
48f435408f
@@ -29,6 +29,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
window.feedBack = window.feedBack || {};
|
window.feedBack = window.feedBack || {};
|
||||||
|
// Idempotent: a second injection of this module must not replace the live state
|
||||||
|
// with a fresh (empty) one — once we're registered, re-running is a no-op.
|
||||||
|
if (window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1) return;
|
||||||
const capabilities = window.feedBack.capabilities;
|
const capabilities = window.feedBack.capabilities;
|
||||||
|
|
||||||
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
|
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
|
||||||
@@ -217,11 +220,53 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the SELECTED instrument's slot from settings on boot (best-effort 'assumed'
|
// ---- Opt-in "launch tuning" default (soft, per-instrument) -------------------
|
||||||
// starting point, NOT a persisted working tuning). settings.tuning may be an offsets
|
// A convenience the player opts into: "start me in THIS tuning on app open." Off
|
||||||
// list OR a name ("Drop D") — a name is resolved to offsets via /api/tunings so a
|
// by default (nothing stored) → boot seeds from /api/settings as before. It is
|
||||||
// named tuning isn't lost. If settings can't be read we still hydrate so consumers
|
// only a SEED — the live working tuning still resets on restart.
|
||||||
// aren't stuck waiting; an explicit set()/select before we resolve wins (no clobber).
|
const LAUNCH_KEY = 'v3-working-tuning-launch-default';
|
||||||
|
function _readLaunchMap() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LAUNCH_KEY) || '{}') || {}; }
|
||||||
|
catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
function _writeLaunchMap(map) {
|
||||||
|
try {
|
||||||
|
if (map && Object.keys(map).length) localStorage.setItem(LAUNCH_KEY, JSON.stringify(map));
|
||||||
|
else localStorage.removeItem(LAUNCH_KEY);
|
||||||
|
} catch (_) { /* private mode */ }
|
||||||
|
}
|
||||||
|
function getLaunchDefault(instrument) {
|
||||||
|
const key = _resolveKey(instrument);
|
||||||
|
const d = _readLaunchMap()[key];
|
||||||
|
return d ? Object.assign(_defaultState(key), d, { source: 'launch-default' }) : null;
|
||||||
|
}
|
||||||
|
// Remember an instrument's CURRENT working tuning (or a supplied state) as its
|
||||||
|
// launch default. Opt-in — nothing calls this unless the player asks.
|
||||||
|
function setLaunchDefault(instrument, state) {
|
||||||
|
const key = _resolveKey(instrument);
|
||||||
|
const src = state || get(key);
|
||||||
|
const map = _readLaunchMap();
|
||||||
|
map[key] = {
|
||||||
|
offsets: Array.isArray(src.offsets) ? src.offsets.slice() : null,
|
||||||
|
stringCount: src.stringCount,
|
||||||
|
instrument: _splitKey(key).instrument,
|
||||||
|
referencePitch: src.referencePitch || 440,
|
||||||
|
};
|
||||||
|
_writeLaunchMap(map);
|
||||||
|
return getLaunchDefault(key);
|
||||||
|
}
|
||||||
|
function clearLaunchDefault(instrument) {
|
||||||
|
const key = _resolveKey(instrument);
|
||||||
|
const map = _readLaunchMap();
|
||||||
|
if (key in map) { delete map[key]; _writeLaunchMap(map); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the SELECTED instrument's slot on boot (best-effort 'assumed' starting
|
||||||
|
// point, NOT a persisted working tuning): the player's opt-in launch default if one
|
||||||
|
// is set for this instrument, else /api/settings — where a NAMED tuning ("Drop D") is
|
||||||
|
// resolved to offsets via /api/tunings so it isn't lost. If neither can be read we
|
||||||
|
// still hydrate so consumers aren't stuck waiting; an explicit set()/select before we
|
||||||
|
// resolve wins (no clobber).
|
||||||
function _seedFromSettings() {
|
function _seedFromSettings() {
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||||
@@ -234,16 +279,25 @@
|
|||||||
function commit(offsets) {
|
function commit(offsets) {
|
||||||
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
|
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
|
||||||
_currentKey = key;
|
_currentKey = key;
|
||||||
_byInstrument[key] = {
|
// Opt-in launch default wins over the raw profile; otherwise use the
|
||||||
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
|
// resolved `offsets` (a named settings tuning was already turned into
|
||||||
stringCount: sc,
|
// offsets via /api/tunings before commit()).
|
||||||
instrument: inst,
|
const launch = _readLaunchMap()[key];
|
||||||
referencePitch: Number(s.reference_pitch) || 440,
|
_byInstrument[key] = launch
|
||||||
provenance: 'assumed',
|
? {
|
||||||
verifiedStrings: null,
|
offsets: Array.isArray(launch.offsets) ? launch.offsets.slice(0, sc) : null,
|
||||||
verifiedAt: null,
|
stringCount: sc, instrument: inst,
|
||||||
source: 'settings',
|
referencePitch: Number(launch.referencePitch) || 440,
|
||||||
};
|
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
||||||
|
source: 'launch-default',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
|
||||||
|
stringCount: sc, instrument: inst,
|
||||||
|
referencePitch: Number(s.reference_pitch) || 440,
|
||||||
|
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
||||||
|
source: 'settings',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
|
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
|
||||||
@@ -268,6 +322,24 @@
|
|||||||
_emitChanged(_currentKey || _resolveKey());
|
_emitChanged(_currentKey || _resolveKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A per-string mic verification is only trustworthy for the context it was done
|
||||||
|
// in — a new song means the player may have retuned, so a stale 'verified' must
|
||||||
|
// never suppress a needed prompt (fail toward re-checking). Decay the CURRENT
|
||||||
|
// instrument's verification back to 'assumed' on each song load; offsets are kept.
|
||||||
|
function _decayVerifiedOnSongLoad() {
|
||||||
|
const key = _currentKey || _resolveKey();
|
||||||
|
const st = _byInstrument[key];
|
||||||
|
if (st && st.provenance === 'verified') {
|
||||||
|
st.provenance = 'assumed';
|
||||||
|
st.verifiedStrings = null;
|
||||||
|
st.verifiedAt = null;
|
||||||
|
_emitChanged(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof window.feedBack.on === 'function') {
|
||||||
|
window.feedBack.on('song:loading', _decayVerifiedOnSongLoad);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
|
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
|
||||||
if (capabilities && capabilities.version === 1 &&
|
if (capabilities && capabilities.version === 1 &&
|
||||||
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
|
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
|
||||||
@@ -309,6 +381,9 @@
|
|||||||
set: set,
|
set: set,
|
||||||
setCurrentInstrument: setCurrentInstrument,
|
setCurrentInstrument: setCurrentInstrument,
|
||||||
resetToDefault: resetToDefault,
|
resetToDefault: resetToDefault,
|
||||||
|
getLaunchDefault: getLaunchDefault,
|
||||||
|
setLaunchDefault: setLaunchDefault,
|
||||||
|
clearLaunchDefault: clearLaunchDefault,
|
||||||
});
|
});
|
||||||
|
|
||||||
_seedFromSettings();
|
_seedFromSettings();
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// State-machine smoke tests for the host working-tuning capability
|
||||||
|
// (static/capabilities/working-tuning.js). Runs the real module in a vm sandbox
|
||||||
|
// with stubbed window.feedBack (emit/on + capabilities), localStorage and fetch.
|
||||||
|
|
||||||
|
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 SRC = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'capabilities', 'working-tuning.js'), 'utf8');
|
||||||
|
|
||||||
|
function makeSandbox(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const listeners = {};
|
||||||
|
const store = opts.localStorage || {};
|
||||||
|
const settings = opts.settings || { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||||
|
const localStorage = {
|
||||||
|
getItem: (k) => (k in store ? store[k] : null),
|
||||||
|
setItem: (k, v) => { store[k] = String(v); },
|
||||||
|
removeItem: (k) => { delete store[k]; },
|
||||||
|
};
|
||||||
|
const sandbox = {
|
||||||
|
window: {
|
||||||
|
feedBack: {
|
||||||
|
capabilities: { version: 1, registerOwner() {}, registerParticipant() {} },
|
||||||
|
emit(ev, detail) { (listeners[ev] || []).slice().forEach((fn) => fn(detail)); },
|
||||||
|
on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||||
|
},
|
||||||
|
localStorage,
|
||||||
|
},
|
||||||
|
localStorage,
|
||||||
|
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve(settings) }),
|
||||||
|
console, Promise, Date, Array, Object, JSON, Number, isFinite, setTimeout,
|
||||||
|
};
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(SRC, sandbox);
|
||||||
|
return {
|
||||||
|
wt: () => sandbox.window.feedBack.workingTuning,
|
||||||
|
emit: (ev, d) => sandbox.window.feedBack.emit(ev, d),
|
||||||
|
on: (ev, fn) => sandbox.window.feedBack.on(ev, fn),
|
||||||
|
reinject: () => vm.runInContext(SRC, sandbox),
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
test('defaults: an unset instrument is assumed with null offsets', () => {
|
||||||
|
const d = makeSandbox().wt().get('guitar-6');
|
||||||
|
assert.equal(d.offsets, null);
|
||||||
|
assert.equal(d.provenance, 'assumed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set + get round-trips, per-instrument isolation (guitar vs bass)', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(s.wt().get('bass-4').offsets, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both directions: E → Drop D → back to E via set()', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||||
|
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||||
|
s.wt().set({ offsets: [0, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||||
|
assert.deepEqual(s.wt().get('guitar-6').offsets, [0, 0, 0, 0, 0, 0]); // came back
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the tuning invalidates a prior verification', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
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.wt().get('guitar-6').provenance, 'verified');
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||||
|
assert.equal(s.wt().get('guitar-6').provenance, 'assumed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verified decays to assumed on song:loading (offsets kept)', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
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.wt().get('guitar-6').provenance, 'verified');
|
||||||
|
s.emit('song:loading', { filename: 'x' });
|
||||||
|
assert.equal(s.wt().get('guitar-6').provenance, 'assumed');
|
||||||
|
assert.deepEqual(s.wt().get('guitar-6').offsets, [0, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetToDefault clears back to defaults', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||||
|
s.wt().resetToDefault('guitar-6');
|
||||||
|
assert.equal(s.wt().get('guitar-6').offsets, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launch default: setLaunchDefault persists + getLaunchDefault returns it', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
s.wt().setLaunchDefault('guitar-6');
|
||||||
|
assert.deepEqual(s.wt().getLaunchDefault('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||||
|
assert.ok('v3-working-tuning-launch-default' in s.store);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launch default is opt-in: none set → getLaunchDefault is null', () => {
|
||||||
|
assert.equal(makeSandbox().wt().getLaunchDefault('guitar-6'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launch default seeds a fresh boot and wins over the raw profile', async () => {
|
||||||
|
const store = {};
|
||||||
|
const s1 = makeSandbox({ localStorage: store });
|
||||||
|
s1.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
s1.wt().setLaunchDefault('guitar-6');
|
||||||
|
// Fresh boot with the SAME localStorage; settings say Standard → launch default wins.
|
||||||
|
const s2 = makeSandbox({ localStorage: store, settings: { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 } });
|
||||||
|
await tick();
|
||||||
|
assert.deepEqual(s2.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||||
|
assert.equal(s2.wt().get('guitar-6').source, 'launch-default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearLaunchDefault removes it', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
s.wt().setLaunchDefault('guitar-6');
|
||||||
|
s.wt().clearLaunchDefault('guitar-6');
|
||||||
|
assert.equal(s.wt().getLaunchDefault('guitar-6'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent re-injection: a second load does NOT clobber live state', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
s.reinject(); // run the module source again in the same context
|
||||||
|
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]); // preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
test('working-tuning-changed fires on set with the changed instrument', () => {
|
||||||
|
const s = makeSandbox();
|
||||||
|
const seen = [];
|
||||||
|
s.on('working-tuning-changed', (e) => seen.push(e));
|
||||||
|
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||||
|
assert.ok(seen.length >= 1);
|
||||||
|
const last = seen[seen.length - 1];
|
||||||
|
assert.equal(last.instrument, 'guitar');
|
||||||
|
assert.deepEqual(last.tuning.offsets, [-2, 0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user