mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
22 declarations, 446 lines. app.js 4,218 -> 3,772. Bodies VERBATIM. Settings load/save, the AV-offset nudge, the default-arrangement pin, the instrument pathway, and the app-update channel. ━━━ INTERFACE WIDTH 1, AND IT GOT THERE BY DRAWING THE BOUNDARY IN THE RIGHT PLACE ━━━ app.js calls loadSettings() and nothing else. The first cut was NOT clean: _defaultArrangement was written from OUTSIDE the cluster, and an imported binding is READ-ONLY, so that one write would have forced a setter or a state container — as it did for the player (player-state.js) and the library (library-state.js). But the writers were saveSettings and pinCurrentArrangementDefault, which ARE settings functions. Widening the slice to include them left ZERO outside writes. Every export is now a plain read-only import and no container is needed. Worth naming, because I reached for a container twice before: the fix for "this binding is written from outside" is sometimes a container, and sometimes it just means the boundary is in the wrong place. Measure the writers before you build machinery. ━━━ handleSliderInput STAYS A HOST HOOK, DELIBERATELY ━━━ It lives in settings now (it is a settings control), but player-controls.js must NOT import it: this module already imports player-controls (_applyMastery, _autoplayExitEnabled, …), so a direct back-import would close a cycle. player-controls keeps reading it through the host seam, and app.js — the root, which imports both — wires it. That is exactly what the seam is for, and the contract test proves the wiring survived. VERIFIED. A/B against origin/main in two browsers: the window contract, the settings screen rendering, the AV-offset and default-arrangement controls present, and a real `input` event dispatched on a slider — which is the path that goes through the host seam. IDENTICAL, zero page errors. node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.8 KiB
JavaScript
126 lines
4.8 KiB
JavaScript
// Verify the Settings-dropdown autosave path in static/js/settings.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');
|
|
|
|
// R3d: settings was carved out of app.js into its own module. Bodies unchanged — only the file
|
|
// moved. It went cleanly because the WRITERS came with it: _defaultArrangement was the one
|
|
// binding written from outside the cluster, by saveSettings and pinCurrentArrangementDefault,
|
|
// which are themselves settings functions. Widening the slice to include them left zero outside
|
|
// writes, so no state container was needed.
|
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'settings.js');
|
|
|
|
function extractFunction(src, signature) {
|
|
const start = src.indexOf(signature);
|
|
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in settings.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');
|
|
});
|