diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c262781..d3c931e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -312,8 +312,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). -### Fixed -- **Count-in follows the song's meter and its pickup measure.** The count-in +### Fixed +- **Highways no longer render blank for metadata-only phrase lists (#832).** + When a chart includes `phrases` but none of them contains authored difficulty + `levels`, mastery filtering now falls through to the arrangement's flat notes, + chords, anchors, and handshapes instead of replacing them with empty arrays. +- **Count-in follows the song's meter and its pickup measure.** The count-in (loop wrap, section practice, and the "Countdown before song" setting) always clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter where the downbeat diff --git a/static/highway.js b/static/highway.js index a7b0507b..8730c3ae 100644 --- a/static/highway.js +++ b/static/highway.js @@ -581,9 +581,10 @@ function createHighway() { // signal to fall back to legacy MIDI-encoded drums. b.drumTab = hwState.drumTab; - // Master-difficulty (feedBack#48) + // Master-difficulty (feedBack#48). This reports authored ladder + // availability; timing-only phrases remain available separately. b.mastery = hwState._mastery; - b.hasPhraseData = !!(hwState._phrases && hwState._phrases.length > 0); + b.hasPhraseData = _hasAuthoredPhraseLevels(); // When phrase data authored ANY handshape, respect the filtered // list strictly (even when this difficulty leaves it empty) — // otherwise low-mastery levels would surface arp hints that @@ -1476,22 +1477,23 @@ function createHighway() { hwState._chordRenderCacheTemplates = null; } - // Rebuild the mastery-filtered note/chord arrays from _phrases + - // _mastery. Called on `ready` and on every setMastery(). When - // _phrases is null (slider-disabled source), we clear the filtered - // arrays — drawNotes/drawChords fall through to the flat arrays. - // - // Output arrays are pre-sorted by time because phrase iterations - // arrive in chronological order and within each level the notes/ - // chords are time-sorted already (PR 1's parser sorts them), so - // concatenation preserves the order. No explicit sort needed. + /** Return whether any phrase contains an authored difficulty level. */ + function _hasAuthoredPhraseLevels(phrases = hwState._phrases) { + return Array.isArray(phrases) + && phrases.some(p => Array.isArray(p?.levels) && p.levels.length > 0); + } + + /** + * Rebuild mastery-filtered chart arrays, falling back to the flat chart + * when no authored difficulty ladder exists. Called on `ready` and every + * setMastery(). Phrase and level order already preserves timeline order. + */ function _rebuildMasteryFilter() { - // Null OR empty → fall through to flat arrays. The server's - // chunked emission invariant means _phrases should never land - // at `[]` in practice (it'd require the `phrases` message to - // fire with zero data), but the defensive guard means a bug - // on the way in wouldn't blank the chart. - if (hwState._phrases === null || hwState._phrases.length === 0) { + // Missing phrases, an empty list, or metadata-only phrases with + // no authored levels all mean there is no difficulty ladder to + // filter. Fall through to the flat arrangement-root arrays. + const hasPhraseLevels = _hasAuthoredPhraseLevels(); + if (!hasPhraseLevels) { hwState._filteredNotes = null; hwState._filteredChords = null; hwState._filteredAnchors = null; @@ -1512,14 +1514,15 @@ function createHighway() { // back to the flat arrangement-root list (DLC pattern). let anyHandShapeInPhrases = false; for (const p of hwState._phrases) { - const n = p.levels.length; + const levels = Array.isArray(p?.levels) ? p.levels : []; + const n = levels.length; if (n === 0) continue; // Map slider fraction to a level index. `n` already equals // `max_difficulty + 1` for fully-authored phrases, and // equals the authored-level count otherwise — so indexing - // into p.levels.length is both correct and defensive. + // into the normalized levels length is both correct and defensive. const idx = Math.min(n - 1, Math.floor(hwState._mastery * n)); - const lv = p.levels[idx]; + const lv = levels[idx]; for (const x of lv.notes) outNotes.push(x); for (const x of lv.chords) outChords.push(x); // Anchors drive the fret zoom / pan. Keeping max-mastery @@ -1529,7 +1532,7 @@ function createHighway() { for (const x of lv.anchors) outAnchors.push(x); for (const x of (lv.handshapes || [])) outHandShapes.push(x); if (!anyHandShapeInPhrases) { - for (const level of p.levels) { + for (const level of levels) { if (level.handshapes && level.handshapes.length > 0) { anyHandShapeInPhrases = true; break; @@ -1843,12 +1846,9 @@ function createHighway() { _rebuildMasteryFilter(); }, getMastery() { return hwState._mastery; }, - // Align with _rebuildMasteryFilter's own "null OR empty → fall - // through" check. If we returned true for _phrases = [], the - // slider would be enabled (via song:ready's hasPhraseData) but - // dragging it would do nothing (filter stays null). Same - // sentinel, same check, single source of truth. - hasPhraseData() { return !!(hwState._phrases && hwState._phrases.length > 0); }, + // Difficulty-ladder availability for mastery controls. Timing-only + // phrase windows remain available through the separate phrase getter. + hasPhraseData() { return _hasAuthoredPhraseLevels(); }, // Lightweight phrase windows for Section Practice — timing only, no note payloads. getPracticePhrases() { if (!hwState._phrases || !hwState._phrases.length) return null; @@ -2671,8 +2671,9 @@ function createHighway() { // WS connection — mirrors getBeats()/getSections(). getLyrics() { return hwState.lyrics; }, // Phrase timing windows for plugins — `[{ index, start_time, end_time, max_difficulty }]`. - // Returns null when the current song has no phrase data (GP imports, single-difficulty - // charts). Gate phrase-aware logic with hasPhraseData() first. Read-only; do not mutate. + // Returns null only when no phrase timing data exists. This remains available for + // metadata-only phrases even when hasPhraseData() reports no authored difficulty ladder. + // Read-only; do not mutate. getPhrases() { if (!hwState._phrases || !hwState._phrases.length) return null; return hwState._phrases.map((p, index) => ({ diff --git a/tests/js/highway_filtered_notes.test.js b/tests/js/highway_filtered_notes.test.js index 468a9206..ecccea4f 100644 --- a/tests/js/highway_filtered_notes.test.js +++ b/tests/js/highway_filtered_notes.test.js @@ -1,7 +1,6 @@ -// Source-level tests for highway.getFilteredNotes() / getFilteredChords() and -// the hasPhraseData() companion getter. These mirror the pattern used in -// highway_note_state.test.js: the createHighway closure is too heavy for a Node -// sandbox, so tests inspect the source text to lock in correct wiring. +// Focused coverage for highway difficulty filtering. The createHighway closure +// is too large for the Node harness, so wiring stays source-checked while the +// extracted filter and bundle helpers are exercised with real chart state. const { test } = require('node:test'); const assert = require('node:assert/strict'); @@ -10,6 +9,85 @@ const path = require('node:path'); const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js'); +function extractBlock(src, marker) { + const start = src.indexOf(marker); + assert.ok(start >= 0, `${marker} present`); + const open = src.indexOf('{', start); + assert.ok(open >= 0, `${marker} has a body`); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(start, i + 1); + } + } + assert.fail(`${marker} body is balanced`); +} + +function createMasteryHarness() { + const src = fs.readFileSync(highwayJs, 'utf8'); + const snippets = [ + extractBlock(src, 'function _hasAuthoredPhraseLevels(phrases = hwState._phrases)'), + extractBlock(src, 'function _rebuildMasteryFilter()'), + extractBlock(src, 'function _makeBundle()'), + ].join('\n'); + const hwState = { + currentTime: 0, + songInfo: { tuning: [0, 0, 0, 0, 0, 0], capo: 0, centOffset: 0 }, + ready: false, + _chartAnchorPerfNow: NaN, + _chartLastAdvanceAt: 0, + notes: [{ t: 1, s: 0, f: 3 }], + chords: [{ t: 2, notes: [{ s: 1, f: 5 }] }], + anchors: [{ time: 0, fret: 1, width: 4 }], + handShapes: [{ start_time: 0, end_time: 3 }], + beats: [], sections: [], chordTemplates: [], stringCount: 6, + lyrics: [], lyricsSource: null, toneChanges: [], toneBase: null, drumTab: null, + _mastery: 1, + _phrases: null, + _filteredNotes: [{ sentinel: 'note' }], + _filteredChords: [{ sentinel: 'chord' }], + _filteredAnchors: [{ sentinel: 'anchor' }], + _filteredHandShapes: [{ sentinel: 'handshape' }], + _phrasesHaveHandShapes: true, + _xfNotes: null, _xfChords: null, _xfAnchors: null, + _xfNotesAll: null, _xfChordsAll: null, _xfChordTemplates: null, + _xfStringCount: null, _xfTuning: null, _xfCapo: null, + _xfHandShapes: null, _xfCentOffset: null, + _inverted: false, _lefty: false, showLyrics: true, + _showTeachingMarks: true, _showFingerHints: true, + }; + let restages = 0; + const runtime = new Function('hwState', 'onRestage', ` + const _bundleReused = {}; + const _CHART_MAX_INTERP_MS = 250; + const performance = { now: () => 0 }; + const _effectiveRenderScale = () => 1; + const project = () => {}; + const boundFretX = () => {}; + const bsearch = () => 0; + const bsearchTime = () => 0; + const boundNoteState = () => null; + const _getNoteStateProvider = () => null; + const _restageChartTransform = onRestage; + ${snippets} + return { + phrases(data) { + if (hwState._phrases === null) hwState._phrases = []; + for (const phrase of data) hwState._phrases.push(phrase); + }, + ready() { + hwState.ready = true; + _rebuildMasteryFilter(); + return _makeBundle(); + }, + hasPhraseData: _hasAuthoredPhraseLevels, + }; + `)(hwState, () => { restages += 1; }); + return { hwState, runtime, restages: () => restages }; +} + test('highway public API exposes getFilteredNotes', () => { const src = fs.readFileSync(highwayJs, 'utf8'); assert.match( @@ -50,7 +128,52 @@ test('highway public API exposes hasPhraseData', () => { const src = fs.readFileSync(highwayJs, 'utf8'); assert.match( src, - /hasPhraseData\s*\(\s*\)\s*\{[^}]*_phrases/, - 'hasPhraseData must reference _phrases', + /hasPhraseData\s*\(\s*\)\s*\{[^}]*_hasAuthoredPhraseLevels\(\)/, + 'hasPhraseData must use the authored-level predicate', ); }); + +test('metadata-only phrases preserve flat chart views through ready', () => { + const { hwState, runtime, restages } = createMasteryHarness(); + runtime.phrases([ + { start_time: 0, end_time: 1, levels: [] }, + { start_time: 1, end_time: 2 }, + { start_time: 2, end_time: 3, levels: null }, + ]); + + const bundle = runtime.ready(); + + assert.strictEqual(bundle.notes, hwState.notes); + assert.strictEqual(bundle.chords, hwState.chords); + assert.strictEqual(bundle.anchors, hwState.anchors); + assert.strictEqual(bundle.handShapes, hwState.handShapes); + assert.equal(bundle.hasPhraseData, false, 'mastery slider stays disabled'); + assert.equal(runtime.hasPhraseData(), false); + assert.equal(restages(), 1, 'ready refreshes the chart transform stage'); +}); + +test('mixed phrase payload safely skips missing levels and filters authored levels', () => { + const { runtime } = createMasteryHarness(); + const filteredNote = { t: 4, s: 2, f: 7 }; + const filteredChord = { t: 5, notes: [{ s: 0, f: 2 }] }; + const filteredAnchor = { time: 4, fret: 5, width: 4 }; + const filteredHandShape = { start_time: 4, end_time: 6 }; + runtime.phrases([ + { start_time: 0, end_time: 3 }, + { start_time: 3, end_time: 4, levels: null }, + { start_time: 4, end_time: 6, levels: [{ + notes: [filteredNote], + chords: [filteredChord], + anchors: [filteredAnchor], + handshapes: [filteredHandShape], + }] }, + ]); + + const bundle = runtime.ready(); + + assert.deepEqual(bundle.notes, [filteredNote]); + assert.deepEqual(bundle.chords, [filteredChord]); + assert.deepEqual(bundle.anchors, [filteredAnchor]); + assert.deepEqual(bundle.handShapes, [filteredHandShape]); + assert.equal(bundle.hasPhraseData, true); +});