mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test, { after } from 'node:test';
|
|
import { connectCdpPage, listCdpTargets, type CdpPage } from './harness/cdp';
|
|
import { FAKE_ANKI_DECK, FAKE_ANKI_MODEL } from './harness/fake-anki';
|
|
import { FIXTURE_CUES } from './harness/fixtures';
|
|
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
|
import { waitUntil } from './harness/wait';
|
|
|
|
type Started = { session: E2eSession; overlay: CdpPage };
|
|
|
|
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
|
// session boots inside whichever test runs first and every test budgets for it.
|
|
const TEST_TIMEOUT_MS = 120_000;
|
|
let started: Promise<Started> | undefined;
|
|
|
|
function useSession(): Promise<Started> {
|
|
started ??= (async () => {
|
|
const session = await startE2eSession();
|
|
try {
|
|
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
|
} catch (error) {
|
|
await session.dispose();
|
|
throw error;
|
|
}
|
|
})();
|
|
return started;
|
|
}
|
|
|
|
after(async () => {
|
|
const running = await started?.catch(() => undefined);
|
|
await running?.session.dispose();
|
|
});
|
|
|
|
type RenderedToken = { surface: string; headword: string | null };
|
|
|
|
// Parks playback inside a cue and waits for the overlay to show that line.
|
|
async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToken[]> {
|
|
const { session, overlay } = await useSession();
|
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
|
// One expression reads the line and its tokens together, so a re-render
|
|
// between two round trips cannot hand back a half-updated subtitle.
|
|
return overlay
|
|
.waitFor<RenderedToken[] | false>(
|
|
`(() => {
|
|
const root = document.getElementById('subtitleRoot');
|
|
const words = Array.from(root.querySelectorAll('.word'), (word) => ({
|
|
surface: word.textContent,
|
|
headword: word.getAttribute('data-headword'),
|
|
}));
|
|
return root.textContent === ${JSON.stringify(cue.text)} && words.length > 0 && words;
|
|
})()`,
|
|
{ description: `overlay to render "${cue.text}" tokenized` },
|
|
)
|
|
.catch(async (error: unknown) => {
|
|
// Say what the overlay was showing, what main thinks the line is, and
|
|
// what the renderer logged, so a dropped update can be placed.
|
|
const state = await overlay.evaluate<string>(
|
|
`(async () => JSON.stringify({
|
|
visibility: document.visibilityState,
|
|
text: document.getElementById('subtitleRoot').textContent,
|
|
html: document.getElementById('subtitleRoot').innerHTML.slice(0, 200),
|
|
mainLine: (await window.electronAPI.getCurrentSubtitle())?.text,
|
|
errorToast: document.getElementById('overlayErrorToast')?.textContent,
|
|
}))()`,
|
|
);
|
|
// Diagnostics must not replace the cue failure if the inspector is gone too.
|
|
const windows = await session
|
|
.mainProcess()
|
|
.then((main) =>
|
|
main.evaluate<string>(
|
|
`JSON.stringify(process.mainModule.require('electron').BrowserWindow.getAllWindows().map((w) => ({
|
|
id: w.id,
|
|
title: w.getTitle(),
|
|
url: w.webContents.getURL().split('/').pop(),
|
|
visible: w.isVisible(),
|
|
loading: w.webContents.isLoading(),
|
|
documentLoaded: w.__subminerOverlayDocumentLoaded,
|
|
contentReady: w.__subminerOverlayContentReady,
|
|
})))`,
|
|
),
|
|
)
|
|
.catch((inspectorError: unknown) => `unavailable (${String(inspectorError)})`);
|
|
const targets = (await listCdpTargets(session.cdpPort))
|
|
.filter((t) => t.type === 'page')
|
|
.map((t) => t.url.split('/').pop());
|
|
const log = overlay.console.slice(-20).join('\n');
|
|
throw new Error(
|
|
`Overlay state: ${state}\nMain windows: ${windows}\nPage targets: ${JSON.stringify(targets)}\nRenderer console:\n${log}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
}
|
|
|
|
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
|
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
|
}
|
|
|
|
test(
|
|
'overlay renders the current mpv subtitle line as dictionary tokens',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const tokens = await showCue(FIXTURE_CUES[0]);
|
|
|
|
assert.ok(hasToken(tokens, '今日', '今日'), JSON.stringify(tokens));
|
|
assert.ok(hasToken(tokens, '天気', '天気'), JSON.stringify(tokens));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'seeking to another cue replaces the line and deinflects verbs to their headword',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const tokens = await showCue(FIXTURE_CUES[2]);
|
|
|
|
assert.ok(hasToken(tokens, '行きます', '行く'), JSON.stringify(tokens));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'mining a sentence card sends the line with generated audio and image to Anki',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const { session } = await useSession();
|
|
const cue = FIXTURE_CUES[1];
|
|
await showCue(cue);
|
|
session.anki.requests.length = 0;
|
|
|
|
await session.runAppCommand('--mine-sentence');
|
|
const update = await waitUntil(
|
|
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
|
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
|
);
|
|
|
|
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
|
assert.equal(added.length, 1);
|
|
assert.partialDeepStrictEqual(added[0]?.params.note, {
|
|
deckName: FAKE_ANKI_DECK,
|
|
modelName: FAKE_ANKI_MODEL,
|
|
fields: { Sentence: cue.text },
|
|
});
|
|
|
|
const stored = session.anki.requests
|
|
.filter((request) => request.action === 'storeMediaFile')
|
|
.map((request) => String(request.params.filename));
|
|
assert.ok(
|
|
stored.some((name) => name.endsWith('.mp3')),
|
|
`no audio in ${stored.join(', ')}`,
|
|
);
|
|
assert.ok(
|
|
stored.some((name) => name.endsWith('.jpg')),
|
|
`no image in ${stored.join(', ')}`,
|
|
);
|
|
|
|
const updatedFields = JSON.stringify(update.params);
|
|
for (const filename of stored) assert.ok(updatedFields.includes(filename), filename);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'open subtitle sidebar lists the parsed cues and follows playback',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const { session, overlay } = await useSession();
|
|
await showCue(FIXTURE_CUES[0]);
|
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
|
await overlay.waitFor(
|
|
`document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`,
|
|
{ description: 'sidebar to list every fixture cue' },
|
|
);
|
|
|
|
for (const [index, cue] of FIXTURE_CUES.entries()) {
|
|
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
|
await overlay.waitFor(
|
|
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`,
|
|
{ description: `sidebar to highlight cue ${index}` },
|
|
);
|
|
}
|
|
|
|
await session.runAppCommand('--toggle-subtitle-sidebar');
|
|
await overlay.waitFor(
|
|
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
|
{ description: 'sidebar to close' },
|
|
);
|
|
},
|
|
);
|
|
|
|
// Runs last: it leaves the lookup popup open over the overlay.
|
|
test(
|
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const { session, overlay } = await useSession();
|
|
await showCue(FIXTURE_CUES[0]);
|
|
const word = await overlay.evaluate<{ x: number; y: number }>(
|
|
`(() => {
|
|
const words = Array.from(document.querySelectorAll('#subtitleRoot .word'));
|
|
const box = words.find((word) => word.textContent === '天気').getBoundingClientRect();
|
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
})()`,
|
|
);
|
|
|
|
// Yomitan's default scan modifier is Shift, and it scans on mouse movement,
|
|
// so approach the word, hold Shift, then move across it.
|
|
const shift = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
|
const move = (x: number, modifiers = 0) =>
|
|
overlay.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y: word.y, modifiers });
|
|
await move(word.x - 30);
|
|
await overlay.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...shift, modifiers: 8 });
|
|
await move(word.x - 2, 8);
|
|
await move(word.x, 8);
|
|
|
|
await overlay.waitFor(
|
|
`document.querySelector('[data-subminer-yomitan-popup-visible="true"]') !== null`,
|
|
{ description: 'overlay to mark the Yomitan popup visible' },
|
|
);
|
|
// Yomitan keeps more than one popup frame around, so read whichever
|
|
// one holds the entry instead of trusting target order.
|
|
let seen: string[] = [];
|
|
const entry = await waitUntil(
|
|
async () => {
|
|
const targets = (await listCdpTargets(session.cdpPort)).filter((target) =>
|
|
target.url.includes('popup.html'),
|
|
);
|
|
seen = [];
|
|
for (const target of targets) {
|
|
// A frame can go away between listing and connecting; skip it.
|
|
const frame = await connectCdpPage(target).catch(() => undefined);
|
|
if (!frame) continue;
|
|
// textContent, not innerText: the entry counts once it is in the DOM,
|
|
// even where a GPU-less runner never lays the frame out.
|
|
const text = await frame.evaluate<string>('document.body?.textContent ?? ""');
|
|
frame.close();
|
|
seen.push(text);
|
|
}
|
|
return seen.find((text) => text.includes('weather'));
|
|
},
|
|
{ description: 'a popup frame to show the fixture dictionary entry' },
|
|
).catch((error: unknown) => {
|
|
const summary = seen.map((text) => text.replace(/\s+/g, ' ').trim().slice(0, 200));
|
|
throw new Error(`Popup frames held: ${JSON.stringify(summary)}`, { cause: error });
|
|
});
|
|
await overlay.send('Input.dispatchKeyEvent', { type: 'keyUp', ...shift });
|
|
assert.match(entry, /天気/);
|
|
},
|
|
);
|