mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
* 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>
1019 lines
35 KiB
TypeScript
1019 lines
35 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
async function openPlayerWithMockSong(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 message of messages) {
|
|
if (this.onmessage) this.onmessage({ data: JSON.stringify(message) });
|
|
}
|
|
}, 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
|
|
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('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 });
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
// Clean up any modals
|
|
await page.evaluate(() => {
|
|
const modal = document.getElementById('shortcuts-modal');
|
|
if (modal) modal.remove();
|
|
});
|
|
});
|
|
|
|
test('should have shortcut registry available', async ({ page }) => {
|
|
const hasRegistry = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
return typeof window._listShortcuts === 'function';
|
|
});
|
|
expect(hasRegistry).toBe(true);
|
|
});
|
|
|
|
test('should list all registered shortcuts', async ({ page }) => {
|
|
const shortcuts = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window._listShortcuts();
|
|
// @ts-ignore
|
|
const activePanel = window._panels.get(window.getActiveShortcutPanel());
|
|
if (activePanel) {
|
|
return Array.from(activePanel.shortcuts.values()).map((s: any) => ({
|
|
key: s.key, scope: s.scope
|
|
}));
|
|
}
|
|
return [];
|
|
});
|
|
console.log('Registered shortcuts:', shortcuts);
|
|
// Assert every built-in is present rather than an exact count, so adding a
|
|
// shortcut elsewhere doesn't break this test for the wrong reason.
|
|
const required = [
|
|
{ key: '?', scope: 'global' },
|
|
{ key: '/', scope: 'library' },
|
|
{ key: 'f', scope: 'library' },
|
|
{ key: 'e', scope: 'library' },
|
|
{ key: 'Space', scope: 'player' },
|
|
{ key: 'ArrowLeft', scope: 'player' },
|
|
{ key: 'ArrowRight', scope: 'player' },
|
|
{ key: 'Escape', scope: 'player' },
|
|
{ key: 'Escape', scope: 'settings' },
|
|
{ key: '[', scope: 'player' },
|
|
{ key: ']', scope: 'player' },
|
|
];
|
|
for (const r of required) {
|
|
expect(shortcuts).toContainEqual(r);
|
|
}
|
|
expect(shortcuts.filter(s => s.key === '?' && s.scope === 'global')).toHaveLength(1);
|
|
});
|
|
|
|
test('should have global ? shortcut for help', async ({ page }) => {
|
|
await page.keyboard.press('?');
|
|
|
|
const modal = page.locator('#shortcuts-modal');
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
|
|
// Title should be "Keyboard shortcuts"
|
|
await expect(modal.locator('h3')).toContainText('Keyboard shortcuts');
|
|
});
|
|
|
|
test('should show library shortcuts in help modal', async ({ page }) => {
|
|
await page.keyboard.press('?');
|
|
|
|
const modal = page.locator('#shortcuts-modal');
|
|
|
|
// Library shortcuts should be visible on library screen
|
|
await expect(modal).toContainText('Focus search');
|
|
await expect(modal).toContainText('/');
|
|
|
|
// Player shortcuts should NOT be visible on library screen
|
|
await expect(modal).not.toContainText('Play/Pause');
|
|
});
|
|
|
|
test('should have correct shortcut scopes', async ({ page }) => {
|
|
const shortcuts = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
const shortcuts = [];
|
|
// @ts-ignore
|
|
const activePanel = window._panels.get(window.getActiveShortcutPanel());
|
|
if (activePanel) {
|
|
for (const [, s] of activePanel.shortcuts) {
|
|
shortcuts.push({ key: s.key, scope: s.scope, description: s.description });
|
|
}
|
|
}
|
|
return shortcuts;
|
|
});
|
|
|
|
const expectedShortcuts = [
|
|
{ key: '?', scope: 'global' },
|
|
{ key: '/', scope: 'library' },
|
|
{ key: 'f', scope: 'library' },
|
|
{ key: 'e', scope: 'library' },
|
|
{ key: 'Space', scope: 'player' },
|
|
{ key: 'ArrowLeft', scope: 'player' },
|
|
{ key: 'ArrowRight', scope: 'player' },
|
|
{ key: 'Escape', scope: 'player' },
|
|
{ key: 'Escape', scope: 'settings' },
|
|
{ key: '[', scope: 'player' },
|
|
{ key: ']', scope: 'player' },
|
|
];
|
|
|
|
for (const expected of expectedShortcuts) {
|
|
const found = shortcuts.find(s => s.key === expected.key && s.scope === expected.scope);
|
|
expect(found, `expected shortcut ${expected.scope}::${expected.key}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('should trigger ? shortcut on library screen', async ({ page }) => {
|
|
// On library screen
|
|
await page.keyboard.press('?');
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
});
|
|
|
|
test('should focus library search for unshifted / without opening shortcut help', async ({ page }) => {
|
|
await page.keyboard.press('/');
|
|
|
|
await expect(page.locator('#lib-filter')).toBeFocused();
|
|
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
|
|
});
|
|
|
|
test('should not open shortcut help for Shift+Slash while typing in search', async ({ page }) => {
|
|
await page.locator('#lib-filter').focus();
|
|
|
|
await page.evaluate(() => {
|
|
const input = document.getElementById('lib-filter');
|
|
input?.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: '/',
|
|
code: 'Slash',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
});
|
|
|
|
await expect(page.locator('#lib-filter')).toBeFocused();
|
|
await expect(page.locator('#shortcuts-modal')).toHaveCount(0);
|
|
});
|
|
|
|
test('Linux Shift+Slash on the library opens help without focusing search behind it (#602)', async ({ page }) => {
|
|
// Linux/Electron reports Shift+/ as key='/', code='Slash'. The help
|
|
// handler must open the modal AND stop the event so the shortcut
|
|
// registry's plain `/` library-search shortcut can't also fire and pull
|
|
// focus to #lib-filter behind the modal (regression for the Copilot
|
|
// review finding on this PR).
|
|
await page.evaluate(() => {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: '/',
|
|
code: 'Slash',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
});
|
|
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
await expect(page.locator('#lib-filter')).not.toBeFocused();
|
|
});
|
|
|
|
test('should trigger ? shortcut on player screen', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
|
|
await page.keyboard.press('?');
|
|
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
|
|
});
|
|
|
|
test('should trigger shortcut help on player screen for Linux Electron Shift+Slash event', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
|
|
await page.evaluate(() => {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: '/',
|
|
code: 'Slash',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
});
|
|
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
|
|
});
|
|
|
|
test('should trigger shortcut help on player screen when visualization picker is focused', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
await page.locator('#viz-picker').focus();
|
|
await expect(page.locator('#viz-picker')).toBeFocused();
|
|
|
|
await page.keyboard.press('?');
|
|
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
await expect(page.locator('#shortcuts-modal')).toContainText('Player');
|
|
});
|
|
|
|
test('should trigger ? shortcut on settings screen', async ({ page }) => {
|
|
// Navigate to settings
|
|
await page.click('text=Settings');
|
|
await page.waitForSelector('#settings.active', { timeout: 5000 });
|
|
|
|
await page.keyboard.press('?');
|
|
await expect(page.locator('#shortcuts-modal')).toBeVisible();
|
|
|
|
// Should show Settings section with Esc to go back
|
|
await expect(page.locator('#shortcuts-modal')).toContainText('Settings');
|
|
await expect(page.locator('#shortcuts-modal')).toContainText('Go back to previous screen');
|
|
// Should NOT show Library or Global shortcuts
|
|
await expect(page.locator('#shortcuts-modal')).not.toContainText('Focus search');
|
|
await expect(page.locator('#shortcuts-modal')).not.toContainText('Show keyboard shortcuts');
|
|
});
|
|
|
|
test('should close modal on Close button', async ({ page }) => {
|
|
await page.keyboard.press('?');
|
|
const modal = page.locator('#shortcuts-modal');
|
|
await expect(modal).toBeVisible();
|
|
|
|
// Click the close button (SVG icon)
|
|
await page.click('#shortcuts-modal button[data-shortcuts-close]');
|
|
await expect(modal).not.toBeVisible();
|
|
});
|
|
|
|
test('should unregister shortcut', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'test-key',
|
|
description: 'Test shortcut',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
// @ts-ignore
|
|
const activePanel = window._panels.get(window.getActiveShortcutPanel());
|
|
const beforeUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
|
|
// @ts-ignore
|
|
const unregistered = window.unregisterShortcut('test-key');
|
|
const afterUnregister = activePanel ? activePanel.shortcuts.has('global::test-key') : false;
|
|
return { beforeUnregister, unregistered, afterUnregister };
|
|
});
|
|
expect(result.beforeUnregister).toBe(true);
|
|
expect(result.unregistered).toBe(true);
|
|
expect(result.afterUnregister).toBe(false);
|
|
});
|
|
|
|
test('should support condition callbacks', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
let conditionMet = false;
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'test-cond',
|
|
description: 'Test with condition',
|
|
scope: 'global',
|
|
condition: () => conditionMet,
|
|
// @ts-ignore
|
|
handler: () => { window._conditionHandlerCalled = true; }
|
|
});
|
|
|
|
// Test with condition false
|
|
// @ts-ignore
|
|
window._conditionHandlerCalled = false;
|
|
const event1 = new KeyboardEvent('keydown', { key: 'test-cond' });
|
|
document.dispatchEvent(event1);
|
|
// @ts-ignore
|
|
const called1 = window._conditionHandlerCalled;
|
|
|
|
// Test with condition true
|
|
conditionMet = true;
|
|
// @ts-ignore
|
|
window._conditionHandlerCalled = false;
|
|
const event2 = new KeyboardEvent('keydown', { key: 'test-cond' });
|
|
document.dispatchEvent(event2);
|
|
// @ts-ignore
|
|
const called2 = window._conditionHandlerCalled;
|
|
|
|
// Cleanup
|
|
// @ts-ignore
|
|
window.unregisterShortcut('test-cond');
|
|
|
|
return { called1, called2 };
|
|
});
|
|
|
|
expect(result.called1).toBe(false); // Should not fire when condition is false
|
|
expect(result.called2).toBe(true); // Should fire when condition is true
|
|
});
|
|
|
|
test('should support modifier key combinations', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
let ctrlCalled = false;
|
|
let shiftCalled = false;
|
|
let noModifierCalled = false;
|
|
let sWithoutCtrlCalled = false;
|
|
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 's',
|
|
description: 'Save with Ctrl',
|
|
scope: 'global',
|
|
modifiers: { ctrl: true },
|
|
handler: () => { ctrlCalled = true; }
|
|
});
|
|
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 't',
|
|
description: 'Test with Shift',
|
|
scope: 'global',
|
|
modifiers: { shift: true },
|
|
handler: () => { shiftCalled = true; }
|
|
});
|
|
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'n',
|
|
description: 'No modifier',
|
|
scope: 'global',
|
|
handler: () => { noModifierCalled = true; }
|
|
});
|
|
|
|
// Test S without Ctrl first (should not fire)
|
|
const event0 = new KeyboardEvent('keydown', { key: 's' });
|
|
document.dispatchEvent(event0);
|
|
sWithoutCtrlCalled = ctrlCalled;
|
|
|
|
// Test Ctrl+S
|
|
const event1 = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
|
|
document.dispatchEvent(event1);
|
|
|
|
// Test Shift+T
|
|
const event2 = new KeyboardEvent('keydown', { key: 't', shiftKey: true });
|
|
document.dispatchEvent(event2);
|
|
|
|
// Test N without modifier
|
|
const event3 = new KeyboardEvent('keydown', { key: 'n' });
|
|
document.dispatchEvent(event3);
|
|
|
|
// Cleanup
|
|
// @ts-ignore
|
|
window.unregisterShortcut('s');
|
|
// @ts-ignore
|
|
window.unregisterShortcut('t');
|
|
// @ts-ignore
|
|
window.unregisterShortcut('n');
|
|
|
|
return { ctrlCalled, shiftCalled, noModifierCalled, sWithoutCtrlCalled };
|
|
});
|
|
|
|
expect(result.ctrlCalled).toBe(true);
|
|
expect(result.shiftCalled).toBe(true);
|
|
expect(result.noModifierCalled).toBe(true);
|
|
expect(result.sWithoutCtrlCalled).toBe(false); // Should not fire without Ctrl
|
|
});
|
|
|
|
test('should support panel-specific shortcuts', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
// Create a new panel
|
|
// @ts-ignore
|
|
const panel1 = window.createShortcutPanel('panel-1');
|
|
|
|
let panelShortcutCalled = false;
|
|
let globalShortcutCalled = false;
|
|
|
|
// Register a panel-specific shortcut
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('panel-1');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'w',
|
|
description: 'Panel-specific action',
|
|
scope: 'global',
|
|
handler: () => { panelShortcutCalled = true; }
|
|
});
|
|
|
|
// Register a global shortcut in default panel
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'g',
|
|
description: 'Global action',
|
|
scope: 'global',
|
|
handler: () => { globalShortcutCalled = true; }
|
|
});
|
|
|
|
// Test panel-specific shortcut (set panel-1 as active)
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('panel-1');
|
|
const event1 = new KeyboardEvent('keydown', { key: 'w' });
|
|
document.dispatchEvent(event1);
|
|
|
|
// Test global shortcut (set default as active)
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
const event2 = new KeyboardEvent('keydown', { key: 'g' });
|
|
document.dispatchEvent(event2);
|
|
|
|
// Cleanup
|
|
// @ts-ignore
|
|
panel1.clearShortcuts();
|
|
// @ts-ignore
|
|
window.unregisterShortcut('g');
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
|
|
return { panelShortcutCalled, globalShortcutCalled };
|
|
});
|
|
|
|
expect(result.panelShortcutCalled).toBe(true);
|
|
expect(result.globalShortcutCalled).toBe(true);
|
|
});
|
|
|
|
test('should show panel-specific shortcuts in modal', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
// Create a new panel
|
|
// @ts-ignore
|
|
const panel1 = window.createShortcutPanel('panel-1');
|
|
|
|
// Register a panel-specific shortcut
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('panel-1');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'x',
|
|
description: 'Panel-specific action',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
|
|
// Register a shortcut in default panel
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'y',
|
|
description: 'Default panel action',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
|
|
// Open the modal (default panel is active)
|
|
// @ts-ignore
|
|
window._openShortcutsModal();
|
|
|
|
// Check if the modal exists and contains the panel-specific shortcut.
|
|
// Use section-targeted DOM queries so an unrelated string elsewhere in
|
|
// the modal can't satisfy these assertions.
|
|
const modal = document.getElementById('shortcuts-modal');
|
|
let hasPanelSection = false;
|
|
let hasShortcut = false;
|
|
let hasKey = false;
|
|
if (modal) {
|
|
const sections = modal.querySelectorAll('section');
|
|
for (const section of sections) {
|
|
const heading = section.querySelector('h4');
|
|
if (heading && heading.textContent.trim() === 'Panel panel-1') {
|
|
hasPanelSection = true;
|
|
hasShortcut = (section.textContent || '').includes('Panel-specific action');
|
|
const kbd = section.querySelector('kbd');
|
|
if (kbd && kbd.textContent.trim() === 'x') {
|
|
hasKey = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// The "Default panel action" entry should live in a row in the Global
|
|
// section; assert that a row description matches exactly rather than
|
|
// matching anywhere in innerHTML (the modal renders rows as <div>s
|
|
// containing a description <span> and a key <kbd>).
|
|
const hasDefaultShortcut = modal
|
|
? Array.from(modal.querySelectorAll('section span')).some(
|
|
(s) => (s.textContent || '').trim() === 'Default panel action'
|
|
)
|
|
: false;
|
|
|
|
// Cleanup
|
|
if (modal) modal.remove();
|
|
// @ts-ignore
|
|
panel1.clearShortcuts();
|
|
// @ts-ignore
|
|
window.unregisterShortcut('y');
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
|
|
return { hasPanelSection, hasShortcut, hasKey, hasDefaultShortcut };
|
|
});
|
|
|
|
expect(result.hasPanelSection).toBe(true); // Should show "Panel panel-1" section
|
|
expect(result.hasShortcut).toBe(true); // Should show the shortcut description
|
|
expect(result.hasKey).toBe(true); // Should show the shortcut key
|
|
expect(result.hasDefaultShortcut).toBe(true); // Should show default panel shortcut
|
|
});
|
|
|
|
test('should clear panel shortcuts on cleanup', async ({ page }) => {
|
|
const result = await page.evaluate(() => {
|
|
// Create a new panel
|
|
// @ts-ignore
|
|
const panel1 = window.createShortcutPanel('panel-1');
|
|
|
|
// Register panel-specific shortcuts
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('panel-1');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'x',
|
|
description: 'Panel shortcut 1',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'y',
|
|
description: 'Panel shortcut 2',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
|
|
// Register a global shortcut in default panel (should not be cleared)
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'z',
|
|
description: 'Global shortcut',
|
|
scope: 'global',
|
|
handler: () => {}
|
|
});
|
|
|
|
// Clear panel shortcuts
|
|
// @ts-ignore
|
|
panel1.clearShortcuts();
|
|
|
|
// Check that panel shortcuts are gone
|
|
// @ts-ignore
|
|
const hasX = panel1.shortcuts.has('global::x');
|
|
// @ts-ignore
|
|
const hasY = panel1.shortcuts.has('global::y');
|
|
// @ts-ignore
|
|
const defaultPanel = window._panels.get('default');
|
|
const hasZ = defaultPanel ? defaultPanel.shortcuts.has('global::z') : false;
|
|
|
|
// Cleanup
|
|
// @ts-ignore
|
|
window.unregisterShortcut('z');
|
|
// @ts-ignore
|
|
window.setActiveShortcutPanel('default');
|
|
|
|
return { hasX, hasY, hasZ };
|
|
});
|
|
|
|
expect(result.hasX).toBe(false); // Panel shortcut should be gone
|
|
expect(result.hasY).toBe(false); // Panel shortcut should be gone
|
|
expect(result.hasZ).toBe(true); // Global shortcut should still exist
|
|
});
|
|
|
|
test('should match shortcut by e.code (Space)', async ({ page }) => {
|
|
// The dispatcher matches against both e.key and e.code so that special
|
|
// keys (Space, ArrowLeft, …) registered by their code still fire when the
|
|
// browser delivers e.key=' ' / 'ArrowLeft'. Lock that behaviour in.
|
|
const result = await page.evaluate(() => {
|
|
let calledByCode = false;
|
|
let calledByKey = false;
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'Space',
|
|
description: 'Test e.code match',
|
|
scope: 'global',
|
|
// @ts-ignore
|
|
handler: () => { window._codeHandlerCalled = (window._codeHandlerCalled || 0) + 1; }
|
|
});
|
|
|
|
// @ts-ignore
|
|
window._codeHandlerCalled = 0;
|
|
// Real-keyboard event: e.key=' ', e.code='Space'
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }));
|
|
// @ts-ignore
|
|
calledByCode = window._codeHandlerCalled === 1;
|
|
|
|
// Synthetic event by e.key='Space' (legacy-style) should also work
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' }));
|
|
// @ts-ignore
|
|
calledByKey = window._codeHandlerCalled === 2;
|
|
|
|
// Cleanup
|
|
// @ts-ignore
|
|
window.unregisterShortcut('Space');
|
|
return { calledByCode, calledByKey };
|
|
});
|
|
|
|
expect(result.calledByCode).toBe(true);
|
|
expect(result.calledByKey).toBe(true);
|
|
});
|
|
|
|
test('Space toggles play/pause when a player rail button is focused (#593)', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
|
|
// Inject a focusable <button> into the player (the bug: BUTTON elements
|
|
// are "interactive controls" so Space was blocked before reaching the
|
|
// shortcut dispatcher) and spy on the player-scope Space shortcut so the
|
|
// assertion does not depend on the real audio path. The dispatcher calls
|
|
// preventDefault() before the handler, so the focused button must NOT
|
|
// also activate.
|
|
await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window.__spacePlayCount = 0;
|
|
// @ts-ignore
|
|
window.__railBtnClicked = 0;
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'Space',
|
|
description: 'Play/Pause (test spy)',
|
|
scope: 'player',
|
|
// @ts-ignore
|
|
handler: () => { window.__spacePlayCount++; },
|
|
});
|
|
const btn = document.createElement('button');
|
|
btn.id = '__test-rail-btn';
|
|
btn.textContent = 'Mixer';
|
|
// @ts-ignore
|
|
btn.addEventListener('click', () => { window.__railBtnClicked++; });
|
|
document.getElementById('player')!.appendChild(btn);
|
|
});
|
|
|
|
await page.locator('#__test-rail-btn').focus();
|
|
await expect(page.locator('#__test-rail-btn')).toBeFocused();
|
|
|
|
await page.keyboard.press('Space');
|
|
|
|
const result = await page.evaluate(() => ({
|
|
// @ts-ignore
|
|
played: window.__spacePlayCount,
|
|
// @ts-ignore
|
|
clicked: window.__railBtnClicked,
|
|
}));
|
|
// Play/pause fired despite the button holding focus…
|
|
expect(result.played).toBe(1);
|
|
// …and the focused button did not also activate (dispatcher preventDefault()).
|
|
expect(result.clicked).toBe(0);
|
|
});
|
|
|
|
test('Space in a player-screen text input still types a space, not play/pause (#593)', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
|
|
// The text-input exemption (_isTextInput) is checked before the player
|
|
// Space carve-out, so typing space in an input must never toggle playback.
|
|
await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window.__spacePlayCount = 0;
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'Space',
|
|
description: 'Play/Pause (test spy)',
|
|
scope: 'player',
|
|
// @ts-ignore
|
|
handler: () => { window.__spacePlayCount++; },
|
|
});
|
|
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('Space');
|
|
|
|
const result = await page.evaluate(() => ({
|
|
// @ts-ignore
|
|
played: window.__spacePlayCount,
|
|
value: (document.getElementById('__test-player-input') as HTMLInputElement).value,
|
|
}));
|
|
expect(result.played).toBe(0);
|
|
expect(result.value).toBe(' ');
|
|
});
|
|
|
|
test('Space inside a modal dialog over the player reaches the modal, not play/pause (#593)', async ({ page }) => {
|
|
await openPlayerWithMockSong(page);
|
|
|
|
// A true modal dialog (role="dialog" aria-modal="true" / .feedBack-modal)
|
|
// layered over the player must trap interaction: Space activates the
|
|
// modal's focused control (native), it does NOT toggle playback behind it.
|
|
await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window.__spacePlayCount = 0;
|
|
// @ts-ignore
|
|
window.__modalBtnClicked = 0;
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'Space',
|
|
description: 'Play/Pause (test spy)',
|
|
scope: 'player',
|
|
// @ts-ignore
|
|
handler: () => { window.__spacePlayCount++; },
|
|
});
|
|
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';
|
|
// @ts-ignore
|
|
btn.addEventListener('click', () => { window.__modalBtnClicked++; });
|
|
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('Space');
|
|
|
|
const result = await page.evaluate(() => ({
|
|
// @ts-ignore
|
|
played: window.__spacePlayCount,
|
|
// @ts-ignore
|
|
clicked: window.__modalBtnClicked,
|
|
}));
|
|
// Playback is NOT toggled behind the modal…
|
|
expect(result.played).toBe(0);
|
|
// …and Space activated the modal's focused button natively.
|
|
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 => {
|
|
if (msg.type() === 'warning') messages.push(msg.text());
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
// @ts-ignore
|
|
window.registerShortcut({
|
|
key: 'test-key',
|
|
description: 'Test shortcut',
|
|
scope: 'invalid-scope',
|
|
handler: () => {}
|
|
});
|
|
});
|
|
|
|
expect(messages.some(m => m.includes('invalid scope'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test.describe('Debug Helpers', () => {
|
|
test('should list shortcuts', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.waitForSelector('.screen.active');
|
|
|
|
const result = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
if (typeof window._listShortcuts === 'function') {
|
|
// @ts-ignore
|
|
window._listShortcuts();
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
test('should test specific shortcut', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.waitForSelector('.screen.active');
|
|
|
|
const result = await page.evaluate(() => {
|
|
// @ts-ignore
|
|
if (typeof window._testShortcut === 'function') {
|
|
// @ts-ignore
|
|
window._testShortcut('Space');
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|