From 4bf6bd96050774a5bbc2fcb9eceda700ce62a9c3 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 8 Jul 2026 20:10:11 -0400 Subject: [PATCH] Change default arrangement setting with the in-song menu Signed-off-by: Joe --- static/app.js | 8 ++ tests/browser/default-arrangement-pin.spec.ts | 92 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/static/app.js b/static/app.js index dec4187d..4bbe43d6 100644 --- a/static/app.js +++ b/static/app.js @@ -6543,6 +6543,14 @@ async function changeArrangement(index) { // the timer, the song:play listener, and the overlay node. hideSongCreditsOverlay(); window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index }); + // A manual arrangement switch (the only caller of changeArrangement) + // is a strong signal of which chart the user wants — pin it the same + // way the ☆ button does so the next song opens on the same part + // instead of falling back to the most-notes heuristic (feedBack + // resetbug). Fire-and-forget: don't block the arrangement switch on + // the settings POST, and swallow network errors since this is a + // best-effort persist, not the primary action. + pinCurrentArrangementDefault().catch(() => {}); const wasPlaying = isPlaying; const time = _audioTime(); if (isPlaying) { diff --git a/tests/browser/default-arrangement-pin.spec.ts b/tests/browser/default-arrangement-pin.spec.ts index 0dd8b58a..3390aeaa 100644 --- a/tests/browser/default-arrangement-pin.spec.ts +++ b/tests/browser/default-arrangement-pin.spec.ts @@ -163,6 +163,98 @@ test('player arrangement pin preserves non-built-in arrangement names', async ({ expect(settingsPosts[1]).toMatchObject({ default_arrangement: 'Combo' }); }); +test('manually switching arrangement mid-song auto-persists it as the default (feedBack resetbug)', async ({ page }) => { + // Regression test: switching the #arr-select dropdown while a song is + // playing used to be scoped to that song only — the next song fell back + // to the "most notes" heuristic instead of remembering the user's pick. + // changeArrangement() now calls pinCurrentArrangementDefault() itself, so + // a plain dropdown switch persists default_arrangement exactly like an + // explicit ☆ click does. + const settingsPosts: SettingsPostPayload[] = []; + await page.route('**/api/settings', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ json: settingsPayload }); + return; + } + settingsPosts.push(route.request().postDataJSON()); + await route.fulfill({ json: { message: 'Settings saved' } }); + }); + + await page.goto('/'); + await page.waitForSelector('#arr-select', { state: 'attached' }); + + // Mock WebSocket so playSong()/changeArrangement()'s WS reconnect never + // hits the network — same pattern as tests/browser/resume-session.spec.ts. + // Must run AFTER goto(): navigation replaces the page's window, so + // installing the mock beforehand would be wiped out. + await page.evaluate(() => { + const arrangements = [ + { index: 0, name: 'Lead', notes: 420 }, + { index: 1, name: 'Rhythm', notes: 553 }, + { index: 2, name: 'Bass', notes: 386 }, + ]; + class MockWebSocket { + static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; + readyState = MockWebSocket.CONNECTING; + onopen: ((ev: Event) => void) | null = null; + onmessage: ((ev: { data: string }) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + url: string; + constructor(url: string) { + this.url = url; + // Honor the explicit ?arrangement= param on reconnect, mirroring + // the real server's "explicit request wins" resolution — otherwise + // a changeArrangement()-triggered reconnect would silently snap + // back to index 0 and make this mock lie about which arrangement + // is actually active. + const requested = Number(new URL(url, 'http://x').searchParams.get('arrangement')); + const active = arrangements.find(a => a.index === requested) || arrangements[0]; + const songInfo = { + type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', + arrangement: active.name, arrangement_index: active.index, duration: 90, + tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements, + }; + setTimeout(() => { + this.readyState = MockWebSocket.OPEN; + if (this.onopen) this.onopen(new Event('open')); + for (const m of [songInfo, { type: 'ready' }]) { + if (this.onmessage) this.onmessage({ data: JSON.stringify(m) }); + } + }, 0); + } + send() {} + close() { + this.readyState = MockWebSocket.CLOSED; + if (this.onclose) this.onclose(new CloseEvent('close')); + } + } + // @ts-ignore + window.WebSocket = MockWebSocket; + }); + + await page.evaluate(async () => { + // @ts-ignore - browser app helper + await window.playSong('mock-song.sloppak'); + }); + await page.waitForSelector('#player.active', { timeout: 5000 }); + await expect(page.locator('#arr-select option')).toHaveCount(3); + + // A plain dropdown switch — no pin click. The v3 UI tucks #arr-select + // inside a rail popover that's hidden until the user opens it, so drive + // the native select+change sequence directly rather than via + // page.selectOption (which requires visibility). + await page.evaluate((idx) => { + const sel = document.getElementById('arr-select') as HTMLSelectElement; + sel.value = idx; + sel.dispatchEvent(new Event('change', { bubbles: true })); + }, '2'); + + await expect.poll(() => settingsPosts.length).toBe(1); + expect(settingsPosts[0]).toEqual({ default_arrangement: 'Bass' }); + await expect(page.locator('#arr-default-pin')).toHaveAttribute('aria-pressed', 'true'); +}); + test('failed settings save does not mark arrangement default as persisted', async ({ page }) => { const settingsPosts: SettingsPostPayload[] = []; await page.route('**/api/settings', async route => {