mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
b5eb20e8fe
The animetosho backend was swapped for TsukiHime and never shipped in a release (the integration landed after v0.18.0), so no config migration or legacy aliases are needed. Renames the module, IPC channels, config key, session action, shortcut id, CLI flag, DOM ids, and CSS classes. "animetosho" is kept only where it names the upstream service: the imported-entry flag TsukiHime returns, the /tosho/ storage mirror path, and storage.animetosho.org, which that mirror currently redirects to. Also drops two stray NUL bytes that have sat inside the dedup-key template literals since the integration landed (#159). They were harmless (any separator groups correctly) but made grep treat the file as binary.
480 lines
14 KiB
TypeScript
480 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
|
|
import { createRendererState } from '../state.js';
|
|
import { createTsukihimeModal } from './tsukihime.js';
|
|
|
|
function createClassList(initialTokens: string[] = []) {
|
|
const tokens = new Set(initialTokens);
|
|
return {
|
|
add: (...entries: string[]) => {
|
|
for (const entry of entries) {
|
|
tokens.add(entry);
|
|
}
|
|
},
|
|
remove: (...entries: string[]) => {
|
|
for (const entry of entries) {
|
|
tokens.delete(entry);
|
|
}
|
|
},
|
|
contains: (entry: string) => tokens.has(entry),
|
|
};
|
|
}
|
|
|
|
function createElementStub() {
|
|
const classList = createClassList();
|
|
return {
|
|
textContent: '',
|
|
className: '',
|
|
style: {},
|
|
classList,
|
|
children: [] as unknown[],
|
|
appendChild(child: unknown) {
|
|
this.children.push(child);
|
|
},
|
|
addEventListener: () => {},
|
|
};
|
|
}
|
|
|
|
function createListStub() {
|
|
return {
|
|
innerHTML: '',
|
|
children: [] as unknown[],
|
|
appendChild(child: unknown) {
|
|
this.children.push(child);
|
|
},
|
|
};
|
|
}
|
|
|
|
function flushAsyncWork(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
const ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
|
attachmentId: 1955356,
|
|
filename: 'episode01.eng.ass',
|
|
lang: 'eng',
|
|
trackName: 'English subs',
|
|
size: 33075,
|
|
url: 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz',
|
|
sourceFilename: 'episode01.mkv',
|
|
};
|
|
|
|
const JAPANESE_TRACK: TsukihimeSubtitleFile = {
|
|
attachmentId: 1955400,
|
|
filename: 'episode01.jpn.ass',
|
|
lang: 'jpn',
|
|
trackName: 'Japanese subs',
|
|
size: 41000,
|
|
url: 'https://storage.tsukihime.org/attach/001dd648/1955400.xz',
|
|
sourceFilename: 'episode01.mkv',
|
|
};
|
|
|
|
const GERMAN_TRACK: TsukihimeSubtitleFile = {
|
|
attachmentId: 1955500,
|
|
filename: 'episode01.ger.ass',
|
|
lang: 'ger',
|
|
trackName: 'Deutsch',
|
|
size: 28000,
|
|
url: 'https://storage.tsukihime.org/attach/001dd6ac/1955500.xz',
|
|
sourceFilename: 'episode01.mkv',
|
|
};
|
|
|
|
interface ModalHarness {
|
|
modal: ReturnType<typeof createTsukihimeModal>;
|
|
state: ReturnType<typeof createRendererState>;
|
|
downloadQueries: unknown[];
|
|
modalCloseNotifications: string[];
|
|
overlayClassList: ReturnType<typeof createClassList>;
|
|
tsukihimeModalClassList: ReturnType<typeof createClassList>;
|
|
titleInput: { value: string };
|
|
status: { textContent: string; style: { color: string } };
|
|
entriesList: ReturnType<typeof createListStub>;
|
|
filesList: ReturnType<typeof createListStub>;
|
|
restoreGlobals: () => void;
|
|
}
|
|
|
|
function createModalHarness(
|
|
files: TsukihimeSubtitleFile[],
|
|
options: {
|
|
secondaryLanguages?: string[];
|
|
listFiles?: (entryId: number) => Promise<unknown>;
|
|
searchEntries?: (query: unknown) => Promise<unknown>;
|
|
} = {},
|
|
): ModalHarness {
|
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
|
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
|
|
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
|
|
const previousWindow = globals.window;
|
|
const previousDocument = globals.document;
|
|
|
|
const modalCloseNotifications: string[] = [];
|
|
const downloadQueries: unknown[] = [];
|
|
|
|
const electronAPI = {
|
|
tsukihimeDownloadFile: async (query: unknown) => {
|
|
downloadQueries.push(query);
|
|
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
|
|
},
|
|
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
|
|
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
|
|
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
|
|
tsukihimeSearchEntries: async (query: unknown) =>
|
|
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
|
|
getJimakuMediaInfo: async () => ({
|
|
title: '',
|
|
season: null,
|
|
episode: null,
|
|
confidence: 'low',
|
|
filename: '',
|
|
rawTitle: '',
|
|
}),
|
|
notifyOverlayModalClosed: (modal: string) => {
|
|
modalCloseNotifications.push(modal);
|
|
},
|
|
} as unknown as ElectronAPI;
|
|
|
|
Object.defineProperty(globalThis, 'window', {
|
|
configurable: true,
|
|
value: { electronAPI },
|
|
});
|
|
Object.defineProperty(globalThis, 'document', {
|
|
configurable: true,
|
|
value: {
|
|
activeElement: null,
|
|
createElement: () => createElementStub(),
|
|
},
|
|
});
|
|
|
|
const overlayClassList = createClassList(['interactive']);
|
|
const tsukihimeModalClassList = createClassList();
|
|
const state = createRendererState();
|
|
state.tsukihimeModalOpen = true;
|
|
state.currentTsukihimeEntryId = 606713;
|
|
state.selectedTsukihimeFileIndex = 0;
|
|
state.tsukihimeFiles = files;
|
|
|
|
const ctx = {
|
|
dom: {
|
|
overlay: { classList: overlayClassList },
|
|
tsukihimeModal: {
|
|
classList: tsukihimeModalClassList,
|
|
setAttribute: () => {},
|
|
},
|
|
tsukihimeTitleInput: { value: '' },
|
|
tsukihimeEpisodeInput: { value: '' },
|
|
tsukihimeSearchButton: { addEventListener: () => {} },
|
|
tsukihimeCloseButton: { addEventListener: () => {} },
|
|
tsukihimeTabEnglishButton: {
|
|
textContent: 'English',
|
|
classList: createClassList(['active']),
|
|
addEventListener: () => {},
|
|
},
|
|
tsukihimeTabJapaneseButton: {
|
|
textContent: 'Japanese',
|
|
classList: createClassList(),
|
|
addEventListener: () => {},
|
|
},
|
|
tsukihimeStatus: { textContent: '', style: { color: '' } },
|
|
tsukihimeEntriesSection: { classList: createClassList(['hidden']) },
|
|
tsukihimeEntriesList: createListStub(),
|
|
tsukihimeFilesSection: { classList: createClassList() },
|
|
tsukihimeFilesList: createListStub(),
|
|
},
|
|
state,
|
|
};
|
|
|
|
const modal = createTsukihimeModal(ctx as never, {
|
|
modalStateReader: { isAnyModalOpen: () => false },
|
|
syncSettingsModalSubtitleSuppression: () => {},
|
|
});
|
|
|
|
return {
|
|
modal,
|
|
state,
|
|
downloadQueries,
|
|
modalCloseNotifications,
|
|
overlayClassList,
|
|
tsukihimeModalClassList,
|
|
titleInput: ctx.dom.tsukihimeTitleInput,
|
|
status: ctx.dom.tsukihimeStatus,
|
|
entriesList: ctx.dom.tsukihimeEntriesList,
|
|
filesList: ctx.dom.tsukihimeFilesList,
|
|
restoreGlobals: () => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
if (hadWindow) {
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
} else {
|
|
delete target.window;
|
|
}
|
|
if (hadDocument) {
|
|
Object.defineProperty(globalThis, 'document', {
|
|
configurable: true,
|
|
value: previousDocument,
|
|
});
|
|
} else {
|
|
delete target.document;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function pressKey(harness: ModalHarness, key: string): boolean {
|
|
let prevented = false;
|
|
harness.modal.handleTsukihimeKeydown({
|
|
key,
|
|
preventDefault: () => {
|
|
prevented = true;
|
|
},
|
|
} as KeyboardEvent);
|
|
return prevented;
|
|
}
|
|
|
|
test('successful Tsukihime subtitle selection closes modal', async () => {
|
|
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
|
try {
|
|
const prevented = pressKey(harness, 'Enter');
|
|
await flushAsyncWork();
|
|
|
|
assert.equal(prevented, true);
|
|
assert.equal(harness.state.tsukihimeModalOpen, false);
|
|
assert.equal(harness.tsukihimeModalClassList.contains('hidden'), true);
|
|
assert.equal(harness.overlayClassList.contains('interactive'), false);
|
|
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
|
|
assert.deepEqual(harness.downloadQueries, [
|
|
{
|
|
entryId: 606713,
|
|
url: ENGLISH_TRACK.url,
|
|
name: ENGLISH_TRACK.filename,
|
|
lang: 'eng',
|
|
},
|
|
]);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('English tab hides non-English languages, not just Japanese', async () => {
|
|
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
|
|
try {
|
|
// With German visible this would move selection onto it; English-only
|
|
// filtering must clamp to the single English track instead.
|
|
pressKey(harness, 'ArrowDown');
|
|
pressKey(harness, 'Enter');
|
|
await flushAsyncWork();
|
|
|
|
assert.deepEqual(harness.downloadQueries, [
|
|
{
|
|
entryId: 606713,
|
|
url: ENGLISH_TRACK.url,
|
|
name: ENGLISH_TRACK.filename,
|
|
lang: 'eng',
|
|
},
|
|
]);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('Japanese tab filters tracks so Enter downloads the Japanese one', async () => {
|
|
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
|
try {
|
|
assert.equal(harness.state.tsukihimeActiveTab, 'en');
|
|
pressKey(harness, 'ArrowRight');
|
|
assert.equal(harness.state.tsukihimeActiveTab, 'ja');
|
|
|
|
pressKey(harness, 'Enter');
|
|
await flushAsyncWork();
|
|
|
|
assert.deepEqual(harness.downloadQueries, [
|
|
{
|
|
entryId: 606713,
|
|
url: JAPANESE_TRACK.url,
|
|
name: JAPANESE_TRACK.filename,
|
|
lang: 'jpn',
|
|
},
|
|
]);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('secondary tab follows configured secondarySub languages', async () => {
|
|
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
|
|
secondaryLanguages: ['de'],
|
|
});
|
|
try {
|
|
// Re-open through the API so the modal fetches the configured languages.
|
|
harness.state.tsukihimeModalOpen = false;
|
|
harness.modal.openTsukihimeModal();
|
|
await flushAsyncWork();
|
|
|
|
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
|
|
harness.state.currentTsukihimeEntryId = 606713;
|
|
|
|
pressKey(harness, 'Enter');
|
|
await flushAsyncWork();
|
|
|
|
assert.deepEqual(harness.downloadQueries, [
|
|
{
|
|
entryId: 606713,
|
|
url: GERMAN_TRACK.url,
|
|
name: GERMAN_TRACK.filename,
|
|
lang: 'ger',
|
|
},
|
|
]);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('a slow release response does not overwrite the newly selected release', async () => {
|
|
const STALE_TRACK: TsukihimeSubtitleFile = {
|
|
...ENGLISH_TRACK,
|
|
attachmentId: 999,
|
|
filename: 'stale.eng.ass',
|
|
};
|
|
const SECOND_ENGLISH_TRACK: TsukihimeSubtitleFile = {
|
|
...ENGLISH_TRACK,
|
|
attachmentId: 1955357,
|
|
filename: 'episode01.eng.sdh.ass',
|
|
};
|
|
const resolvers: Array<(value: unknown) => void> = [];
|
|
|
|
const harness = createModalHarness([], {
|
|
listFiles: (entryId) =>
|
|
new Promise((resolve) => {
|
|
if (entryId === 1) {
|
|
// Entry 1 answers late, after the user has moved on to entry 2.
|
|
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
|
|
} else {
|
|
// Two tracks, so the modal does not auto-download a lone match.
|
|
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
|
|
}
|
|
}),
|
|
});
|
|
|
|
try {
|
|
harness.state.tsukihimeEntries = [
|
|
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
|
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
|
|
];
|
|
|
|
harness.modal.selectTsukihimeEntry(0);
|
|
harness.modal.selectTsukihimeEntry(1);
|
|
await flushAsyncWork();
|
|
|
|
// Entry 2's tracks are on screen; now entry 1 finally answers.
|
|
assert.deepEqual(
|
|
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
|
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
|
);
|
|
|
|
resolvers.forEach((resolve) => resolve(undefined));
|
|
await flushAsyncWork();
|
|
|
|
assert.equal(harness.state.currentTsukihimeEntryId, 2);
|
|
assert.deepEqual(
|
|
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
|
|
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
|
|
);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('ArrowLeft switches back to the English tab', () => {
|
|
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
|
|
try {
|
|
pressKey(harness, 'ArrowRight');
|
|
assert.equal(harness.state.tsukihimeActiveTab, 'ja');
|
|
pressKey(harness, 'ArrowLeft');
|
|
assert.equal(harness.state.tsukihimeActiveTab, 'en');
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
|
|
const harness = createModalHarness([], {
|
|
searchEntries: async () => ({
|
|
ok: true,
|
|
data: [
|
|
{
|
|
id: 12255,
|
|
title: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]',
|
|
timestamp: null,
|
|
totalSize: 423868898,
|
|
numFiles: 1,
|
|
sublangs: ['en-US', 'ja'],
|
|
},
|
|
{
|
|
id: 12256,
|
|
title: 'release without langs',
|
|
timestamp: null,
|
|
totalSize: null,
|
|
numFiles: null,
|
|
sublangs: [],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
try {
|
|
harness.state.tsukihimeFiles = [];
|
|
harness.state.currentTsukihimeEntryId = null;
|
|
harness.titleInput.value = 'Futsutsuka na Akujo';
|
|
|
|
const seenStatuses: string[] = [];
|
|
const status = harness.status;
|
|
Object.defineProperty(status, 'textContent', {
|
|
get: () => seenStatuses.at(-1) ?? '',
|
|
set: (value: string) => {
|
|
seenStatuses.push(value);
|
|
},
|
|
});
|
|
|
|
pressKey(harness, 'Enter');
|
|
await flushAsyncWork();
|
|
|
|
assert.equal(
|
|
seenStatuses.some((message) => message.includes('TsukiHime')),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
seenStatuses.some((message) => message.includes('Tsukihime')),
|
|
false,
|
|
);
|
|
|
|
const firstEntry = harness.entriesList.children[0] as {
|
|
children: Array<{ textContent: string }>;
|
|
};
|
|
assert.equal(firstEntry.children.length, 1);
|
|
assert.match(firstEntry.children[0]!.textContent, /en-US, ja/);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|
|
|
|
test('renderFiles omits the size detail when the API does not report one', () => {
|
|
const zeroSizeTrack: TsukihimeSubtitleFile = {
|
|
...ENGLISH_TRACK,
|
|
size: 0,
|
|
};
|
|
const harness = createModalHarness([zeroSizeTrack, JAPANESE_TRACK]);
|
|
try {
|
|
pressKey(harness, 'ArrowDown');
|
|
|
|
const firstFile = harness.filesList.children[0] as {
|
|
children: Array<{ textContent: string }>;
|
|
};
|
|
assert.equal(firstFile.children.length, 1);
|
|
assert.equal(firstFile.children[0]!.textContent.includes('0 B'), false);
|
|
assert.match(firstFile.children[0]!.textContent, /English subs/);
|
|
} finally {
|
|
harness.restoreGlobals();
|
|
}
|
|
});
|