diff --git a/src/renderer/modals/jimaku.test.ts b/src/renderer/modals/jimaku.test.ts index 773efb23..5e59757e 100644 --- a/src/renderer/modals/jimaku.test.ts +++ b/src/renderer/modals/jimaku.test.ts @@ -320,3 +320,171 @@ test('a slow reply from a superseded search does not overwrite the newer results Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); } }); + +test('closing the modal discards an in-flight search reply', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + let resolveSearch!: (entries: unknown[]) => void; + let listFilesCalls = 0; + const electronAPI = { + jimakuSearchEntries: () => + new Promise((resolve) => { + resolveSearch = (entries) => resolve({ ok: true, data: entries }); + }), + jimakuListFiles: async () => { + listFilesCalls += 1; + return { ok: true, data: [] }; + }, + notifyOverlayModalClosed: () => {}, + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: 'Shinzanmono' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: { textContent: '', style: { color: '' } }, + jimakuEntriesSection: { classList: createClassList(['hidden']) }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + await flushAsyncWork(); + jimakuModal.closeJimakuModal(); + + // A single entry would normally auto-select and fetch its files. + resolveSearch([{ id: 7, name: 'Only entry' }]); + await flushAsyncWork(); + + assert.equal(state.jimakuEntries.length, 0); + assert.equal(state.currentEntryId, null); + assert.equal(listFilesCalls, 0); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); + +test('a slow files reply for a previously selected entry is ignored', async () => { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const pending = new Map void>(); + const electronAPI = { + jimakuListFiles: (query: { entryId: number }) => + new Promise((resolve) => { + pending.set(query.entryId, (files) => resolve({ ok: true, data: files })); + }), + } as unknown as ElectronAPI; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { electronAPI }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + activeElement: null, + createElement: () => createElementStub(), + }, + }); + + try { + const state = createRendererState(); + state.jimakuModalOpen = true; + state.jimakuEntries = [ + { id: 1, name: 'Entry A' }, + { id: 2, name: 'Entry B' }, + ]; + + const ctx = { + dom: { + overlay: { classList: createClassList(['interactive']) }, + jimakuModal: { classList: createClassList(), setAttribute: () => {} }, + jimakuTitleInput: { value: '' }, + jimakuSeasonInput: { value: '' }, + jimakuEpisodeInput: { value: '' }, + jimakuSearchButton: { addEventListener: () => {} }, + jimakuCloseButton: { addEventListener: () => {} }, + jimakuStatus: { textContent: '', style: { color: '' } }, + jimakuEntriesSection: { classList: createClassList() }, + jimakuEntriesList: createListStub(), + jimakuFilesSection: { classList: createClassList(['hidden']) }, + jimakuFilesList: createListStub(), + jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} }, + jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} }, + jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} }, + }, + state, + }; + + const jimakuModal = createJimakuModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + }); + + // Select entry A, then move to entry B before A's files arrive. + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + jimakuModal.handleJimakuKeydown({ + key: 'ArrowDown', + preventDefault: () => {}, + } as KeyboardEvent); + jimakuModal.handleJimakuKeydown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent); + await flushAsyncWork(); + assert.equal(state.currentEntryId, 2); + + pending.get(1)!([ + { name: 'a.srt', url: 'https://jimaku.cc/a.srt', size: 1, last_modified: '' }, + ]); + await flushAsyncWork(); + assert.equal(state.jimakuFiles.length, 0); + + pending.get(2)!([ + { name: 'b1.srt', url: 'https://jimaku.cc/b1.srt', size: 1, last_modified: '' }, + { name: 'b2.srt', url: 'https://jimaku.cc/b2.srt', size: 1, last_modified: '' }, + ]); + await flushAsyncWork(); + assert.deepEqual( + state.jimakuFiles.map((file) => file.name), + ['b1.srt', 'b2.srt'], + ); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + } +}); diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index 14d8b3d3..009a7097 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -22,7 +22,12 @@ export function createJimakuModal( : 'rgba(255, 255, 255, 0.8)'; } + // Bumped whenever the lists are reset (new search, tab switch, open, close) + // so any in-flight entries or files reply for the old state is discarded. + let searchGeneration = 0; + function resetJimakuLists(): void { + searchGeneration += 1; ctx.state.jimakuEntries = []; ctx.state.jimakuFiles = []; ctx.state.selectedEntryIndex = 0; @@ -150,9 +155,6 @@ export function createJimakuModal( return { query: title, episode: Number.isFinite(episode) ? episode : null }; } - // Bumped per search so a slow reply cannot overwrite a newer search's results. - let searchRequestId = 0; - async function performJimakuSearch(): Promise { const { query, episode } = getSearchQuery(); if (!query) { @@ -165,12 +167,11 @@ export function createJimakuModal( ctx.state.currentEpisodeFilter = episode; const category = ctx.state.jimakuActiveTab; - searchRequestId += 1; - const requestId = searchRequestId; + const generation = searchGeneration; const response: JimakuApiResponse = await window.electronAPI.jimakuSearchEntries( { query, category }, ); - if (requestId !== searchRequestId) return; + if (generation !== searchGeneration) return; if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.` @@ -206,12 +207,15 @@ export function createJimakuModal( ctx.dom.jimakuFilesList.innerHTML = ''; ctx.dom.jimakuFilesSection.classList.add('hidden'); + const generation = searchGeneration; const response: JimakuApiResponse = await window.electronAPI.jimakuListFiles( { entryId, episode, }, ); + // The user may have picked another entry or reset the modal meanwhile. + if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return; if (!response.ok) { const retry = response.error.retryAfter ? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`