mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(jimaku): add live-action subtitle search (#251)
This commit is contained in:
@@ -119,6 +119,8 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
classList: jimakuBroadenButtonClassList,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
jimakuTabAnimeButton: { classList: createClassList(['active']), setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: createClassList(), setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
@@ -147,3 +149,427 @@ test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('switching to the Live action tab re-runs the search with the live action category', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const searchQueries: Array<{ query: string; category?: string }> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: async (query: { query: string; category?: string }) => {
|
||||
searchQueries.push(query);
|
||||
return { ok: true, data: [] };
|
||||
},
|
||||
} 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 animeTabClassList = createClassList(['active']);
|
||||
const liveActionTabClassList = createClassList();
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList(['interactive']) },
|
||||
jimakuModal: { classList: createClassList(), setAttribute: () => {} },
|
||||
jimakuTitleInput: { value: 'Shinzanmono' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '3' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
jimakuEntriesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: createClassList(['hidden']) },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: { classList: createClassList(['hidden']), addEventListener: () => {} },
|
||||
jimakuTabAnimeButton: { classList: animeTabClassList, setAttribute: () => {} },
|
||||
jimakuTabLiveActionButton: { classList: liveActionTabClassList, setAttribute: () => {} },
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(state.jimakuActiveTab, 'liveAction');
|
||||
assert.equal(liveActionTabClassList.contains('active'), true);
|
||||
assert.equal(animeTabClassList.contains('active'), false);
|
||||
assert.deepEqual(searchQueries, [{ query: 'Shinzanmono', category: 'liveAction' }]);
|
||||
assert.equal(status.textContent, 'No live action entries found. Try the Anime tab.');
|
||||
|
||||
// Same tab again is a no-op: no duplicate request.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(searchQueries.length, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow reply from a superseded search does not overwrite the newer results', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const pending: Array<(entries: unknown[]) => void> = [];
|
||||
const electronAPI = {
|
||||
jimakuSearchEntries: () =>
|
||||
new Promise((resolve) => {
|
||||
pending.push((entries) => resolve({ ok: true, data: entries }));
|
||||
}),
|
||||
jimakuListFiles: async () => ({ ok: true, data: [] }),
|
||||
} 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: () => {},
|
||||
});
|
||||
|
||||
// Anime -> Live action -> Anime, all before any reply arrives.
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowRight',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'ArrowLeft',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
assert.equal(pending.length, 2);
|
||||
|
||||
// The stale live action reply lands after the newer anime search was issued.
|
||||
pending[0]!([{ id: 1, name: 'Stale live action entry' }]);
|
||||
await flushAsyncWork();
|
||||
assert.equal(state.jimakuEntries.length, 0);
|
||||
|
||||
pending[1]!([
|
||||
{ id: 2, name: 'Anime A' },
|
||||
{ id: 3, name: 'Anime B' },
|
||||
]);
|
||||
await flushAsyncWork();
|
||||
assert.deepEqual(
|
||||
state.jimakuEntries.map((entry) => entry.id),
|
||||
[2, 3],
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test('media info arriving after the modal closed does not fill inputs or search', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
let resolveMediaInfo!: (info: unknown) => void;
|
||||
let searchCalls = 0;
|
||||
const electronAPI = {
|
||||
getJimakuMediaInfo: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveMediaInfo = resolve;
|
||||
}),
|
||||
jimakuSearchEntries: async () => {
|
||||
searchCalls += 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();
|
||||
const titleInput = { value: '' };
|
||||
const status = { textContent: '', style: { color: '' } };
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
jimakuModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
||||
jimakuTitleInput: titleInput,
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: status,
|
||||
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.openJimakuModal();
|
||||
await flushAsyncWork();
|
||||
jimakuModal.closeJimakuModal();
|
||||
|
||||
resolveMediaInfo({
|
||||
title: 'Shinzanmono',
|
||||
season: 1,
|
||||
episode: 3,
|
||||
confidence: 'high',
|
||||
filename: 'Shinzanmono S01E03.mkv',
|
||||
rawTitle: 'Shinzanmono S01E03',
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(titleInput.value, '');
|
||||
assert.equal(searchCalls, 0);
|
||||
assert.equal(status.textContent, 'Loading media info...');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user