diff --git a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md index 1024c4ad..ae52670b 100644 --- a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md +++ b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md @@ -3,9 +3,9 @@ id: TASK-255 title: Add overlay playlist browser modal for sibling video files and mpv queue status: In Progress assignee: - - codex + - '@codex' created_date: '2026-03-30 05:46' -updated_date: '2026-03-31 04:57' +updated_date: '2026-03-31 05:40' labels: - feature - overlay @@ -44,6 +44,8 @@ Add an in-session overlay modal that opens from a keybinding during active playb 2026-03-30 CodeRabbit follow-up: 1) add failing runtime coverage for unreadable playlist-browser file stat failures, 2) add failing renderer coverage for stale snapshot UI reset on refresh failure/close, 3) add failing renderer coverage to block playlist-browser open when another modal already owns the overlay, 4) implement minimal fixes, 5) rerun targeted tests plus typecheck for touched surfaces. 2026-03-30 current CodeRabbit round: verify 4 unresolved threads, ignore already-fixed outdated dblclick thread if current code matches, add failing-first coverage for selection preservation / timestamp fixture consistency / string test-clock alignment, implement minimal fixes, rerun targeted tests plus typecheck. + +2026-03-30 latest CodeRabbit round on PR #37: 1) add failing coverage for negative fractional numeric __subminerTestNowMs input so nowMs() matches the string-backed path, 2) add failing coverage that playlist-browser modal tests restore absent window/document globals without leaving undefined-valued properties behind, 3) refactor repeated playlist-browser modal test harness into a shared setup/teardown fixture while preserving assertions, 4) implement minimal fixes, 5) rerun touched tests plus typecheck. ## Implementation Notes @@ -76,4 +78,6 @@ Split playlist-browser UI row rendering into `src/renderer/modals/playlist-brows 2026-03-30 PR #37 unresolved CodeRabbit threads currently reduce to three likely-actionable items plus one outdated renderer dblclick thread to verify against HEAD before touching code. 2026-03-30 Addressed latest unresolved CodeRabbit items on PR #37: preserved playlist-browser selection across mutation snapshots, taught nowMs() to honor string-backed test clocks so it stays aligned with currentDbTimestamp(), and normalized maintenance test timestamp fixtures to toDbTimestamp(). The older playlist-browser dblclick thread remains unresolved in GitHub state but current HEAD already contains that fix in playlist-browser-renderer.ts. + +2026-03-30 latest CodeRabbit remediation on PR #37: switched nowMs() numeric test-clock branch from Math.floor() to Math.trunc() so numeric and string-backed mock clocks agree for negative fractional values. Refactored playlist-browser modal tests onto a shared setup/teardown fixture that restores global window/document descriptors correctly, and added regression coverage that injected globals are deleted when originally absent. Verification: `bun test src/core/services/immersion-tracker/time.test.ts src/renderer/modals/playlist-browser.test.ts`, `bun run typecheck`. diff --git a/src/core/services/immersion-tracker/time.test.ts b/src/core/services/immersion-tracker/time.test.ts index 53951df2..75ff5e18 100644 --- a/src/core/services/immersion-tracker/time.test.ts +++ b/src/core/services/immersion-tracker/time.test.ts @@ -8,10 +8,21 @@ test('nowMs returns wall-clock epoch milliseconds', () => { test('nowMs honors string-backed test clock values', () => { const previousNowMs = globalThis.__subminerTestNowMs; - globalThis.__subminerTestNowMs = '1700000000123.9'; + globalThis.__subminerTestNowMs = '123.9'; try { - assert.equal(nowMs(), 1_700_000_000_123); + assert.equal(nowMs(), 123); + } finally { + globalThis.__subminerTestNowMs = previousNowMs; + } +}); + +test('nowMs truncates negative numeric test clock values', () => { + const previousNowMs = globalThis.__subminerTestNowMs; + globalThis.__subminerTestNowMs = -1.9; + + try { + assert.equal(nowMs(), -1); } finally { globalThis.__subminerTestNowMs = previousNowMs; } diff --git a/src/core/services/immersion-tracker/time.ts b/src/core/services/immersion-tracker/time.ts index 6323de39..8c8a94ca 100644 --- a/src/core/services/immersion-tracker/time.ts +++ b/src/core/services/immersion-tracker/time.ts @@ -4,7 +4,7 @@ declare global { function getMockNowMs(testNowMs: number | string | undefined): number | null { if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) { - return Math.floor(testNowMs); + return Math.trunc(testNowMs); } if (typeof testNowMs === 'string') { const parsed = Number(testNowMs.trim()); diff --git a/src/renderer/modals/playlist-browser.test.ts b/src/renderer/modals/playlist-browser.test.ts index c41524df..48212b12 100644 --- a/src/renderer/modals/playlist-browser.test.ts +++ b/src/renderer/modals/playlist-browser.test.ts @@ -203,141 +203,153 @@ function createMutationSnapshot(): PlaylistBrowserSnapshot { }; } -test('playlist browser modal opens with playlist-focused current item selection', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; - const notifications: string[] = []; +function restoreGlobalDescriptor( + key: K, + descriptor: PropertyDescriptor | undefined, +) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + return; + } + Reflect.deleteProperty(globalThis, key); +} + +function createPlaylistBrowserDomFixture() { + return { + overlay: { + classList: createClassList(), + focus: () => {}, + }, + playlistBrowserModal: createFakeElement(), + playlistBrowserTitle: createFakeElement(), + playlistBrowserStatus: createFakeElement(), + playlistBrowserDirectoryList: createListStub(), + playlistBrowserPlaylistList: createListStub(), + playlistBrowserClose: createFakeElement(), + }; +} + +function createPlaylistBrowserElectronApi(overrides?: Partial): ElectronAPI { + return { + getPlaylistBrowserSnapshot: async () => createSnapshot(), + notifyOverlayModalOpened: () => {}, + notifyOverlayModalClosed: () => {}, + focusMainWindow: async () => {}, + setIgnoreMouseEvents: () => {}, + appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), + ...overrides, + } as ElectronAPI; +} + +function setupPlaylistBrowserModalTest(options?: { + electronApi?: Partial; + shouldToggleMouseIgnore?: boolean; +}) { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const state = createRendererState(); + const dom = createPlaylistBrowserDomFixture(); + const ctx = { + state, + platform: { + shouldToggleMouseIgnore: options?.shouldToggleMouseIgnore ?? false, + }, + dom, + }; Object.defineProperty(globalThis, 'window', { configurable: true, value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => createSnapshot(), - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, + electronAPI: createPlaylistBrowserElectronApi(options?.electronApi), focus: () => {}, - }, + } satisfies { electronAPI: ElectronAPI; focus: () => void }, + writable: true, }); Object.defineProperty(globalThis, 'document', { configurable: true, value: { createElement: () => createPlaylistRow(), }, + writable: true, + }); + + return { + state, + dom, + createModal(overrides: Partial[1]> = {}) { + return createPlaylistBrowserModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + ...overrides, + }); + }, + restore() { + restoreGlobalDescriptor('window', previousWindowDescriptor); + restoreGlobalDescriptor('document', previousDocumentDescriptor); + }, + }; +} + +test('playlist browser test cleanup must delete injected globals that were originally absent', () => { + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false); + + const env = setupPlaylistBrowserModalTest(); + + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true); + + env.restore(); + + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false); + assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false); + assert.equal(typeof globalThis.window, 'undefined'); + assert.equal(typeof globalThis.document, 'undefined'); +}); + +test('playlist browser modal opens with playlist-focused current item selection', async () => { + const notifications: string[] = []; + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + }, }); try { - const state = createRendererState(); - const directoryList = createListStub(); - const playlistList = createListStub(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus: createFakeElement(), - playlistBrowserDirectoryList: directoryList, - playlistBrowserPlaylistList: playlistList, - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - assert.equal(state.playlistBrowserModalOpen, true); - assert.equal(state.playlistBrowserActivePane, 'playlist'); - assert.equal(state.playlistBrowserSelectedPlaylistIndex, 1); - assert.equal(state.playlistBrowserSelectedDirectoryIndex, 1); - assert.equal(directoryList.children.length, 2); - assert.equal(playlistList.children.length, 2); - assert.equal(directoryList.children[0]?.children.length, 2); - assert.equal(playlistList.children[0]?.children.length, 2); + assert.equal(env.state.playlistBrowserModalOpen, true); + assert.equal(env.state.playlistBrowserActivePane, 'playlist'); + assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 1); + assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 1); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); + assert.equal(env.dom.playlistBrowserDirectoryList.children[0]?.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children[0]?.children.length, 2); assert.deepEqual(notifications, ['open:playlist-browser']); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser modal action buttons stop double-click propagation', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => createSnapshot(), - notifyOverlayModalOpened: () => {}, - notifyOverlayModalClosed: () => {}, - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), - }, - }); + const env = setupPlaylistBrowserModalTest(); try { - const state = createRendererState(); - const directoryList = createListStub(); - const playlistList = createListStub(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus: createFakeElement(), - playlistBrowserDirectoryList: directoryList, - playlistBrowserPlaylistList: playlistList, - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - const row = directoryList.children[0] as ReturnType | undefined; + const row = + env.dom.playlistBrowserDirectoryList.children[0] as + | ReturnType + | undefined; const trailing = row?.children?.[1] as ReturnType | undefined; const button = trailing?.children?.at(-1) as @@ -355,98 +367,53 @@ test('playlist browser modal action buttons stop double-click propagation', asyn assert.equal(stopped, true); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser preserves prior selection across mutation snapshots', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => ({ - ...createSnapshot(), - directoryItems: [ - ...createSnapshot().directoryItems, - { - path: '/tmp/show/Show - S01E03.mkv', - basename: 'Show - S01E03.mkv', - episodeLabel: 'S1E3', - isCurrentFile: false, - }, - ], - playlistItems: [ - ...createSnapshot().playlistItems, - { - index: 2, - id: 3, - filename: '/tmp/show/Show - S01E03.mkv', - title: 'Episode 3', - displayLabel: 'Episode 3', - current: false, - playing: false, - path: '/tmp/show/Show - S01E03.mkv', - }, - ], - }), - notifyOverlayModalOpened: () => {}, - notifyOverlayModalClosed: () => {}, - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ - ok: true, - message: 'Queued file', - snapshot: createMutationSnapshot(), - }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => ({ + ...createSnapshot(), + directoryItems: [ + ...createSnapshot().directoryItems, + { + path: '/tmp/show/Show - S01E03.mkv', + basename: 'Show - S01E03.mkv', + episodeLabel: 'S1E3', + isCurrentFile: false, + }, + ], + playlistItems: [ + ...createSnapshot().playlistItems, + { + index: 2, + id: 3, + filename: '/tmp/show/Show - S01E03.mkv', + title: 'Episode 3', + displayLabel: 'Episode 3', + current: false, + playing: false, + path: '/tmp/show/Show - S01E03.mkv', + }, + ], + }), + appendPlaylistBrowserFile: async () => ({ + ok: true, + message: 'Queued file', + snapshot: createMutationSnapshot(), + }), }, }); try { - const state = createRendererState(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus: createFakeElement(), - playlistBrowserDirectoryList: createListStub(), - playlistBrowserPlaylistList: createListStub(), - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - state.playlistBrowserActivePane = 'directory'; - state.playlistBrowserSelectedDirectoryIndex = 2; - state.playlistBrowserSelectedPlaylistIndex = 0; + env.state.playlistBrowserActivePane = 'directory'; + env.state.playlistBrowserSelectedDirectoryIndex = 2; + env.state.playlistBrowserSelectedPlaylistIndex = 0; await modal.handlePlaylistBrowserKeydown({ key: 'Enter', @@ -457,88 +424,47 @@ test('playlist browser preserves prior selection across mutation snapshots', asy shiftKey: false, } as never); - assert.equal(state.playlistBrowserSelectedDirectoryIndex, 2); - assert.equal(state.playlistBrowserSelectedPlaylistIndex, 2); + assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 2); + assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 2); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; const calls: Array<[string, unknown[]]> = []; const notifications: string[] = []; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => createSnapshot(), - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async (filePath: string) => { - calls.push(['append', [filePath]]); - return { ok: true, message: 'append-ok', snapshot: createSnapshot() }; - }, - playPlaylistBrowserIndex: async (index: number) => { - calls.push(['play', [index]]); - return { ok: true, message: 'play-ok', snapshot: createSnapshot() }; - }, - removePlaylistBrowserIndex: async (index: number) => { - calls.push(['remove', [index]]); - return { ok: true, message: 'remove-ok', snapshot: createSnapshot() }; - }, - movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => { - calls.push(['move', [index, direction]]); - return { ok: true, message: 'move-ok', snapshot: createSnapshot() }; - }, - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + appendPlaylistBrowserFile: async (filePath: string) => { + calls.push(['append', [filePath]]); + return { ok: true, message: 'append-ok', snapshot: createSnapshot() }; + }, + playPlaylistBrowserIndex: async (index: number) => { + calls.push(['play', [index]]); + return { ok: true, message: 'play-ok', snapshot: createSnapshot() }; + }, + removePlaylistBrowserIndex: async (index: number) => { + calls.push(['remove', [index]]); + return { ok: true, message: 'remove-ok', snapshot: createSnapshot() }; + }, + movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => { + calls.push(['move', [index, direction]]); + return { ok: true, message: 'move-ok', snapshot: createSnapshot() }; + }, }, }); try { - const state = createRendererState(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus: createFakeElement(), - playlistBrowserDirectoryList: createListStub(), - playlistBrowserPlaylistList: createListStub(), - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); const preventDefault = () => {}; - state.playlistBrowserActivePane = 'directory'; - state.playlistBrowserSelectedDirectoryIndex = 0; + env.state.playlistBrowserActivePane = 'directory'; + env.state.playlistBrowserSelectedDirectoryIndex = 0; await modal.handlePlaylistBrowserKeydown({ key: 'Enter', code: 'Enter', @@ -556,7 +482,7 @@ test('playlist browser modal keydown routes append, remove, reorder, tab switch, metaKey: false, shiftKey: false, } as never); - assert.equal(state.playlistBrowserActivePane, 'playlist'); + assert.equal(env.state.playlistBrowserActivePane, 'playlist'); await modal.handlePlaylistBrowserKeydown({ key: 'ArrowDown', @@ -591,73 +517,28 @@ test('playlist browser modal keydown routes append, remove, reorder, tab switch, ['remove', [1]], ['play', [1]], ]); - assert.equal(state.playlistBrowserModalOpen, false); + assert.equal(env.state.playlistBrowserModalOpen, false); assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser keeps modal open when playing selected queue item fails', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; const notifications: string[] = []; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => createSnapshot(), - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), + playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }), }, }); try { - const state = createRendererState(); - const playlistBrowserStatus = createFakeElement(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus, - playlistBrowserDirectoryList: createListStub(), - playlistBrowserPlaylistList: createListStub(), - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - assert.equal(state.playlistBrowserModalOpen, true); + assert.equal(env.state.playlistBrowserModalOpen, true); await modal.handlePlaylistBrowserKeydown({ key: 'Enter', @@ -668,250 +549,109 @@ test('playlist browser keeps modal open when playing selected queue item fails', shiftKey: false, } as never); - assert.equal(state.playlistBrowserModalOpen, true); - assert.equal(playlistBrowserStatus.textContent, 'play failed'); - assert.equal(playlistBrowserStatus.classList.contains('error'), true); + assert.equal(env.state.playlistBrowserModalOpen, true); + assert.equal(env.dom.playlistBrowserStatus.textContent, 'play failed'); + assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true); assert.deepEqual(notifications, ['open:playlist-browser']); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser refresh failure clears stale rendered rows and reports the error', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; const notifications: string[] = []; let refreshShouldFail = false; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => { - if (refreshShouldFail) { - throw new Error('snapshot failed'); - } - return createSnapshot(); - }, - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => { + if (refreshShouldFail) { + throw new Error('snapshot failed'); + } + return createSnapshot(); + }, + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), }, }); try { - const state = createRendererState(); - const playlistBrowserTitle = createFakeElement(); - const playlistBrowserStatus = createFakeElement(); - const directoryList = createListStub(); - const playlistList = createListStub(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle, - playlistBrowserStatus, - playlistBrowserDirectoryList: directoryList, - playlistBrowserPlaylistList: playlistList, - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - assert.equal(directoryList.children.length, 2); - assert.equal(playlistList.children.length, 2); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); refreshShouldFail = true; await modal.refreshSnapshot(); - assert.equal(state.playlistBrowserSnapshot, null); - assert.equal(directoryList.children.length, 0); - assert.equal(playlistList.children.length, 0); - assert.equal(playlistBrowserTitle.textContent, 'Playlist Browser'); - assert.equal(playlistBrowserStatus.textContent, 'snapshot failed'); - assert.equal(playlistBrowserStatus.classList.contains('error'), true); + assert.equal(env.state.playlistBrowserSnapshot, null); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0); + assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser'); + assert.equal(env.dom.playlistBrowserStatus.textContent, 'snapshot failed'); + assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true); assert.deepEqual(notifications, ['open:playlist-browser']); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser close clears rendered snapshot ui', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; const notifications: string[] = []; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => createSnapshot(), - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), }, }); try { - const state = createRendererState(); - const playlistBrowserTitle = createFakeElement(); - const playlistBrowserStatus = createFakeElement(); - const directoryList = createListStub(); - const playlistList = createListStub(); - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay: { - classList: createClassList(), - focus: () => {}, - }, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle, - playlistBrowserStatus, - playlistBrowserDirectoryList: directoryList, - playlistBrowserPlaylistList: playlistList, - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { - modalStateReader: { isAnyModalOpen: () => false }, - syncSettingsModalSubtitleSuppression: () => {}, - }); + const modal = env.createModal(); await modal.openPlaylistBrowserModal(); - assert.equal(directoryList.children.length, 2); - assert.equal(playlistList.children.length, 2); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2); modal.closePlaylistBrowserModal(); - assert.equal(state.playlistBrowserSnapshot, null); - assert.equal(state.playlistBrowserStatus, ''); - assert.equal(directoryList.children.length, 0); - assert.equal(playlistList.children.length, 0); - assert.equal(playlistBrowserTitle.textContent, 'Playlist Browser'); - assert.equal(playlistBrowserStatus.textContent, ''); + assert.equal(env.state.playlistBrowserSnapshot, null); + assert.equal(env.state.playlistBrowserStatus, ''); + assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0); + assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0); + assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser'); + assert.equal(env.dom.playlistBrowserStatus.textContent, ''); assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } }); test('playlist browser open is ignored while another modal is already open', async () => { - const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; - const previousWindow = globals.window; - const previousDocument = globals.document; const notifications: string[] = []; let snapshotCalls = 0; - - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - electronAPI: { - getPlaylistBrowserSnapshot: async () => { - snapshotCalls += 1; - return createSnapshot(); - }, - notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), - notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), - focusMainWindow: async () => {}, - setIgnoreMouseEvents: () => {}, - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }), - } as unknown as ElectronAPI, - focus: () => {}, - }, - }); - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: { - createElement: () => createPlaylistRow(), + const env = setupPlaylistBrowserModalTest({ + electronApi: { + getPlaylistBrowserSnapshot: async () => { + snapshotCalls += 1; + return createSnapshot(); + }, + notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`), + notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`), }, }); try { - const state = createRendererState(); - const overlay = { - classList: createClassList(), - focus: () => {}, - }; - const ctx = { - state, - platform: { - shouldToggleMouseIgnore: false, - }, - dom: { - overlay, - playlistBrowserModal: createFakeElement(), - playlistBrowserTitle: createFakeElement(), - playlistBrowserStatus: createFakeElement(), - playlistBrowserDirectoryList: createListStub(), - playlistBrowserPlaylistList: createListStub(), - playlistBrowserClose: createFakeElement(), - }, - }; - - const modal = createPlaylistBrowserModal(ctx as never, { + const modal = env.createModal({ modalStateReader: { isAnyModalOpen: () => true }, - syncSettingsModalSubtitleSuppression: () => {}, }); await modal.openPlaylistBrowserModal(); - assert.equal(state.playlistBrowserModalOpen, false); + assert.equal(env.state.playlistBrowserModalOpen, false); assert.equal(snapshotCalls, 0); - assert.equal(overlay.classList.contains('interactive'), false); + assert.equal(env.dom.overlay.classList.contains('interactive'), false); assert.deepEqual(notifications, []); } finally { - Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + env.restore(); } });