Files
SubMiner/e2e/harness/fake-anki.ts
T
sudacode 929e78b28c fix(anki): clip mined media on the sub-delay retimed timeline
mpv's sub-start/sub-end and parsed cues carry the subtitle file's own
timestamps; sub-delay (z/x, sub-step) only moves where mpv plays them.
Mining now shifts subtitle timings onto the video clock before media
generation, review, and MiscInfo timestamps.
2026-10-25 21:32:59 -07:00

132 lines
3.9 KiB
TypeScript

import { once } from 'node:events';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
export type AnkiRequest = { action: string; params: Record<string, unknown> };
export type FakeAnki = {
url: string;
/** Every AnkiConnect action received so far, with `multi` batches flattened. */
requests: AnkiRequest[];
stop: () => Promise<void>;
};
export const FAKE_ANKI_DECK = 'E2E';
export const FAKE_ANKI_MODEL = 'Lapis';
const MODEL_FIELDS = [
'Expression',
'ExpressionFurigana',
'ExpressionReading',
'ExpressionAudio',
'SelectionText',
'MainDefinition',
'Sentence',
'SentenceFurigana',
'SentenceAudio',
'Picture',
'Glossary',
'IsWordAndSentenceCard',
'IsClickCard',
'IsSentenceCard',
'IsAudioCard',
'MiscInfo',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
// Added notes are echoed back by `notesInfo` so the app can resolve their fields;
// scenarios still assert on `requests`, not on stored notes.
export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000;
const handle = (body: unknown): unknown => {
if (!isRecord(body) || typeof body.action !== 'string') return null;
const params = isRecord(body.params) ? body.params : {};
if (body.action === 'multi') {
return Array.isArray(params.actions) ? params.actions.map(handle) : [];
}
requests.push({ action: body.action, params });
switch (body.action) {
case 'version':
return 6;
case 'deckNames':
return [FAKE_ANKI_DECK];
case 'modelNames':
return [FAKE_ANKI_MODEL];
case 'modelFieldNames':
return MODEL_FIELDS;
case 'addNote': {
const noteId = nextNoteId++;
const note = isRecord(params.note) ? params.note : {};
addedNotes.set(noteId, isRecord(note.fields) ? note.fields : {});
return noteId;
}
case 'notesInfo': {
const ids = Array.isArray(params.notes) ? params.notes : [];
return ids.flatMap((id) => {
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
if (!fields) return [];
return [
{
noteId: id,
modelName: FAKE_ANKI_MODEL,
tags: [],
fields: Object.fromEntries(
MODEL_FIELDS.map((name, order) => [
name,
{ value: String(fields[name] ?? ''), order },
]),
),
},
];
});
}
case 'findNotes':
return [];
case 'storeMediaFile':
return params.filename ?? null;
default:
return null;
}
};
const server = http.createServer((request, response) => {
response.setHeader('content-type', 'application/json');
// GET exposes the request log so the session CLI can read it from outside.
if (request.method === 'GET') {
response.end(JSON.stringify(requests));
return;
}
const chunks: Buffer[] = [];
request.on('data', (chunk: Buffer) => chunks.push(chunk));
request.on('end', () => {
let body: unknown = null;
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
// Malformed bodies get the same null result AnkiConnect gives unknown actions.
}
response.end(JSON.stringify({ result: handle(body), error: null }));
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
requests,
stop: async () => {
server.closeAllConnections();
server.close();
await once(server, 'close');
},
};
}