mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit Escape didn't always leave a song: clicking a transport control (play/FF/RW/ restart) left that <button> focused, and _shortcutDispatchBlocked() bails the shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the player-scope Escape=Back shortcut never fired until the user clicked empty canvas to blur the control. Space already had a player-screen carve-out (#593); Escape did not. That asymmetry was the bug. Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked to Escape, on the player AND settings screens (both register Escape=Back; settings had the identical latent bug). The earlier guards still win: text inputs are exempted first, the Section Practice popover already claims Escape, and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it. Plugins' player-scope Escape shortcuts are fixed identically. Phase 2 — resume: leaving the player snapshots {song, arrangement, position, speed} to localStorage; a non-blocking "Resume practice" pill offers it back on the next non-player screen / next launch. playSong() gains a {resume} option that restores speed + seeks to the saved position on song:ready instead of the normal autostart. Conservative (ignores <3s / near-end), cleared on natural song-end and once consumed, expires after 24h. Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A true-modal confirm with monotonic Escape (the second Escape leaves) and Space/Enter = Leave. The player Escape shortcut and the v3 close button route through window.requestExitSong(); auto-exit on song-end and a results screen's own Close stay unguarded. Design rationale: a multi-seat design charrette (engagement, learning-design, operability, codebase-reality) — leaving a song should be reliable and recoverable, not gated; the confirm is opt-in only. Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * test(browser): suppress first-run onboarding in keyboard/resume/exit specs The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts pointer/keyboard events; on a fresh profile it covers the player and breaks any test that presses Escape or clicks. Stub GET /api/profile to an onboarded profile in each beforeEach so the app behaves like a returning user (the state these tests assume). Also tighten the Section Practice Escape test to assert the guarantee the fix actually provides — Escape does not exit the song while the popover is open (the line-447 guard wins over the carve-out) — rather than asserting the popover's own close handler fires, which isn't wired for a synthetic bar. Verified locally against a worktree server (Chromium): all 16 new specs pass (5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay Refinements from tester feedback on the exit-confirm (default stays OFF): - Escape on the open prompt now = Stay (dismiss + return to the song), matching every other modal and the generic _confirmDialog (Esc=cancel). A second Escape therefore returns to the song instead of leaving it. Leaving stays the explicit, default-focused "Leave" button, so Space/Enter/click = "just get me out" (the OP's "Space always hits leave"). - Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5 + _juceMode) so it isn't running/being scored behind the modal; Stay resumes exactly what we paused. Guards: cancel any count-in on open; resume only if we paused (wasPlaying), only if still the same live song on the player (_audioSeekGen unchanged), and never auto-resume a song the user had paused. - Trap Tab inside the dialog; backdrop click was already Stay. Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test becomes "second Escape stays", plus a backdrop-click-stays test. The audio pause/resume itself is verified manually on web + desktop (the mock song has no backing track); these specs lock the navigation + keyboard semantics. NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE transport (known play/pause-desync path) — smoke-test on the desktop build before merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(player): accurate exit-confirm copy + keep resume snapshot on failed load Two review follow-ups on the Escape/resume/confirm work: - Settings copy said "a second Escape (or Space/Enter) still leaves", but Escape dismisses the confirm (Stay) like every other modal — only Space/Enter/Leave exit. Corrected the Gameplay-tab description so it matches the implementation (and the committed exit-confirm specs). - resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so a transient load/connect failure permanently lost the Resume pill with no retry. Clear only after the load resolves; on failure keep the snapshot (and drop the pending in-memory resume) so the pill re-offers it on the next non-player screen. All 16 Escape/resume/exit-confirm Playwright specs still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
290783b80b
commit
fef870047b
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Opt-in "Ask before leaving a song" confirm. Default OFF → Escape/✕ leave
|
||||
// instantly. When ON, a true-modal confirm appears and PAUSES the song; Escape
|
||||
// (like every other modal) DISMISSES it → Stay, so a second Escape returns to
|
||||
// the song rather than leaving, and Space/Enter activate the default-focused
|
||||
// "Leave". (The mock song has no backing audio, so the pause-on-open /
|
||||
// resume-on-Stay is verified manually on web + desktop; these specs lock the
|
||||
// navigation + keyboard semantics.)
|
||||
|
||||
const CONFIRM_KEY = 'confirmExitSong';
|
||||
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) 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;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.playSong('mock-song.sloppak'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Exit-confirm toggle', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so Escape reaches the player, not the overlay.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), CONFIRM_KEY);
|
||||
});
|
||||
|
||||
test('default OFF: Escape exits the song immediately, no confirm', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Escape opens the confirm and the song stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
// "Leave" is focused so Space/Enter leaves immediately.
|
||||
await expect(page.locator('#fb-exit-confirm button', { hasText: 'Leave' })).toBeFocused();
|
||||
});
|
||||
|
||||
test('ON: a second Escape dismisses the prompt and stays in the song', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// Escape = dismiss (Stay), matching every other modal — NOT leave.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: clicking the backdrop dismisses the prompt and stays', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
// mousedown on the overlay backdrop (top-left, away from the centered card)
|
||||
// is Stay — never an accidental leave.
|
||||
await page.locator('#fb-exit-confirm').click({ position: { x: 5, y: 5 } });
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('ON: "Stay" keeps you in the song; "Leave" exits', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Stay' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('#fb-exit-confirm button', { hasText: 'Leave' }).click();
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('ON: Enter on the default-focused "Leave" leaves', async ({ page }) => {
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.setConfirmExitSong(true); });
|
||||
await openPlayerWithMockSong(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#fb-exit-confirm')).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#fb-exit-confirm')).toHaveCount(0);
|
||||
await expect(page.locator('#player.active')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
|
||||
|
||||
test.describe('Keyboard Shortcuts', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
|
||||
// intercepts pointer/keyboard events — so the app behaves like a returning
|
||||
// user, which is the state these tests assume.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
});
|
||||
@@ -778,6 +786,183 @@ test('should support condition callbacks', async ({ page }) => {
|
||||
expect(result.clicked).toBe(1);
|
||||
});
|
||||
|
||||
// ── Escape = universal "Back" carve-out ──────────────────────────────────
|
||||
// Escape must escape a focused non-modal control exactly like Space does,
|
||||
// so a focused transport/rail button can't swallow it ("Escape in song not
|
||||
// consistent"). These mirror the #593 Space tests above. Each registers an
|
||||
// Escape spy in the relevant scope (which replaces the built-in handler for
|
||||
// that composite key) so the assertion doesn't depend on showScreen teardown.
|
||||
|
||||
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The bug: a focused <button> is an "interactive control", so Escape was
|
||||
// blocked before reaching the dispatcher and the song wouldn't exit until
|
||||
// the user clicked empty canvas to blur the control.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-rail-btn';
|
||||
btn.textContent = 'Restart';
|
||||
document.getElementById('player')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.locator('#__test-rail-btn').focus();
|
||||
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Back-to-library fired despite the control button holding focus.
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The text-input exemption (_isTextInput) is checked before the Escape
|
||||
// carve-out, so Escape in a field is the field's own concern (clear/blur),
|
||||
// never a song exit.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = '__test-player-input';
|
||||
document.getElementById('player')!.appendChild(input);
|
||||
});
|
||||
|
||||
await page.locator('#__test-player-input').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
|
||||
// over the player is a focus trap: Escape there must NOT eject past it to
|
||||
// exit the song — the modal owns Escape. The carve-out's modal-overlay
|
||||
// guard keeps the player-back shortcut from firing.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
const modal = document.createElement('div');
|
||||
modal.id = '__test-modal';
|
||||
modal.className = 'feedBack-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-modal-btn';
|
||||
btn.textContent = 'Close';
|
||||
modal.appendChild(btn);
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
|
||||
await page.locator('#__test-modal-btn').focus();
|
||||
await expect(page.locator('#__test-modal-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Playback is NOT exited behind the modal.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
|
||||
// The Section Practice popover claims Escape earlier in
|
||||
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
|
||||
// open popover suppresses the player-scope back-to-library Escape — the
|
||||
// popover's own handler owns closing it. This locks that ordering guard.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Back to library (test spy)',
|
||||
scope: 'player',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escBackCount++; },
|
||||
});
|
||||
let bar = document.getElementById('section-practice-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'section-practice-bar';
|
||||
document.getElementById('player')!.appendChild(bar);
|
||||
}
|
||||
bar.classList.add('section-practice-bar--open');
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escBackCount);
|
||||
// Player-back did NOT fire while the popover was open.
|
||||
expect(backCount).toBe(0);
|
||||
});
|
||||
|
||||
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
|
||||
// The same focus bug existed on the settings screen (the carve-out was
|
||||
// player-only). The fix covers settings too: Escape returns to the
|
||||
// previous screen even when a settings control holds focus.
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window.__escSettingsBackCount = 0;
|
||||
// @ts-ignore
|
||||
window.registerShortcut({
|
||||
key: 'Escape',
|
||||
description: 'Go back from settings (test spy)',
|
||||
scope: 'settings',
|
||||
// @ts-ignore
|
||||
handler: () => { window.__escSettingsBackCount++; },
|
||||
});
|
||||
// @ts-ignore
|
||||
window.showScreen('settings');
|
||||
const btn = document.createElement('button');
|
||||
btn.id = '__test-settings-btn';
|
||||
btn.textContent = 'Some setting';
|
||||
document.getElementById('settings')!.appendChild(btn);
|
||||
});
|
||||
|
||||
await page.waitForSelector('#settings.active', { timeout: 5000 });
|
||||
await page.locator('#__test-settings-btn').focus();
|
||||
await expect(page.locator('#__test-settings-btn')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
|
||||
expect(backCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should warn on invalid scope', async ({ page }) => {
|
||||
const messages: string[] = [];
|
||||
page.on('console', msg => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Resume-last-session: leaving the player snapshots {song, arrangement,
|
||||
// position, speed} so an exit is recoverable via a non-blocking "Resume" pill.
|
||||
// These exercise the deterministic plumbing (snapshot guards, staleness, the
|
||||
// pill, and resume consumption) without depending on real audio timing.
|
||||
|
||||
const RESUME_KEY = 'feedBack.resumeSession';
|
||||
|
||||
// Make playSong()'s WebSocket a no-network mock that emits a song_info + ready.
|
||||
async function installMockSong(page) {
|
||||
await page.evaluate(() => {
|
||||
const messages = [
|
||||
{ type: 'song_info', title: 'Mock Song', artist: 'Mock Artist', arrangement: 'Lead', arrangement_index: 0, duration: 90, tuning: [0, 0, 0, 0, 0, 0], stringCount: 6, arrangements: [{ index: 0, name: 'Lead', notes: 1 }] },
|
||||
{ type: 'ready' },
|
||||
];
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen = null; onmessage = null; onerror = null; onclose = null; url;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event('open'));
|
||||
for (const m of messages) 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;
|
||||
});
|
||||
}
|
||||
|
||||
async function openPlayerWithMockSong(page) {
|
||||
await installMockSong(page);
|
||||
await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
await window.playSong('mock-song.sloppak');
|
||||
});
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#hud-title')).toHaveText('Mock Song', { timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('Resume last session', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Suppress the first-run onboarding overlay (a modal that intercepts
|
||||
// pointer/keyboard events) so the player isn't covered.
|
||||
await page.route('**/api/profile', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
|
||||
} else { await route.continue(); }
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate((k) => localStorage.removeItem(k), RESUME_KEY);
|
||||
});
|
||||
|
||||
test('snapshots song + arrangement + position once you are mid-song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const snap = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(30);
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
});
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap.f).toBe('mock-song.sloppak');
|
||||
expect(snap.a).toBe(0);
|
||||
expect(Math.round(snap.t)).toBe(30);
|
||||
expect(snap.title).toBe('Mock Song');
|
||||
});
|
||||
|
||||
test('does NOT snapshot a barely-started or basically-finished song', async ({ page }) => {
|
||||
await openPlayerWithMockSong(page);
|
||||
const result = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(1); // < 3s min → ignored
|
||||
// @ts-ignore
|
||||
const tooEarly = window._readResumeSession();
|
||||
// duration is 90; end-guard is 5s, so 88 > 85 → ignored
|
||||
// @ts-ignore
|
||||
window._snapshotResumeSession(88);
|
||||
// @ts-ignore
|
||||
const tooLate = window._readResumeSession();
|
||||
return { tooEarly, tooLate };
|
||||
});
|
||||
expect(result.tooEarly).toBeNull();
|
||||
expect(result.tooLate).toBeNull();
|
||||
});
|
||||
|
||||
test('a stale (>24h) snapshot is ignored', async ({ page }) => {
|
||||
const got = await page.evaluate((k) => {
|
||||
const old = { f: 'old.sloppak', a: 0, t: 42, sp: 1, title: 'Old', ts: Date.now() - 25 * 60 * 60 * 1000 };
|
||||
localStorage.setItem(k, JSON.stringify(old));
|
||||
// @ts-ignore
|
||||
return window._readResumeSession();
|
||||
}, RESUME_KEY);
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
test('the Resume pill appears off-player and hides on the player', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', artist: 'Mock Artist', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await expect(page.locator('#fb-resume-pill')).toContainText('Mock Song');
|
||||
|
||||
// Entering the player hides it (screen:changed → _hideResumePill()).
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.showScreen('player'); });
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('dismissing the pill removes it and does not re-show it this session', async ({ page }) => {
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
// @ts-ignore
|
||||
window.feedBack._maybeShowResumePill();
|
||||
}, RESUME_KEY);
|
||||
|
||||
await expect(page.locator('#fb-resume-pill')).toBeVisible();
|
||||
await page.locator('#fb-resume-pill button[aria-label="Dismiss"]').click();
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
|
||||
// A re-offer attempt within the same session is suppressed.
|
||||
await page.evaluate(() => { /* @ts-ignore */ window.feedBack._maybeShowResumePill(); });
|
||||
await expect(page.locator('#fb-resume-pill')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('resumeLastSession() re-enters the song and consumes the snapshot', async ({ page }) => {
|
||||
await installMockSong(page);
|
||||
await page.evaluate((k) => {
|
||||
const snap = { f: 'mock-song.sloppak', a: 0, t: 30, sp: 1, title: 'Mock Song', ts: Date.now() };
|
||||
localStorage.setItem(k, JSON.stringify(snap));
|
||||
}, RESUME_KEY);
|
||||
|
||||
await page.evaluate(async () => { /* @ts-ignore */ await window.resumeLastSession(); });
|
||||
|
||||
await page.waitForSelector('#player.active', { timeout: 5000 });
|
||||
// The snapshot is consumed (cleared) so it isn't offered again.
|
||||
const remaining = await page.evaluate((k) => localStorage.getItem(k), RESUME_KEY);
|
||||
expect(remaining).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user