diff --git a/eslint.config.js b/eslint.config.js index 59e0017..77d878c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -55,7 +55,7 @@ module.exports = [ // module graph, which is what makes no-cycle meaningful here — a carved // module that imports app.js back would close a cycle and fail this gate. { - files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'], + files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js', 'static/highway.js'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, plugins: { 'import-x': importX }, // v4 flat-config resolver (resolver-next + createNodeResolver). Without diff --git a/static/highway.js b/static/highway.js index 7188aea..9d9405c 100644 --- a/static/highway.js +++ b/static/highway.js @@ -2,6 +2,38 @@ * Canvas-based note highway renderer. * Receives note data via WebSocket, renders on requestAnimationFrame. */ +import { + BG, + CHAIN_GAP_THRESHOLD, + CHAIN_RENDER_FULL_MAX, + CHORD_FRAME_FRETS, + DEFAULT_STRING_BRIGHT, + DEFAULT_STRING_COLORS, + DEFAULT_STRING_DIM, + FRETLINE_TARGET_OFFSET, + FRETLINE_WINDOW_AFTER, + FRETLINE_WINDOW_BEFORE, + MAX_RENDERER_DRAW_FAILURES, + MUTE_BOX_BAR, + MUTE_BOX_STROKE, + REPEAT_BOX_BAR, + REPEAT_BOX_FILL, + VISIBLE_SECONDS, + Z_CAM, + Z_MAX, + _AUTO_ADJUST_COOLDOWN_MS, + _AUTO_SCALE_MIN, + _AUTO_UPSCALE_COOLDOWN_MS, + _CHART_MAX_INTERP_MS, + _DOM_VIS_CHECK_FRAMES, + _DRAW_BUDGET_HI_MS, + _DRAW_BUDGET_LO_MS, + _LYRIC_MEASURE_INNER_MAX, + _LYRIC_MEASURE_OUTER_MAX, + _PAUSED_FRAME_INTERVAL_MS, + _SHIMMER_LUT_SIZE, +} from './js/highway-constants.js'; + function createHighway() { // 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. @@ -111,11 +143,6 @@ function createHighway() { // re-anchor refines the estimate from the latest segment. Default // to 1 until we have two anchors to compare. hwState._chartObservedRate = 1; - // Cap the interpolation so a stalled main thread (long task, GC, - // dropped tick) can't make getTime drift far past reality. Also the - // threshold for "audio looks paused" — if setTime hasn't advanced t - // in this long, treat as paused. - const _CHART_MAX_INTERP_MS = 100; // Visibility-aware rAF (feedBack#246): when the canvas is hidden // (display:none on itself or any ancestor — e.g. splitscreen's // workaround), pause renderer.draw and emit highway:visibility on @@ -125,39 +152,9 @@ function createHighway() { // where offsetParent === null isn't enough. hwState._visibleOverride = null; hwState._lastVisible = null; - // Throttled DOM visibility sampling. Reading canvas.offsetParent - // every rAF frame forces a style/layout recalc — profiled at ~0.5 s - // main-thread self-time over a 63 s session. The displayed state - // changes rarely (navigate / splitscreen panel toggle), so the DOM - // is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached - // value serves the frames in between (worst-case transition latency - // ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal). - // Set _domVisSampledFrame to NaN to force a fresh sample on the next - // check (done on init, canvas replace, resize, and override-clear so - // deliberate transitions don't wait out the throttle window). - // NOTE those manual resets are LATENCY optimizations, not correctness - // requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES - // frames regardless, so a visibility-affecting path that forgets to - // reset self-heals within ~10 frames — stale visibility can never be - // served indefinitely. - const _DOM_VIS_CHECK_FRAMES = 10; hwState._domVisCached = false; hwState._domVisSampledFrame = NaN; hwState.animFrame = null; - // Paused-render throttle (feedBack#654). The rAF loop runs - // unconditionally and only gates on visibility + ready, never on - // playback — so an expensive renderer (3D Highway's Three.js WebGL - // scene) does a full render every frame even while paused. That is - // pure waste, and the dominant cost on high-refresh / ANGLE setups - // (Chromium on Windows paces rAF to the fastest attached monitor, - // so the loop can run at 144 Hz even on a 60 Hz panel). While the - // audio clock is stalled, cap draws to one per - // _PAUSED_FRAME_INTERVAL_MS. Note position is clock-derived - // (n.t - currentTime), so this changes smoothness only — never - // audio/visual sync. A low non-zero rate (not a hard skip) keeps - // resize / seek-scrub / renderer-swap repaints correct without - // having to hook each of those paths. - const _PAUSED_FRAME_INTERVAL_MS = 100; hwState._lastPausedDrawAt = 0; hwState._connectOpts = {}; hwState._resizeContainer = null; @@ -273,9 +270,9 @@ function createHighway() { hwState._perfHud = null; hwState._hudOn = false; // cached highwayPerfHud flag (re-read ~2x/sec, not per-frame) hwState._hudFlagAt = 0; - const _DRAW_BUDGET_HI_MS = 12; // sustained draw cost above this -> scale down - const _DRAW_BUDGET_LO_MS = 7; // sustained draw cost below this -> scale back up - const _AUTO_SCALE_MIN = 0.25; // hard floor (lowest the user-configurable floor may be set) + // sustained draw cost above this -> scale down + // sustained draw cost below this -> scale back up + // hard floor (lowest the user-configurable floor may be set) // User-configurable floor for the load-adaptive render scale (#654): // 0.25 = stock (can drop to quarter-res on heavy frames → pixelated), // 1.0 = never auto-downscale below the Quality (renderScale) ceiling — so @@ -287,12 +284,6 @@ function createHighway() { const v = parseFloat(localStorage.getItem('highwayMinRenderScale')); return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN; })(); - const _AUTO_ADJUST_COOLDOWN_MS = 600; - // Upscaling is deliberately LAZY (longer cooldown than the downscale path) so - // the resolution doesn't visibly hunt up/down on passages that hover near the - // budget — testers saw "quality going up and down" as parts got busier (#618 - // charrette). Downscale stays prompt to protect the frame rate. - const _AUTO_UPSCALE_COOLDOWN_MS = 2500; hwState._inverted = localStorage.getItem('invertHighway') === 'true'; hwState._lefty = localStorage.getItem('lefty') === '1'; hwState._lastChordOnFretLine = null; // chord object currently shown on fret line @@ -311,22 +302,6 @@ function createHighway() { // shimmer). Incremented once per rAF in draw(). hwState._frameIdx = 0; - // 64-entry precomputed jitter LUT replacing Math.random() in the - // lit-sustain shimmer hot path (drawSustains). Visually - // indistinguishable from per-frame Math.random at rAF cadence, - // allocation-free, and removes 4 RNG calls per visible lit sustain - // per frame on dense charts. Seeded deterministically (xorshift32) - // so the LUT itself is identical across `createHighway()` instances - // — shimmer is therefore reload-stable and test-reproducible PER - // instance for a given (frameIdx, n.s, n.t) seed. The seed includes - // closure-scope `_frameIdx` which is per-instance, so two - // splitscreen highways with different rAF cadence will shimmer - // differently at any given wall-clock moment; what's stable is the - // LUT contents. - // - // _SHIMMER_LUT_SIZE MUST stay a power of two — `_shimmerNoise` - // indexes with `& (_SHIMMER_LUT_SIZE - 1)` for the cheap modulo. - const _SHIMMER_LUT_SIZE = 64; const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE); for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) { let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap @@ -341,22 +316,6 @@ function createHighway() { return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)]; } - // Memoize ctx.measureText() for the lyric overlay. Per-syllable - // measurement was the dominant cost in dense karaoke charts; text - // and fontSize are the only inputs (font face string is constant - // `bold ${fontSize}px sans-serif`). Two-level Map (outer: fontSize, - // inner: text) so a cache hit avoids the `fontSize + '|' + text` - // concat that previously allocated on every lookup. - // - // Bounded on BOTH levels: window resizes change `fontSize`, so each - // resize creates a fresh inner Map; without an outer cap, the cache - // would retain every fontSize ever rendered for the page lifetime. - // Cap outer at 16 distinct fontSize buckets (more than enough — a - // session typically sees one or two), inner at 4096 entries per - // bucket. Clear-on-overflow on both — a karaoke cold start re-warms - // in one frame. - const _LYRIC_MEASURE_OUTER_MAX = 16; - const _LYRIC_MEASURE_INNER_MAX = 4096; const _lyricMeasureCache = new Map(); // Map> function _measureLyricText(c, fontSize, text) { let inner = _lyricMeasureCache.get(fontSize); @@ -374,36 +333,6 @@ function createHighway() { return w; } - // Rendering config - const VISIBLE_SECONDS = 3.0; - const Z_CAM = 2.2; - const Z_MAX = 10.0; - const BG = '#080810'; - - // String color palettes. Indices 0–5 cover guitar / bass; 6–7 - // are added for extended-range GP imports (7-string, 8-string). - // Lookups still use `|| '#888'` as a safety fallback for any - // out-of-range index. - // - // These are `let`, not `const`: setStringColors() (used by the core - // "Highway String Colors" theming UI) overrides per-index entries at - // runtime, deriving the dim/bright variants from the chosen base color. - // DEFAULT_* keep the originals so a reset restores them byte-for-byte. - const DEFAULT_STRING_COLORS = [ - '#cc0000', '#cca800', '#0066cc', - '#cc6600', '#00cc66', '#9900cc', - '#cc00aa', '#00cccc', // 7th = magenta, 8th = teal - ]; - const DEFAULT_STRING_DIM = [ - '#520000', '#524200', '#002952', - '#522900', '#005229', '#3d0052', - '#520042', '#005252', - ]; - const DEFAULT_STRING_BRIGHT = [ - '#ff3c3c', '#ffe040', '#3c9cff', - '#ff9c3c', '#3cff9c', '#cc3cff', - '#ff3ce0', '#3ce0e0', - ]; hwState.STRING_COLORS = DEFAULT_STRING_COLORS.slice(); hwState.STRING_DIM = DEFAULT_STRING_DIM.slice(); hwState.STRING_BRIGHT = DEFAULT_STRING_BRIGHT.slice(); @@ -964,8 +893,6 @@ function createHighway() { // every frame. Reset on every successful draw and whenever a new // renderer is installed. hwState._rendererDrawFailures = 0; - const MAX_RENDERER_DRAW_FAILURES = 3; - // True only while the current renderer has had a successful init // since its last destroy (or was freshly installed but never init'd // because canvas was null). Gates destroy calls so an uninit'd @@ -2705,42 +2632,6 @@ function createHighway() { return lo; } - // ── Chord rendering — chains, frames, fretline preview (feedBack#88) ── - // - // Charts often repeat the same chord shape several times in a - // row (e.g. a G strummed 4 times). We call a contiguous run of same-id - // chords with gaps < CHAIN_GAP_THRESHOLD a "chain". Chains drive two - // visual choices: - // • The first chord in a chain renders in full; subsequent chords in - // a chain of CHAIN_RENDER_FULL_MAX or longer render as a "repeat - // box" — a translucent boxed frame so the eye can see the rhythm - // pattern without re-scanning identical fret numbers. - // • Each chord anchors a CHORD_FRAME_FRETS-wide frame; muted and - // open-only chords inherit the frame from their predecessor so - // they don't snap to fret 0. - // - // We compute chain stats and frame anchors once per `src` array via - // _ensureChordRenderCache (lazy, invalidates when the array reference - // changes — which happens on chord ingest, mastery rebuild, or song - // reset). The render path is then pure read. - const CHAIN_GAP_THRESHOLD = 0.5; - const CHAIN_RENDER_FULL_MAX = 4; - const CHORD_FRAME_FRETS = 4; - - // Fretline preview: the static fret line at the bottom shows the chord - // closest to the strum line (currentTime + FRETLINE_TARGET_OFFSET) within - // the [target - FRETLINE_WINDOW_BEFORE, target + FRETLINE_WINDOW_AFTER] - // window, as a teaching aid. - const FRETLINE_TARGET_OFFSET = -0.25; - const FRETLINE_WINDOW_BEFORE = 0.1; - const FRETLINE_WINDOW_AFTER = 0.3; - - // Repeat / mute box colors. - const REPEAT_BOX_FILL = 'rgba(48, 80, 128, 0.06)'; - const REPEAT_BOX_BAR = '#50a0dc'; - const MUTE_BOX_STROKE = '#6060809b'; - const MUTE_BOX_BAR = '#606080d1'; - // Reset all chord-render-derived state. Called from init() and // reconnect() so per-song state (preview, frame-mismatch warnings, // chain cache) doesn't leak across songs that reuse chord IDs. diff --git a/static/js/highway-constants.js b/static/js/highway-constants.js new file mode 100644 index 0000000..98905f0 --- /dev/null +++ b/static/js/highway-constants.js @@ -0,0 +1,190 @@ +// highway.js's immutable constants: geometry, colour tables, timing budgets, and the +// load-adaptive render-scale thresholds. +// +// WHY THESE — AND ONLY THESE — 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 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 MUTABLE state (hwState) must never become a module-level singleton — two highways would +// silently share it. That is the opposite of the app.js carve, where a single state container +// was right because there is exactly one app. +// +// These 29 are pure literals: frozen numbers, strings and colour tables, never reassigned and +// never mutated. Sharing them across instances is not just 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: none of these has one. + +// Cap the interpolation so a stalled main thread (long task, GC, +// dropped tick) can't make getTime drift far past reality. Also the +// threshold for "audio looks paused" — if setTime hasn't advanced t +// in this long, treat as paused. +export const _CHART_MAX_INTERP_MS = 100; + +// Throttled DOM visibility sampling. Reading canvas.offsetParent +// every rAF frame forces a style/layout recalc — profiled at ~0.5 s +// main-thread self-time over a 63 s session. The displayed state +// changes rarely (navigate / splitscreen panel toggle), so the DOM +// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached +// value serves the frames in between (worst-case transition latency +// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal). +// Set _domVisSampledFrame to NaN to force a fresh sample on the next +// check (done on init, canvas replace, resize, and override-clear so +// deliberate transitions don't wait out the throttle window). +// NOTE those manual resets are LATENCY optimizations, not correctness +// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES +// frames regardless, so a visibility-affecting path that forgets to +// reset self-heals within ~10 frames — stale visibility can never be +// served indefinitely. +export const _DOM_VIS_CHECK_FRAMES = 10; + +// Paused-render throttle (feedBack#654). The rAF loop runs +// unconditionally and only gates on visibility + ready, never on +// playback — so an expensive renderer (3D Highway's Three.js WebGL +// scene) does a full render every frame even while paused. That is +// pure waste, and the dominant cost on high-refresh / ANGLE setups +// (Chromium on Windows paces rAF to the fastest attached monitor, +// so the loop can run at 144 Hz even on a 60 Hz panel). While the +// audio clock is stalled, cap draws to one per +// _PAUSED_FRAME_INTERVAL_MS. Note position is clock-derived +// (n.t - currentTime), so this changes smoothness only — never +// audio/visual sync. A low non-zero rate (not a hard skip) keeps +// resize / seek-scrub / renderer-swap repaints correct without +// having to hook each of those paths. +export const _PAUSED_FRAME_INTERVAL_MS = 100; + +export const _DRAW_BUDGET_HI_MS = 12; + +export const _DRAW_BUDGET_LO_MS = 7; + +export const _AUTO_SCALE_MIN = 0.25; + +export const _AUTO_ADJUST_COOLDOWN_MS = 600; + +// Upscaling is deliberately LAZY (longer cooldown than the downscale path) so +// the resolution doesn't visibly hunt up/down on passages that hover near the +// budget — testers saw "quality going up and down" as parts got busier (#618 +// charrette). Downscale stays prompt to protect the frame rate. +export const _AUTO_UPSCALE_COOLDOWN_MS = 2500; + +// 64-entry precomputed jitter LUT replacing Math.random() in the +// lit-sustain shimmer hot path (drawSustains). Visually +// indistinguishable from per-frame Math.random at rAF cadence, +// allocation-free, and removes 4 RNG calls per visible lit sustain +// per frame on dense charts. Seeded deterministically (xorshift32) +// so the LUT itself is identical across `createHighway()` instances +// — shimmer is therefore reload-stable and test-reproducible PER +// instance for a given (frameIdx, n.s, n.t) seed. The seed includes +// closure-scope `_frameIdx` which is per-instance, so two +// splitscreen highways with different rAF cadence will shimmer +// differently at any given wall-clock moment; what's stable is the +// LUT contents. +// +// _SHIMMER_LUT_SIZE MUST stay a power of two — `_shimmerNoise` +// indexes with `& (_SHIMMER_LUT_SIZE - 1)` for the cheap modulo. +export const _SHIMMER_LUT_SIZE = 64; + +// Memoize ctx.measureText() for the lyric overlay. Per-syllable +// measurement was the dominant cost in dense karaoke charts; text +// and fontSize are the only inputs (font face string is constant +// `bold ${fontSize}px sans-serif`). Two-level Map (outer: fontSize, +// inner: text) so a cache hit avoids the `fontSize + '|' + text` +// concat that previously allocated on every lookup. +// +// Bounded on BOTH levels: window resizes change `fontSize`, so each +// resize creates a fresh inner Map; without an outer cap, the cache +// would retain every fontSize ever rendered for the page lifetime. +// Cap outer at 16 distinct fontSize buckets (more than enough — a +// session typically sees one or two), inner at 4096 entries per +// bucket. Clear-on-overflow on both — a karaoke cold start re-warms +// in one frame. +export const _LYRIC_MEASURE_OUTER_MAX = 16; + +export const _LYRIC_MEASURE_INNER_MAX = 4096; + +// Rendering config +export const VISIBLE_SECONDS = 3.0; + +export const Z_CAM = 2.2; + +export const Z_MAX = 10.0; + +export const BG = '#080810'; + +// String color palettes. Indices 0–5 cover guitar / bass; 6–7 +// are added for extended-range GP imports (7-string, 8-string). +// Lookups still use `|| '#888'` as a safety fallback for any +// out-of-range index. +// +// These are `let`, not `const`: setStringColors() (used by the core +// "Highway String Colors" theming UI) overrides per-index entries at +// runtime, deriving the dim/bright variants from the chosen base color. +// DEFAULT_* keep the originals so a reset restores them byte-for-byte. +export const DEFAULT_STRING_COLORS = [ + '#cc0000', '#cca800', '#0066cc', + '#cc6600', '#00cc66', '#9900cc', + '#cc00aa', '#00cccc', // 7th = magenta, 8th = teal +]; + +export const DEFAULT_STRING_DIM = [ + '#520000', '#524200', '#002952', + '#522900', '#005229', '#3d0052', + '#520042', '#005252', +]; + +export const DEFAULT_STRING_BRIGHT = [ + '#ff3c3c', '#ffe040', '#3c9cff', + '#ff9c3c', '#3cff9c', '#cc3cff', + '#ff3ce0', '#3ce0e0', +]; + +export const MAX_RENDERER_DRAW_FAILURES = 3; + +// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ── +// +// Charts often repeat the same chord shape several times in a +// row (e.g. a G strummed 4 times). We call a contiguous run of same-id +// chords with gaps < CHAIN_GAP_THRESHOLD a "chain". Chains drive two +// visual choices: +// • The first chord in a chain renders in full; subsequent chords in +// a chain of CHAIN_RENDER_FULL_MAX or longer render as a "repeat +// box" — a translucent boxed frame so the eye can see the rhythm +// pattern without re-scanning identical fret numbers. +// • Each chord anchors a CHORD_FRAME_FRETS-wide frame; muted and +// open-only chords inherit the frame from their predecessor so +// they don't snap to fret 0. +// +// We compute chain stats and frame anchors once per `src` array via +// _ensureChordRenderCache (lazy, invalidates when the array reference +// changes — which happens on chord ingest, mastery rebuild, or song +// reset). The render path is then pure read. +export const CHAIN_GAP_THRESHOLD = 0.5; + +export const CHAIN_RENDER_FULL_MAX = 4; + +export const CHORD_FRAME_FRETS = 4; + +// Fretline preview: the static fret line at the bottom shows the chord +// closest to the strum line (currentTime + FRETLINE_TARGET_OFFSET) within +// the [target - FRETLINE_WINDOW_BEFORE, target + FRETLINE_WINDOW_AFTER] +// window, as a teaching aid. +export const FRETLINE_TARGET_OFFSET = -0.25; + +export const FRETLINE_WINDOW_BEFORE = 0.1; + +export const FRETLINE_WINDOW_AFTER = 0.3; + +// Repeat / mute box colors. +export const REPEAT_BOX_FILL = 'rgba(48, 80, 128, 0.06)'; + +export const REPEAT_BOX_BAR = '#50a0dc'; + +export const MUTE_BOX_STROKE = '#6060809b'; + +export const MUTE_BOX_BAR = '#606080d1'; diff --git a/tests/js/highway_adaptive_scale.test.js b/tests/js/highway_adaptive_scale.test.js index ea993fe..2f32e81 100644 --- a/tests/js/highway_adaptive_scale.test.js +++ b/tests/js/highway_adaptive_scale.test.js @@ -27,16 +27,33 @@ function extractBlock(src, signature) { return src.slice(start, i); } + +// 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 adaptive-scale state with a floor', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier'); - assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)'); - assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget'); - assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget'); + assert.match(src, /(?:export\s+)?const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)'); + assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget'); + assert.match(src, /(?:export\s+)?const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget'); }); test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function _effectiveRenderScale()'); // Derives from the (sanitized) user ceiling and auto factor. assert.match(fn, /_renderScale/, 'effective scale must derive from the user _renderScale'); @@ -47,7 +64,7 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () = }); test('min render scale floor is user-configurable + exposed on the api (#654)', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); // Hard floor constant kept; configurable floor read from localStorage. assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin'); assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/, @@ -65,7 +82,7 @@ test('min render scale floor is user-configurable + exposed on the api (#654)', }); test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resize', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function _adaptRenderScale('); assert.match(fn, /_DRAW_BUDGET_HI_MS/, 'must scale down past the high budget'); assert.match(fn, /_DRAW_BUDGET_LO_MS/, 'must scale up below the low budget'); @@ -74,27 +91,27 @@ test('_adaptRenderScale uses the draw budget + cooldown and re-applies via resiz }); test('draw() only adapts during active playback and feeds the HUD', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function draw()'); assert.match(fn, /if\s*\(\s*!_paused\s*\)\s*_adaptRenderScale/, 'must skip adaptation while paused'); assert.match(fn, /_updatePerfHud\(\)/, 'must update the perf HUD each drawn frame'); }); test('bundle + canvas sizing use the effective scale, not the raw user value', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale'); assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale'); }); test('api exposes effective scale + perf stats', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /getEffectiveRenderScale\(\)\s*\{\s*return\s+_effectiveRenderScale\(\)/, 'api.getEffectiveRenderScale missing'); assert.match(src, /getPerfStats\(\)\s*\{/, 'api.getPerfStats missing'); }); // Robustness fixes from the #655 Copilot review. test('render scale is sanitized on load and effective scale guards non-finite', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /parseFloat\(localStorage\.getItem\('renderScale'\)[\s\S]{0,160}?Number\.isFinite/, 'render scale load must validate via Number.isFinite + clamp'); const eff = extractBlock(src, 'function _effectiveRenderScale()'); @@ -102,7 +119,7 @@ test('render scale is sanitized on load and effective scale guards non-finite', }); test('stop() tears down the perf HUD and resets per-session accumulators', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /stop\(\)\s*\{[\s\S]{0,400}?_perfHud\.remove\(\)/, 'stop() must remove the perf HUD so it cannot strand in the DOM'); assert.match(src, /stop\(\)\s*\{[\s\S]{0,1200}?_autoScale\s*=\s*1/, @@ -112,7 +129,7 @@ test('stop() tears down the perf HUD and resets per-session accumulators', () => }); test('perf HUD throttles its localStorage flag read off the hot path', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function _updatePerfHud()'); assert.match(fn, /_hudFlagAt/, 'HUD must cache the flag and re-read on an interval, not every frame'); }); diff --git a/tests/js/highway_monotonic_clock.test.js b/tests/js/highway_monotonic_clock.test.js index 19c27ba..7896db8 100644 --- a/tests/js/highway_monotonic_clock.test.js +++ b/tests/js/highway_monotonic_clock.test.js @@ -60,7 +60,7 @@ function buildClockSandbox(perfNowImpl) { performance: { now: perfNowImpl }, }; vm.createContext(sandbox); - const src = fs.readFileSync(HIGHWAY_JS, 'utf8'); + const src = highwaySources(); const setTimeBody = extractBlock(src, 'setTime(t) {'); const getTimeBody = extractBlock(src, 'getTime() {'); // Strip trailing comma if present (object-literal method declarations). @@ -72,8 +72,25 @@ function buildClockSandbox(perfNowImpl) { 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 = fs.readFileSync(HIGHWAY_JS, 'utf8'); + 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 @@ -82,11 +99,11 @@ test('highway declares chart anchor + stall-detect + rate state', () => { 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, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap'); + 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 = fs.readFileSync(HIGHWAY_JS, 'utf8'); + const src = highwaySources(); const m = src.match(/getTime\(\)\s*\{[\s\S]+?\n\s*\},/); assert.ok(m, 'getTime() body not found'); const slice = m[0]; @@ -98,7 +115,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', ( }); test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually changes', () => { - const src = fs.readFileSync(HIGHWAY_JS, 'utf8'); + 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 @@ -115,7 +132,7 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch }); test('getTime falls back to chartTime when audio has stalled (paused)', () => { - const src = fs.readFileSync(HIGHWAY_JS, 'utf8'); + 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). @@ -139,7 +156,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => { }); test('api.stop() clears the chart anchor state so re-init starts fresh', () => { - const src = fs.readFileSync(HIGHWAY_JS, 'utf8'); + 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. diff --git a/tests/js/highway_pause_throttle.test.js b/tests/js/highway_pause_throttle.test.js index 99db51e..99455d9 100644 --- a/tests/js/highway_pause_throttle.test.js +++ b/tests/js/highway_pause_throttle.test.js @@ -29,14 +29,31 @@ function extractBlock(src, signature) { return src.slice(start, i); } + +// 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 the paused-render throttle state', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); - assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap'); + const src = highwaySources(); + assert.match(src, /(?:export\s+)?const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap'); assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator'); }); test('draw() throttles full renders while the audio clock is stalled', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function draw()'); // Reuse getTime()'s pause signal rather than inventing a parallel one. assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)'); @@ -46,7 +63,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => { }); test('throttle runs after the ready gate, before bundle/draw', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function draw()'); // Regex landmarks (not exact-string indexOf) so harmless spacing / // semicolon changes don't break the ordering guard — matches the diff --git a/tests/js/highway_string_colors.test.js b/tests/js/highway_string_colors.test.js index 080daea..f0984da 100644 --- a/tests/js/highway_string_colors.test.js +++ b/tests/js/highway_string_colors.test.js @@ -37,18 +37,35 @@ function extractBlock(src, signature) { // ── 2D highway (static/highway.js) ──────────────────────────────────────── + +// 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('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); - assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset'); - assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset'); - assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset'); + const src = highwaySources(); + assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset'); + assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset'); + assert.match(src, /(?:export\s+)?const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset'); assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults'); assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults'); assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults'); }); test('2D public API exposes getStringColors / setStringColors', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy'); const fn = extractBlock(src, 'setStringColors(arr)'); // Each provided index sets base + derived dim/bright; missing → default. @@ -110,7 +127,7 @@ test('app.js color manager name-maps to both highways, with identity no-op + bui // ── Executable: dim/bright derivation math ──────────────────────────────── function loadColorMath() { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const snippet = [ extractBlock(src, 'function _clampByte(n)'), extractBlock(src, 'function _parseHex(hex)'),