Skip to content
Open
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
8 changes: 8 additions & 0 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
92 changes: 92 additions & 0 deletions tests/browser/default-arrangement-pin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down