feedBack/tests/js/settings_autosave.test.js
Byron Gamatos 84fe29688c
refactor(app): carve settings into static/js/settings.js (R3d) (#920)
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>
2026-07-12 14:08:45 +02:00

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');
});