import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { once } from 'node:events'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api'; import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; type TestOverlayNotificationPayload = { id?: string; title: string; body?: string; image?: string; variant?: string; persistent?: boolean; actions?: Array<{ id: string; label: string; noteId?: number }>; }; type GenerateAudioFn = ( path: MediaInput, startTime: number, endTime: number, audioPadding?: number, audioStreamIndex?: number, normalizeAudio?: boolean, volumeScale?: number, ) => Promise; type PendingYoutubeJob = { sourceUrl: string; noteId: number; startTime: number; endTime: number; label: string | number; audioStreamIndex?: number; audioFieldName?: string; imageFieldName?: string; generateAudio: boolean; generateImage: boolean; volumeScale?: number; }; /** * The private members these tests swap or call. AnkiIntegration has no constructor seam for its * AnkiConnect client or media generator, so tests replace them after construction. */ type IntegrationInternals = { client: { findNotes?: () => Promise; notesInfo?: (noteIds: number[]) => Promise; updateNoteFields?: (noteId: number, fields: Record) => Promise; storeMediaFile?: (filename: string, data: Buffer) => Promise; }; mediaGenerator: { generateAudio?: GenerateAudioFn; generateScreenshot?: (path: MediaInput) => Promise; generateNotificationIcon?: (videoPath: string, timestamp: number) => Promise; writeNotificationIconToFile?: (iconBuffer: Buffer, noteId: number) => string; scheduleNotificationIconCleanup?: (filePath: string) => void; }; config: AnkiConnectConfig; cardCreationService: { createSentenceCard: () => Promise }; fieldGroupingService: { triggerFieldGroupingForLastAddedCard: () => Promise }; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; beginUpdateProgress: (message: string) => void; endUpdateProgress: () => void; processSentence: (sentence: string, fields: Record) => string; processSentenceFurigana: (sentence: string, fields: Record) => string; queuePendingYoutubeMediaUpdate: (job: PendingYoutubeJob) => void; queuePendingYoutubeMediaUpdateForNote: (job: { noteId: number; noteInfo: { noteId: number; fields: Record }; label: string | number; }) => Promise; generateAudio: () => Promise; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; appendKnownWordsFromNoteInfo: (noteInfo: { noteId: number; fields: Record; }) => void; rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; reportCardsReady: (noteIds: number[]) => void; rememberMiningProvenance: (noteIds: number[], provenance: PluginMiningProvenance | null) => void; }; /** Named stand-ins for AnkiIntegration's long positional constructor arguments. */ type IntegrationHooks = { mpvClient?: Record; osd?: (text: string) => void; desktop?: (title: string, options: { body?: string; icon?: string }) => void; overlay?: (payload: TestOverlayNotificationPayload) => void; dismissOverlay?: (id: string) => void; knownWordCacheStatePath?: string; getCachedMediaPath?: () => Promise; shouldRequireRemoteMediaCache?: () => boolean; getYoutubeMediaSourceUrl?: () => string | null; }; function createIntegration( config: Partial, hooks: IntegrationHooks = {}, ): AnkiIntegration { return new AnkiIntegration( config as AnkiConnectConfig, {} as never, (hooks.mpvClient ?? {}) as never, hooks.osd, hooks.desktop as never, undefined, hooks.knownWordCacheStatePath, {}, undefined, hooks.overlay as never, hooks.getCachedMediaPath, hooks.shouldRequireRemoteMediaCache, hooks.getYoutubeMediaSourceUrl, hooks.dismissOverlay, ); } function internalsOf(integration: AnkiIntegration): IntegrationInternals { return integration as unknown as IntegrationInternals; } function describeMediaInputForTest(input: MediaInput): string { if (typeof input === 'string') { return input; } return `${input.path}:${input.source ?? 'raw'}`; } // --- Known-word cache ------------------------------------------------------- type KnownWordsOptions = { highlightEnabled?: boolean; nPlusOneEnabled?: boolean; onFindNotes?: () => Promise; }; type KnownWordsContext = { integration: AnkiIntegration; calls: { findNotes: number; notesInfo: number }; }; /** Runs `run` against an integration whose AnkiConnect client is a counting fake; cleans up its cache dir. */ async function withKnownWordsIntegration( options: KnownWordsOptions, run: (ctx: KnownWordsContext) => Promise | void, ): Promise { const calls = { findNotes: 0, notesInfo: 0 }; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-integration-')); try { const integration = createIntegration( { knownWords: { highlightEnabled: options.highlightEnabled ?? true }, nPlusOne: options.nPlusOneEnabled === undefined ? undefined : { enabled: options.nPlusOneEnabled }, }, { knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json') }, ); internalsOf(integration).client = { findNotes: async () => { calls.findNotes += 1; return options.onFindNotes ? options.onFindNotes() : []; }, notesInfo: async () => { calls.notesInfo += 1; return []; }, }; await run({ integration, calls }); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } } const KNOWN_WORD_REFRESH_CASES: Array<{ name: string; options: KnownWordsOptions; expected: { findNotes: number; notifications: number }; }> = [ { name: 'refreshes once, skipping stale checks, and notifies annotation cache listeners', options: {}, expected: { findNotes: 1, notifications: 1 }, }, { name: 'notifies when n+1 is enabled without highlights', options: { highlightEnabled: false, nPlusOneEnabled: true }, expected: { findNotes: 1, notifications: 1 }, }, { name: 'skips work when highlight mode is disabled', options: { highlightEnabled: false }, expected: { findNotes: 0, notifications: 0 }, }, ]; for (const c of KNOWN_WORD_REFRESH_CASES) { test(`AnkiIntegration.refreshKnownWordCache ${c.name}`, async () => { await withKnownWordsIntegration(c.options, async ({ integration, calls }) => { let notifications = 0; integration.setKnownWordCacheUpdatedCallback(() => { notifications += 1; }); await integration.refreshKnownWordCache(); assert.equal(calls.findNotes, c.expected.findNotes); assert.equal(calls.notesInfo, 0); assert.equal(notifications, c.expected.notifications); }); }); } test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => { let releaseFindNotes: () => void = () => {}; const findNotesGate = new Promise((resolve) => { releaseFindNotes = resolve; }); await withKnownWordsIntegration( { onFindNotes: async () => { await findNotesGate; return []; }, }, async ({ integration, calls }) => { const first = integration.refreshKnownWordCache(); await Promise.resolve(); const second = integration.refreshKnownWordCache(); releaseFindNotes(); await Promise.all([first, second]); assert.equal(calls.findNotes, 1); assert.equal(calls.notesInfo, 0); }, ); }); test('AnkiIntegration notifies when mined note info updates known words', async () => { await withKnownWordsIntegration({}, ({ integration }) => { let notifications = 0; const internals = internalsOf(integration); internals.config.deck = 'Mining'; internals.config.knownWords = { ...internals.config.knownWords, decks: { Mining: ['Word'] }, }; integration.setKnownWordCacheUpdatedCallback(() => { notifications += 1; }); internals.appendKnownWordsFromNoteInfo({ noteId: 42, fields: { Word: { value: '食べる' } } }); assert.equal(integration.isKnownWord('食べる'), true); assert.equal(notifications, 1); }); }); test('AnkiIntegration resolves merged-away note ids to the kept note id', () => { const integration = createIntegration({}); const internals = internalsOf(integration); internals.rememberMergedNoteIds(111, 222); internals.rememberMergedNoteIds(222, 333); assert.equal(integration.resolveCurrentNoteId(111), 333); assert.equal(integration.resolveCurrentNoteId(222), 333); assert.equal(integration.resolveCurrentNoteId(333), 333); assert.equal(integration.resolveCurrentNoteId(444), 444); }); test('AnkiIntegration reports ready cards under the note that kept them after a merge', () => { const integration = createIntegration({}); const ready: number[][] = []; integration.setCardsReadyCallback((noteIds) => ready.push(noteIds)); const internals = internalsOf(integration); internals.rememberMergedNoteIds(111, 222); internals.reportCardsReady([111, 222, 444]); assert.deepEqual(ready, [[222, 444]]); }); test('ready events preserve each mining source when multiple notes merge into an older note', () => { const integration = createIntegration({}); const ready: PluginMinedNote[][] = []; integration.setCardsReadyCallback((_ids, notes) => ready.push(notes)); const internals = internalsOf(integration); const provenance = { minedAt: 1234, media: { playbackId: 1, path: 'Show - 02.mkv', title: null }, subtitle: { text: 'テスト', startTime: 1, endTime: 2 }, }; internals.rememberMiningProvenance([111, 222], provenance); provenance.media.path = 'Show - 03.mkv'; internals.rememberMergedNoteIds(111, 7); internals.rememberMergedNoteIds(222, 7); internals.reportCardsReady([111, 222]); assert.deepEqual( ready[0]?.map((note) => [note.originalNoteId, note.noteId, note.provenance?.media?.path]), [ [111, 7, 'Show - 02.mkv'], [222, 7, 'Show - 02.mkv'], ], ); internals.reportCardsReady([333]); assert.equal(ready[1]?.[0]?.provenance, null); }); // --- Sentence highlighting -------------------------------------------------- const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―'; const HIGHLIGHT_CASES = [ { name: 'highlights mined word from expression field when sentence has no bold marker', highlightWord: true, noteSentence: MPV_SENTENCE, expected: '先日 貴様らが潜入した キールダンジョンから―', }, { name: 'keeps existing Yomitan bold target when present', highlightWord: true, noteSentence: '潜入した', expected: '先日 貴様らが潜入した キールダンジョンから―', }, { name: 'leaves sentence plain when word highlighting is disabled', highlightWord: false, noteSentence: '潜入', expected: MPV_SENTENCE, }, ]; for (const c of HIGHLIGHT_CASES) { test(`AnkiIntegration ${c.name}`, () => { const integration = createIntegration({ fields: { word: 'Expression', sentence: 'Sentence' }, behavior: { highlightWord: c.highlightWord }, }); const processed = internalsOf(integration).processSentence(MPV_SENTENCE, { expression: '潜入', sentence: c.noteSentence, }); assert.equal(processed, c.expected); }); } test('AnkiIntegration highlights mined word in sentence furigana field', () => { const integration = createIntegration({ fields: { word: 'Expression', sentence: 'Sentence' }, behavior: { highlightWord: true }, }); const processed = internalsOf(integration).processSentenceFurigana( '不思議ふしぎな特技とくぎを', { expression: '特技', sentence: '不思議な特技を' }, ); assert.equal( processed, '不思議ふしぎな特技とくぎを', ); }); // --- Proxy, field grouping, notifications ----------------------------------- test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => { const occupiedServer = http.createServer(); occupiedServer.listen(0, '127.0.0.1'); await once(occupiedServer, 'listening'); const occupiedAddress = occupiedServer.address(); assert.ok(occupiedAddress && typeof occupiedAddress === 'object'); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-')); const overlayNotifications: TestOverlayNotificationPayload[] = []; const integration = createIntegration( { enabled: true, url: 'http://127.0.0.1:8765', proxy: { enabled: true, host: '127.0.0.1', port: occupiedAddress.port, upstreamUrl: 'http://127.0.0.1:8765', }, behavior: { notificationType: 'overlay' }, knownWords: { highlightEnabled: false }, nPlusOne: { enabled: false }, }, { knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json'), overlay: (payload) => { overlayNotifications.push(payload); }, }, ); try { integration.start(); await integration.waitUntilReady(); assert.deepEqual(overlayNotifications, [ { title: 'SubMiner', body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`, variant: 'info', }, ]); } finally { integration.stop(); occupiedServer.close(); await once(occupiedServer, 'close'); fs.rmSync(stateDir, { recursive: true, force: true }); } }); test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { const integration = createIntegration({ isKiku: { enabled: true, fieldGrouping: 'manual' }, }); let groupingTriggered = 0; const internals = internalsOf(integration); internals.cardCreationService = { createSentenceCard: async () => { integration.trackDuplicateNoteIdsForNote(42, [7]); return true; }, }; internals.fieldGroupingService = { triggerFieldGroupingForLastAddedCard: async () => { groupingTriggered += 1; }, }; assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true); assert.equal(groupingTriggered, 1); }); test('AnkiIntegration reports a new sentence card ready only after field grouping', async () => { const integration = createIntegration({ isKiku: { enabled: true, fieldGrouping: 'manual' }, }); const events: string[] = []; integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`)); const internals = internalsOf(integration); internals.cardCreationService = { createSentenceCard: async () => { integration.trackDuplicateNoteIdsForNote(42, [7]); internals.reportCardsReady([42]); return true; }, }; internals.fieldGroupingService = { triggerFieldGroupingForLastAddedCard: async () => { events.push('group'); }, }; assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true); assert.deepEqual(events, ['group', 'ready 42']); }); test('AnkiIntegration reports a card with queued YouTube media ready once the media lands', async () => { const events: string[] = []; const integration = createIntegration({ fields: { audio: 'ExpressionAudio', image: 'Picture' }, media: { imageFormat: 'jpg' }, behavior: { notificationType: 'osd' }, }); installFakeAnki(integration, { noteFieldNames: ['ExpressionAudio', 'Picture'], mediaGenerator: { generateAudio: async () => Buffer.from('audio'), generateScreenshot: async () => Buffer.from('image'), }, }); integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`)); const internals = internalsOf(integration); internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 })); // Note 7 was merged into 101, so it waits on 101's media too. internals.rememberMergedNoteIds(7, 101); internals.reportCardsReady([101, 7, 5]); assert.deepEqual(events, ['ready 5']); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); assert.deepEqual(events, ['ready 5', 'ready 101']); }); test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; const integration = createIntegration( { behavior: { notificationType: 'osd' } }, { osd: (text) => osdMessages.push(text) }, ); await internalsOf(integration).showNotification(42, 'taberu', 'image failed'); assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { const osdMessages: string[] = []; const desktopMessages: string[] = []; const overlayMessages: string[] = []; const integration = createIntegration( { behavior: { notificationType: 'both' } }, { osd: (text) => osdMessages.push(text), desktop: (title, options) => desktopMessages.push(`${title}:${options.body ?? ''}`), overlay: (payload) => overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`), }, ); assert.equal(await integration.createSentenceCard('食べる', 0, 1), false); assert.deepEqual(osdMessages, []); assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']); assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']); }); // --- YouTube media cache ---------------------------------------------------- /** * Installs a fake AnkiConnect client whose notes carry empty values for `noteFieldNames`, plus a * media generator that succeeds unless overridden. Records every note update and stored file. */ function installFakeAnki( integration: AnkiIntegration, options: { noteFieldNames: string[]; mediaGenerator?: IntegrationInternals['mediaGenerator']; }, ) { const updatedNotes: Array<{ noteId: number; fields: Record }> = []; const storedMedia: string[] = []; const internals = internalsOf(integration); internals.client = { notesInfo: async (noteIds) => noteIds.map((noteId) => ({ noteId, fields: Object.fromEntries(options.noteFieldNames.map((name) => [name, { value: '' }])), })), updateNoteFields: async (noteId, fields) => { updatedNotes.push({ noteId, fields }); }, storeMediaFile: async (filename) => { storedMedia.push(filename); }, }; internals.mediaGenerator = { generateAudio: async () => Buffer.from('audio'), generateScreenshot: async () => Buffer.from('image'), ...options.mediaGenerator, }; return { updatedNotes, storedMedia }; } function youtubeJob(overrides: Partial & { noteId: number }): PendingYoutubeJob { return { sourceUrl: 'https://www.youtube.com/watch?v=abc123', startTime: 10, endTime: 12, label: String(overrides.noteId), audioFieldName: 'SentenceAudio', imageFieldName: 'Picture', generateAudio: true, generateImage: true, ...overrides, }; } test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => { const osdMessages: string[] = []; const mediaInputs: string[] = []; const integration = createIntegration( { fields: { audio: 'ExpressionAudio', image: 'Picture' }, media: { imageFormat: 'jpg' }, behavior: { notificationType: 'osd' }, }, { osd: (text) => osdMessages.push(text) }, ); const { updatedNotes, storedMedia } = installFakeAnki(integration, { noteFieldNames: ['ExpressionAudio', 'Picture'], mediaGenerator: { generateAudio: async (mediaPath, _start, _end, _padding, audioStreamIndex, _norm, scale) => { mediaInputs.push( `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${scale}`, ); return Buffer.from('audio'); }, generateScreenshot: async (mediaPath) => { mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`); return Buffer.from('image'); }, }, }); const internals = internalsOf(integration); internals.queuePendingYoutubeMediaUpdate( youtubeJob({ noteId: 101, label: 'first', audioStreamIndex: 22, volumeScale: 0.25 }), ); internals.queuePendingYoutubeMediaUpdate( youtubeJob({ noteId: 202, sourceUrl: 'https://youtu.be/abc123', startTime: 20, endTime: 22, label: 'second', audioStreamIndex: 23, volumeScale: 0.8, }), ); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); assert.deepEqual(mediaInputs, [ 'audio:/tmp/media.mkv:youtube-cache:auto:0.25', 'image:/tmp/media.mkv:youtube-cache', 'audio:/tmp/media.mkv:youtube-cache:auto:0.8', 'image:/tmp/media.mkv:youtube-cache', ]); assert.deepEqual( updatedNotes.map((update) => update.noteId), [101, 202], ); for (const update of updatedNotes) { assert.match(update.fields.SentenceAudio ?? '', /^\[sound:audio_/); assert.match(update.fields.Picture ?? '', /^ message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'), ), ); }); test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => { const audioVolumeScales: Array = []; let mpvVolume = 30; const integration = createIntegration( { fields: { image: 'Picture' }, media: { imageFormat: 'jpg' } }, { mpvClient: { currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777', currentSubStart: 10, currentSubEnd: 12, currentTimePos: 11, requestProperty: async (name: string) => { assert.equal(name, 'volume'); return mpvVolume; }, }, osd: () => undefined, getCachedMediaPath: async () => null, shouldRequireRemoteMediaCache: () => true, getYoutubeMediaSourceUrl: () => 'https://www.youtube.com/watch?v=abc123', }, ); const { updatedNotes, storedMedia } = installFakeAnki(integration, { noteFieldNames: ['SentenceAudio', 'Picture'], mediaGenerator: { generateAudio: async (_path, _start, _end, _padding, _stream, _norm, volumeScale) => { audioVolumeScales.push(volumeScale); return Buffer.from('audio'); }, }, }); const internals = internalsOf(integration); internals.showNotification = async () => undefined; const queued = await internals.queuePendingYoutubeMediaUpdateForNote({ noteId: 404, noteInfo: { noteId: 404, fields: { SentenceAudio: { value: '' }, Picture: { value: '' } }, }, label: 'resolved source', }); // The volume is captured when the job is queued, not when the cache becomes ready. mpvVolume = 90; await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); assert.equal(queued, true); assert.equal(updatedNotes.length, 1); assert.equal(updatedNotes[0]?.noteId, 404); assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/); assert.equal(updatedNotes[0]?.fields.ExpressionAudio, undefined); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^