Files
feedBack/tests/js/highway_monotonic_clock.test.js
T
8e89b39ad3 refactor(highway): carve the constants into static/js/highway-constants.js (R3c) (#914)
29 constants, 190 lines. highway.js 4,267 -> 4,158. The first real slice, and the one that
every later one imports.

━━━ WHY ONLY THE CONSTANTS MAY LIVE AT MODULE SCOPE ━━━

createHighway() is a FACTORY, not a singleton. The constitution publishes
window.createHighway precisely so a plugin can build a SECOND highway for its own panel, and
highway.js already says so at the top of the closure:

    // R3c: per-instance mutable state in one object, so extracted renderer/ws
    // modules can close over it as a factory arg without cross-panel sharing.

So hwState — all 79 mutable properties — must NEVER become a module-level singleton: two
highways would silently share it, and one panel would drive the other's clock, scale and
colour tables. Extracted functions will take it as an ARGUMENT.

That is the OPPOSITE of the app.js carve, where a single state container (player-state.js,
library-state.js) was exactly right, because there is exactly one app. Same epic, same
language, opposite answer — because one is a singleton and the other is a factory.

These 29 are pure literals: numbers, strings and colour tables, never reassigned, never
mutated. Sharing them across instances is not merely safe, it is what you want — one copy of
the shimmer LUT bounds and the string palettes rather than one per panel. Anything with a
runtime dependency (document, window, performance, localStorage) stays in the factory;
checked, and none of these has one.

ESLint now knows static/highway.js is a module. It could not have known before this commit:
the flip (#913) changed the SCRIPT TAG, but the file had no import/export yet, so it still
parsed as a script and lint stayed green. The first `import` is what makes the config wrong.

TESTS. Four source-shape harnesses asserted `const _AUTO_SCALE_MIN = …` etc. lived in
highway.js. They now read highway.js AND every static/js/highway-*.js — deliberately, rather
than being re-pinned at whichever file currently holds a constant. Re-pinning just breaks
again on the next carve, and a source-shape assertion that silently stops finding its target
is indistinguishable from one that passes. Bite-tested: renaming two constants away fails
them.

VERIFIED. A/B against origin/main: 15 probes IDENTICAL, zero page errors. AND THE PERF GATE
PASSES AT 1.97ms against its 12ms budget — which is the point of having built it (#910)
first: these constants moved from closure scope to module scope, and V8 does not treat those
identically. It does here. Now I know rather than hope.

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:24:40 +02:00

285 lines
13 KiB
JavaScript

// Verify static/highway.js's getTime() interpolates smoothly via
// performance.now() between setTime() calls (so plugins observe sub-
// frame clock motion despite audio.currentTime's coarse step
// quantization — browsers refresh the reported value at ~20+ ms
// granularity even though the underlying audio thread runs faster).
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 HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
// Brace-balanced extraction so source-level tests stay robust to body
// growth. Returns the full method-or-block text including its braces.
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${signature}' not found`);
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++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
// Build a sandbox with the chart-clock state and the extracted setTime
// + getTime methods so behavioral tests can exercise the real
// implementation in isolation.
function buildClockSandbox(perfNowImpl) {
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
const hwState = {
chartTime: 0,
currentTime: 0,
avOffsetSec: 0,
// Per-song chart offset (loose-folder format only). Tests pin
// this at 0 so the clock-behaviour assertions can stay
// expressed in raw audio time without offset bookkeeping.
songOffset: 0,
// Match production: NaN sentinels for "no prior anchor" so
// the first setTime call always re-anchors (even setTime(0))
// and so getTime() before any setTime returns chartTime.
_chartAnchorAudioT: NaN,
_chartAnchorPerfNow: NaN,
_chartLastAdvanceAt: 0,
_chartObservedRate: 1,
};
const sandbox = {
hwState,
_CHART_MAX_INTERP_MS: 100,
performance: { now: perfNowImpl },
};
vm.createContext(sandbox);
const src = highwaySources();
const setTimeBody = extractBlock(src, 'setTime(t) {');
const getTimeBody = extractBlock(src, 'getTime() {');
// Strip trailing comma if present (object-literal method declarations).
const cleanup = (s) => s.replace(/,?\s*$/, '');
vm.runInContext(`
globalThis.setTime = function ${cleanup(setTimeBody)};
globalThis.getTime = function ${cleanup(getTimeBody)};
`, sandbox);
return sandbox;
}
// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the
// whole set. Re-pinning these assertions at whichever file currently holds a constant just
// means they break again on the next carve — and worse, a source-shape assertion that silently
// stops finding its target is indistinguishable from one that passes.
function highwaySources() {
const root = path.join(__dirname, '..', '..');
const jsDir = path.join(root, 'static', 'js');
const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.startsWith('highway-') && f.endsWith('.js')) {
parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
}
return parts.join('\n');
}
test('highway declares chart anchor + stall-detect + rate state', () => {
const src = highwaySources();
// Both anchor fields use NaN sentinels — _chartAnchorAudioT in
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
// and never re-anchors, leaving the clock uninitialized.
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
assert.match(src, /(?:export\s+)?const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
});
test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', () => {
const src = highwaySources();
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
assert.ok(m, 'getTime() body not found');
const slice = m[0];
assert.match(
slice,
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
);
});
test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => {
const src = highwaySources();
// Repeated setTime calls with the same value must not refresh the
// anchor (else interpolation stutters); they also must not refresh
// _chartLastAdvanceAt (else getTime would never detect a stalled
// audio clock as paused).
// The implementation may capture performance.now() into a local
// (e.g. newPerfNow) and assign that to both fields; accept either
// direct or via-local writes.
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
const block = m[0];
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
});
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
const src = highwaySources();
// Find the actual getTime body. Match the whole brace-balanced
// method (using a generous greedy slice to ensure we capture both
// the stall check and the interpolation expression below it).
const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/);
assert.ok(m, 'getTime() body not found');
const slice = m[0];
// Must check stall-since-last-advance against the cap.
assert.match(
slice,
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
'getTime must short-circuit when audio has stalled past the cap',
);
// Must interpolate when active.
assert.match(slice, /performance\.now\(\)|nowP/, 'getTime must use perfNow');
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
assert.match(
slice,
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
'getTime must compute anchor + rate-scaled elapsed during play',
);
});
test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
const src = highwaySources();
// Use the brace-balanced extractor so the assertions are scoped to
// the actual stop() body — a fixed-size slice would falsely match
// resets that landed in an adjacent method.
const stopBlock = extractBlock(src, 'stop() {');
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
});
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
test('behavior: getTime interpolates smoothly between two anchors at 1x', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
// First anchor at audioT=10, perf=0.
sb.setTime(10);
// Browser hasn't refreshed audio.currentTime; setTime called again
// with the same value at perf=16. Anchor must NOT move.
now = 16;
sb.setTime(10);
// Plugin reads at perf=24 — interpolated 24ms from anchor.
now = 24;
const t = sb.getTime();
assert.ok(Math.abs(t - (10 + 0.024)) < 0.001, `expected ~10.024, got ${t}`);
});
test('behavior: getTime returns chartTime when audio has stalled (paused)', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 16; sb.setTime(10);
// 200ms after the last advance — well past the 100ms cap. Even
// though setTime is still being called every 16ms with the same
// value (the 60Hz tick), getTime must report raw chartTime.
now = 200;
const t = sb.getTime();
assert.equal(t, 10, `paused getTime must be chartTime (10), got ${t}`);
});
test('behavior: getTime adjusts for non-1x playback rate (observed)', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
// Establish initial anchor.
sb.setTime(10);
// Audio advanced 0.025s in 50ms real time → observed rate = 0.5.
now = 50;
sb.setTime(10.025);
// Read 25ms after the latest anchor: chart should advance by
// rate * elapsed = 0.5 * 0.025 = 0.0125 → 10.0375.
now = 75;
const t = sb.getTime();
assert.ok(Math.abs(t - 10.0375) < 0.0005, `expected ~10.0375 (rate-scaled), got ${t}`);
});
test('behavior: seek discontinuity resets observed rate to 1x', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
// Seek: large t jump in same perf delta — observed-rate clamp
// rejects this segment, resets to 1.
now = 70;
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
});
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.05); // observed rate ~1
// Long pause-like gap with NO setTime call. getTime should detect
// (now - _chartLastAdvanceAt > 100) and return chartTime.
now = 200;
const t = sb.getTime();
assert.equal(t, 10.05, 'beyond cap must fall back to chartTime');
});
test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', () => {
// Regression: _chartAnchorAudioT used to start at 0, so setTime(0)
// on the very first 60 Hz tick failed the `t !== _chartAnchorAudioT`
// check and skipped the re-anchor branch entirely. _chartAnchorPerfNow
// would stay NaN, and getTime would propagate NaN to plugins.
let now = 16; // realistic first-tick perf
const sb = buildClockSandbox(() => now);
sb.setTime(0);
// Anchor must now be initialized.
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
// getTime should return a finite value, not NaN.
const t = sb.getTime();
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
});
test('behavior: getTime before any setTime returns chartTime (no NaN)', () => {
// Regression: getTime called during early boot (before the first
// 60 Hz tick) used to compute `nowP - NaN` and propagate NaN. Must
// bail to chartTime when the anchor is still the NaN sentinel.
let now = 0;
const sb = buildClockSandbox(() => now);
const t = sb.getTime();
assert.equal(t, 0, 'getTime before any setTime must return chartTime, not NaN');
assert.ok(!Number.isNaN(t), 'getTime must not return NaN');
});
test('behavior: long anchor gap resets observed rate to 1x', () => {
// After a long gap (e.g. tab inactive, paused for a while), the
// next setTime() shouldn't carry forward the prior segment's
// observed rate — it likely reflects a different playback state.
let now = 0;
const sb = buildClockSandbox(() => now);
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
// window, so the rate must reset to 1.
now = 1100;
sb.setTime(10.5);
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
});