mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
fix(jimaku): ignore stale search and file responses
- Discard replies after modal resets or entry changes - Add regression tests for in-flight requests
This commit is contained in:
@@ -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 });
|
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<number, (files: unknown[]) => 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ export function createJimakuModal(
|
|||||||
: 'rgba(255, 255, 255, 0.8)';
|
: '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 {
|
function resetJimakuLists(): void {
|
||||||
|
searchGeneration += 1;
|
||||||
ctx.state.jimakuEntries = [];
|
ctx.state.jimakuEntries = [];
|
||||||
ctx.state.jimakuFiles = [];
|
ctx.state.jimakuFiles = [];
|
||||||
ctx.state.selectedEntryIndex = 0;
|
ctx.state.selectedEntryIndex = 0;
|
||||||
@@ -150,9 +155,6 @@ export function createJimakuModal(
|
|||||||
return { query: title, episode: Number.isFinite(episode) ? episode : null };
|
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<void> {
|
async function performJimakuSearch(): Promise<void> {
|
||||||
const { query, episode } = getSearchQuery();
|
const { query, episode } = getSearchQuery();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -165,12 +167,11 @@ export function createJimakuModal(
|
|||||||
ctx.state.currentEpisodeFilter = episode;
|
ctx.state.currentEpisodeFilter = episode;
|
||||||
|
|
||||||
const category = ctx.state.jimakuActiveTab;
|
const category = ctx.state.jimakuActiveTab;
|
||||||
searchRequestId += 1;
|
const generation = searchGeneration;
|
||||||
const requestId = searchRequestId;
|
|
||||||
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
|
const response: JimakuApiResponse<JimakuEntry[]> = await window.electronAPI.jimakuSearchEntries(
|
||||||
{ query, category },
|
{ query, category },
|
||||||
);
|
);
|
||||||
if (requestId !== searchRequestId) return;
|
if (generation !== searchGeneration) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const retry = response.error.retryAfter
|
const retry = response.error.retryAfter
|
||||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||||
@@ -206,12 +207,15 @@ export function createJimakuModal(
|
|||||||
ctx.dom.jimakuFilesList.innerHTML = '';
|
ctx.dom.jimakuFilesList.innerHTML = '';
|
||||||
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
ctx.dom.jimakuFilesSection.classList.add('hidden');
|
||||||
|
|
||||||
|
const generation = searchGeneration;
|
||||||
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
|
const response: JimakuApiResponse<JimakuFileEntry[]> = await window.electronAPI.jimakuListFiles(
|
||||||
{
|
{
|
||||||
entryId,
|
entryId,
|
||||||
episode,
|
episode,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// The user may have picked another entry or reset the modal meanwhile.
|
||||||
|
if (generation !== searchGeneration || ctx.state.currentEntryId !== entryId) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const retry = response.error.retryAfter
|
const retry = response.error.retryAfter
|
||||||
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
? ` Retry after ${response.error.retryAfter.toFixed(1)}s.`
|
||||||
|
|||||||
Reference in New Issue
Block a user