diff --git a/plugin.json b/plugin.json index 8d0dd09..04e89e9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "section_map", "name": "Section Map", - "version": "1.2.5", + "version": "1.2.7", "private": false, "script": "screen.js", "category": "practice", diff --git a/screen.js b/screen.js index 8627dd1..cfc1062 100644 --- a/screen.js +++ b/screen.js @@ -249,7 +249,39 @@ function _smUpdate() { const info = highway.getSongInfo(); const t = highway.getTime(); - if (!sections || sections.length === 0 || !info.duration) return; + if (!sections || sections.length === 0 || !info.duration) { + // The previous song's bar must not linger once the CURRENT song has + // no section data. This early-return used to bail before touching + // any state, so switching to a song/arrangement with no sections + // (a GP import, or a core re-stream that bypasses the playSong + // wrapper's own reset -- e.g. an in-player arrangement switch) left + // the old blocks painted and the marker/active-highlight frozen at + // wherever the last real song left them. + // + // Gate on the bar's actual painted content, NOT _smSections: the + // song:ready handler above already sets _smSections = [] itself + // before calling _smUpdate() directly, so by the time this runs + // _smSections.length is already 0 on exactly the transition this + // guard exists to catch -- gating on it made the clear a no-op on + // that path, since a bar with stale rendered blocks would report + // "nothing to clear". _smBar.innerHTML is only ever set by + // _smRender() and only ever emptied by _smRemove() or this same + // block, so it accurately tracks whether the bar still shows + // painted content regardless of which path zeroed _smSections + // first. Clearing is idempotent, so this still bails immediately + // on every later tick once already cleared, rather than rebuilding + // an already-empty bar every 200ms while no song/sections are loaded. + if (_smBar.innerHTML !== '') { + _smSections = []; + _smDuration = 0; + _smSectionDifficulty = {}; + _smBar.innerHTML = ''; + _smMarkerEl = null; + _smBlockEls = []; + _smActiveIdx = -1; + } + return; + } _smDuration = info.duration; diff --git a/tests/screen.test.js b/tests/screen.test.js index 0ed7640..463f31f 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -358,6 +358,86 @@ test('_smUpdate only touches the blocks whose active state changed, leaving cach } }); +test('_smUpdate clears a stale bar/state once the current song has no section data', () => { + const mod = freshPlugin(); + const originalHighway = global.highway; + const bar = new FakeBar(); + const sections = [{ name: 'Intro', time: 0 }, { name: 'Chorus', time: 30 }]; + try { + global.highway = { + getSections: () => sections, + getSongInfo: () => ({ duration: 120 }), + getTime: () => 5, + }; + mod._setState({ bar, sections: [], duration: 0 }); + mod._smUpdate(); + assert.ok(bar.innerHTML.includes('Intro'), 'sanity: first song rendered'); + + // Switch to a song/arrangement with no section data WITHOUT going + // through _smRemove() (mirrors a core re-stream that bypasses the + // playSong wrapper, e.g. an in-player arrangement switch, or any + // path that skips the wrapper's own state reset). + global.highway.getSections = () => []; + global.highway.getSongInfo = () => ({ duration: 200 }); + mod._smUpdate(); + + const state = mod._getState(); + assert.deepEqual(state.sections, []); + assert.equal(state.duration, 0); + assert.equal(state.activeIdx, -1); + assert.equal(bar.innerHTML, '', 'stale blocks from the previous song must not linger'); + + // A further tick with still-no-sections is a no-op, not a rebuild. + mod._smUpdate(); + assert.equal(bar.innerHTML, ''); + } finally { + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('_smUpdate clears the bar even when song:ready already zeroed _smSections first', () => { + // The song:ready handler (installed in _smStartRealtimeHooks) sets + // _smSections = [] itself and THEN calls _smUpdate() directly -- so on + // that path _smSections is already empty by the time _smUpdate's guard + // runs. Gating the clear on `_smSections.length > 0` (the original fix) + // made it a no-op on exactly this path: the bar's stale rendered blocks + // and cached marker/block-el refs never got cleared. This test + // reproduces that ordering directly (bypassing the real event + // subscription) and asserts the bar clears anyway. + const mod = freshPlugin(); + const originalHighway = global.highway; + const bar = new FakeBar(); + bar.innerHTML = '