Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
34 changes: 33 additions & 1 deletion screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
80 changes: 80 additions & 0 deletions tests/screen.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="sm-block">Intro</div>';
const staleBlockEl = { style: {} };
const staleMarkerEl = { style: {} };
try {
global.highway = {
getSections: () => [],
getSongInfo: () => ({ duration: 200 }),
getTime: () => 0,
};
// Mirrors song:ready's own reset (sections already []) landing
// just before _smUpdate() is called, while the bar DOM/cached
// refs from the PREVIOUS song are still live.
mod._setState({
bar, sections: [], duration: 0,
blockEls: [staleBlockEl], markerEl: staleMarkerEl, activeIdx: 0,
});

mod._smUpdate();

const state = mod._getState();
assert.equal(bar.innerHTML, '', 'stale blocks must clear even though _smSections was already []');
assert.equal(state.markerEl, null);
assert.deepEqual(state.blockEls, []);
assert.equal(state.activeIdx, -1);
} finally {
if (typeof originalHighway === 'undefined') delete global.highway;
else global.highway = originalHighway;
}
});

// _smUpdateDifficultyFills updates an existing glass in place (no DOM
// rebuild) on a difficulty refresh, only rebuilding the one glass element
// when its size bucket changes, per its own doc comment.
Expand Down
Loading