diff --git a/CHANGELOG.md b/CHANGELOG.md index 6294fd6c..01f1da51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -619,6 +619,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sloppak assembly dropped all tone data — affected sloppaks showed no signal chain in the Tones plugin and no tone-change markers on the highway. The assembler (`lib/sloppak_convert.py`) now lifts each arrangement's tones from the source chart via the new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to render the gear chain. Sloppaks built before this release carry no tone data and must be rebuilt from their source chart to gain it. - Tab View (feedBack-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the *top* (clearing the transparent HUD) and extended all the way to the bottom of `#player`, where the opaque `#player-controls` (z-index 10) drew over the last row. The overlay now measures `#player-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on the controls bar re-runs the inset when it wraps to a second row on narrow viewports. - Tab View (feedBack-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (`.at-cursor-bar`) instead of the next-beat cursor (`.at-cursor-beat`). +- Home "Jump back in!" and library "Keep practicing" shelves loaded the wrong arrangement when the user had an instrument preference set (e.g. bass). The stats recorder defaulted to arrangement 0 because it never learned what arrangement the server actually loaded via instrument routing. Downstream shelves then passed the stale 0 as an explicit index, bypassing server-side routing. Now the stats recorder listens for `song:loaded` and corrects the arrangement from the server-echoed `arrangement_index` field. ### Migration notes - **Constitution amended to 1.1.0 (Principle II — Vanilla Frontend).** Prebuilt Tailwind (`static/tailwind.min.css`) is now codified as non-negotiable: no Play CDN / runtime CSS JIT anywhere, core or plugin. Plugin authors: a plugin that uses Tailwind classes not guaranteed in core — especially arbitrary values like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.preflight = false`. Plugins that use only core-guaranteed utilities, or that ship no Tailwind at all, need no change. Contributors: after adding any Tailwind class to core or a bundled plugin, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI job fails. diff --git a/static/v3/stats-recorder.js b/static/v3/stats-recorder.js index cb252355..7ab88067 100644 --- a/static/v3/stats-recorder.js +++ b/static/v3/stats-recorder.js @@ -188,6 +188,19 @@ // Arrangement switch restarts scoring — treat as a fresh session. reset(d.filename || (cur && cur.filename), d.arrangement == null ? 0 : Number(d.arrangement)); }); + // Learn the actual arrangement the server loaded. The server may have + // applied instrument routing (e.g. bass vs guitar) that differs from + // whatever was passed at song:loading time, and the stats recorder must + // record against the real arrangement so downstream consumers ("Jump back + // in!", "Keep practicing", "Continue Playing") read back the correct index. + // highway.js fires song:loaded when it processes the song_info WS message, + // which carries arrangement_index from the server. + sm.on('song:loaded', (e) => { + const d = (e && e.detail) || {}; + if (cur && Number.isFinite(d.arrangementIndex)) { + cur.arrangement = Number(d.arrangementIndex); + } + }); // ── Per-note tally (from the note_detect plugin) ──────────────────────-- sm.on('note:hit', () => { diff --git a/tests/js/stats_recorder_arrangement.test.js b/tests/js/stats_recorder_arrangement.test.js new file mode 100644 index 00000000..27eb6793 --- /dev/null +++ b/tests/js/stats_recorder_arrangement.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +function makeFeedBack() { + const handlers = new Map(); + return { + on(event, fn) { + const list = handlers.get(event) || []; + list.push(fn); + handlers.set(event, list); + }, + emit(event, detail) { + (handlers.get(event) || []).forEach((fn) => fn({ detail })); + }, + capabilities: { snapshotDiagnostics: () => ({}) }, + }; +} + +function setup() { + const posts = []; + const origFetch = global.fetch; + global.fetch = async (url, opts) => { + if (url === '/api/stats') { + posts.push(JSON.parse(opts.body)); + } + return { ok: true, json: async () => ({}) }; + }; + const fb = makeFeedBack(); + global.window = { feedBack: fb }; + require('../../static/v3/stats-recorder.js'); + return { posts, fb, cleanup: () => { global.fetch = origFetch; delete global.window; } }; +} + +test('stats-recorder corrects arrangement from song:loaded', () => { + const { posts, fb, cleanup } = setup(); + + // Scenario 1: instrument routing picks a different arrangement (e.g. bass) + fb.emit('song:loading', { filename: 'song.archive', arrangement: null }); + fb.emit('song:loaded', { + filename: 'song.archive', arrangement: 'Bass', + arrangementIndex: 2, + arrangements: [{ name: 'Lead' }, { name: 'Rhythm' }, { name: 'Bass' }], + }); + fb.emit('song:pause', { time: 30 }); + assert.equal(posts.length, 1, 'scenario 1: should have posted stats'); + assert.equal(posts[0].arrangement, 2, 'scenario 1: arrangement should be the server-chosen index (2)'); + + // Scenario 2: arrangementIndex 0 is kept (guitar lead is correct) + fb.emit('song:loading', { filename: 'song2.archive', arrangement: null }); + fb.emit('song:loaded', { + filename: 'song2.archive', arrangement: 'Lead', + arrangementIndex: 0, + }); + fb.emit('song:pause', { time: 10 }); + assert.equal(posts.length, 2, 'scenario 2: should have posted stats'); + assert.equal(posts[1].arrangement, 0, 'scenario 2: should keep arrangement 0 when server selected it'); + + // Scenario 3: explicit arrangement is preserved + fb.emit('song:loading', { filename: 'song3.archive', arrangement: 0 }); + fb.emit('song:loaded', { + filename: 'song3.archive', arrangement: 'Lead', + arrangementIndex: 0, + }); + fb.emit('song:pause', { time: 10 }); + assert.equal(posts.length, 3, 'scenario 3: should have posted stats'); + assert.equal(posts[2].arrangement, 0, 'scenario 3: explicit arrangement 0 preserved'); + + cleanup(); +});