import test from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; type TestOverlayNotificationPayload = { title: string; body?: string; image?: string; variant?: string; actions?: Array<{ id: string; label: string; noteId?: number }>; }; interface IntegrationTestContext { integration: AnkiIntegration; calls: { findNotes: number; notesInfo: number; }; stateDir: string; } function describeMediaInputForTest(input: MediaInput): string { if (typeof input === 'string') { return input; } return `${input.path}:${input.source ?? 'raw'}`; } function createIntegrationTestContext( options: { highlightEnabled?: boolean; nPlusOneEnabled?: boolean; onFindNotes?: () => Promise; onNotesInfo?: () => Promise; stateDirPrefix?: string; } = {}, ): IntegrationTestContext { const calls = { findNotes: 0, notesInfo: 0, }; const stateDir = fs.mkdtempSync( path.join(os.tmpdir(), options.stateDirPrefix ?? 'subminer-anki-integration-'), ); const knownWordCacheStatePath = path.join(stateDir, 'known-words-cache.json'); const client = { findNotes: async () => { calls.findNotes += 1; if (options.onFindNotes) { return options.onFindNotes(); } return [] as number[]; }, notesInfo: async () => { calls.notesInfo += 1; if (options.onNotesInfo) { return options.onNotesInfo(); } return [] as unknown[]; }, } as { findNotes: () => Promise; notesInfo: () => Promise; }; const integration = new AnkiIntegration( { knownWords: { highlightEnabled: options.highlightEnabled ?? true, }, nPlusOne: options.nPlusOneEnabled === undefined ? undefined : { enabled: options.nPlusOneEnabled, }, }, {} as never, {} as never, undefined, undefined, undefined, knownWordCacheStatePath, ); const integrationWithClient = integration as unknown as { client: { findNotes: () => Promise; notesInfo: () => Promise; }; }; integrationWithClient.client = client; const privateState = integration as unknown as { knownWordsScope: string; knownWordsLastRefreshedAtMs: number; }; privateState.knownWordsScope = 'all'; privateState.knownWordsLastRefreshedAtMs = Date.now(); return { integration, calls, stateDir, }; } function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void { fs.rmSync(ctx.stateDir, { recursive: true, force: true }); } function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { const exact = availableFieldNames.find((name) => name === preferredName); if (exact) return exact; const lower = preferredName.toLowerCase(); return availableFieldNames.find((name) => name.toLowerCase() === lower) ?? null; } function createFieldGroupingMergeCollaborator(options?: { config?: Partial; currentSubtitleText?: string; generatedMedia?: { audioField?: string; audioValue?: string; imageField?: string; imageValue?: string; miscInfoValue?: string; }; }): FieldGroupingMergeCollaborator { const config = { fields: { sentence: 'Sentence', audio: 'ExpressionAudio', image: 'Picture', ...(options?.config?.fields ?? {}), }, ...(options?.config ?? {}), } as AnkiConnectConfig; return new FieldGroupingMergeCollaborator({ getConfig: () => config, getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', }), getCurrentSubtitleText: () => options?.currentSubtitleText, resolveFieldName, resolveNoteFieldName: (noteInfo, preferredName) => { if (!preferredName) return null; return resolveFieldName(Object.keys(noteInfo.fields), preferredName); }, extractFields: (fields) => { const result: Record = {}; for (const [key, value] of Object.entries(fields)) { result[key.toLowerCase()] = value.value || ''; } return result; }, processSentence: (mpvSentence) => `${mpvSentence}::processed`, generateMediaForMerge: async () => options?.generatedMedia ?? {}, warnFieldParseOnce: () => undefined, }); } test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () => { const ctx = createIntegrationTestContext(); try { await ctx.integration.refreshKnownWordCache(); assert.equal(ctx.calls.findNotes, 1); assert.equal(ctx.calls.notesInfo, 0); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => { const ctx = createIntegrationTestContext({ stateDirPrefix: 'subminer-anki-integration-refresh-notify-', }); let notifications = 0; try { ctx.integration.setKnownWordCacheUpdatedCallback(() => { notifications += 1; }); await ctx.integration.refreshKnownWordCache(); assert.equal(notifications, 1); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => { const ctx = createIntegrationTestContext({ highlightEnabled: false, nPlusOneEnabled: true, stateDirPrefix: 'subminer-anki-integration-nplusone-notify-', }); let notifications = 0; try { ctx.integration.setKnownWordCacheUpdatedCallback(() => { notifications += 1; }); await ctx.integration.refreshKnownWordCache(); assert.equal(ctx.calls.findNotes, 1); assert.equal(notifications, 1); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { const ctx = createIntegrationTestContext({ highlightEnabled: false, stateDirPrefix: 'subminer-anki-integration-disabled-', }); try { await ctx.integration.refreshKnownWordCache(); assert.equal(ctx.calls.findNotes, 0); assert.equal(ctx.calls.notesInfo, 0); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration notifies when mined note info updates known words', () => { const ctx = createIntegrationTestContext({ stateDirPrefix: 'subminer-anki-integration-known-update-', }); let notifications = 0; try { const integrationState = ctx.integration as unknown as { config: AnkiConnectConfig; appendKnownWordsFromNoteInfo: (noteInfo: { noteId: number; fields: Record; }) => void; }; integrationState.config.deck = 'Mining'; integrationState.config.knownWords = { ...integrationState.config.knownWords, decks: { Mining: ['Word'], }, }; ctx.integration.setKnownWordCacheUpdatedCallback(() => { notifications += 1; }); integrationState.appendKnownWordsFromNoteInfo({ noteId: 42, fields: { Word: { value: '食べる' }, }, }); assert.equal(ctx.integration.isKnownWord('食べる'), true); assert.equal(notifications, 1); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => { let releaseFindNotes: (() => void) | undefined; const findNotesPromise = new Promise((resolve) => { releaseFindNotes = resolve; }); const ctx = createIntegrationTestContext({ onFindNotes: async () => { await findNotesPromise; return [] as number[]; }, stateDirPrefix: 'subminer-anki-integration-concurrent-', }); const first = ctx.integration.refreshKnownWordCache(); await Promise.resolve(); const second = ctx.integration.refreshKnownWordCache(); if (releaseFindNotes !== undefined) { releaseFindNotes(); } await Promise.all([first, second]); try { assert.equal(ctx.calls.findNotes, 1); assert.equal(ctx.calls.notesInfo, 0); } finally { cleanupIntegrationTestContext(ctx); } }); test('AnkiIntegration resolves merged-away note ids to the kept note id', () => { const ctx = createIntegrationTestContext({ stateDirPrefix: 'subminer-anki-integration-note-redirect-', }); try { const integrationWithInternals = ctx.integration as unknown as { rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; }; integrationWithInternals.rememberMergedNoteIds(111, 222); integrationWithInternals.rememberMergedNoteIds(222, 333); assert.equal(ctx.integration.resolveCurrentNoteId(111), 333); assert.equal(ctx.integration.resolveCurrentNoteId(222), 333); assert.equal(ctx.integration.resolveCurrentNoteId(333), 333); assert.equal(ctx.integration.resolveCurrentNoteId(444), 444); } finally { cleanupIntegrationTestContext(ctx); } }); function processSentenceWithConfig( config: Partial, mpvSentence: string, noteFields: Record, ): string { const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); return ( integration as unknown as { processSentence: (sentence: string, fields: Record) => string; } ).processSentence(mpvSentence, noteFields); } function processSentenceFuriganaWithConfig( config: Partial, sentenceFurigana: string, noteFields: Record, ): string { const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never); return ( integration as unknown as { processSentenceFurigana: (sentence: string, fields: Record) => string; } ).processSentenceFurigana(sentenceFurigana, noteFields); } test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => { const processed = processSentenceWithConfig( { fields: { word: 'Expression', sentence: 'Sentence', }, behavior: { highlightWord: true, }, }, '先日 貴様らが潜入した キールダンジョンから―', { expression: '潜入', sentence: '先日 貴様らが潜入した キールダンジョンから―', }, ); assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); }); test('AnkiIntegration keeps existing Yomitan bold target when present', () => { const processed = processSentenceWithConfig( { fields: { word: 'Expression', sentence: 'Sentence', }, behavior: { highlightWord: true, }, }, '先日 貴様らが潜入した キールダンジョンから―', { expression: '潜入', sentence: '潜入した', }, ); assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); }); test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => { const processed = processSentenceWithConfig( { fields: { word: 'Expression', sentence: 'Sentence', }, behavior: { highlightWord: false, }, }, '先日 貴様らが潜入した キールダンジョンから―', { expression: '潜入', sentence: '潜入', }, ); assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―'); }); test('AnkiIntegration highlights mined word in sentence furigana field', () => { const processed = processSentenceFuriganaWithConfig( { fields: { word: 'Expression', sentence: 'Sentence', }, behavior: { highlightWord: true, }, }, '不思議ふしぎ特技とくぎ', { expression: '特技', sentence: '不思議な特技を', }, ); assert.equal( processed, '不思議ふしぎ特技とくぎ', ); }); test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => { const integration = new AnkiIntegration( { enabled: true, proxy: { enabled: false, }, } as never, {} as never, {} as never, ); const privateState = integration as unknown as { runtime: { proxyServer: unknown | null; }; }; assert.equal(privateState.runtime.proxyServer, null); }); test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { const integration = new AnkiIntegration( { isKiku: { enabled: true, fieldGrouping: 'manual', }, } as never, {} as never, {} as never, ); let groupingTriggered = 0; const internals = integration as unknown as { cardCreationService: { createSentenceCard: ( sentence: string, startTime: number, endTime: number, secondarySubText?: string, ) => Promise; }; fieldGroupingService: { triggerFieldGroupingForLastAddedCard: () => Promise; }; }; 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 marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; const integration = new AnkiIntegration( { behavior: { notificationType: 'osd', }, }, {} as never, {} as never, (text) => { osdMessages.push(text); }, ); await ( integration as unknown as { showNotification: ( noteId: number, label: string | number, errorSuffix?: string, ) => Promise; } ).showNotification(42, 'taberu', 'image failed'); assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => { const osdMessages: string[] = []; const updatedNotes: Array<{ noteId: number; fields: Record }> = []; const storedMedia: string[] = []; const mediaInputs: string[] = []; const integration = new AnkiIntegration( { fields: { image: 'Picture', }, media: { imageFormat: 'jpg', }, behavior: { notificationType: 'osd', }, }, {} as never, {} as never, (text) => { osdMessages.push(text); }, ); const internals = integration as unknown as { client: { notesInfo: (noteIds: number[]) => Promise; updateNoteFields: (noteId: number, fields: Record) => Promise; storeMediaFile: (filename: string, data: Buffer) => Promise; }; mediaGenerator: { generateAudio: ( path: MediaInput, startTime: number, endTime: number, audioPadding?: number, audioStreamIndex?: number, ) => Promise; generateScreenshot: (path: MediaInput) => Promise; }; queuePendingYoutubeMediaUpdate: (job: { sourceUrl: string; noteId: number; startTime: number; endTime: number; label: string | number; audioStreamIndex?: number; audioFieldName?: string; imageFieldName?: string; generateAudio: boolean; generateImage: boolean; }) => void; }; internals.client = { notesInfo: async (noteIds) => noteIds.map((noteId) => ({ noteId, fields: { SentenceAudio: { value: '' }, Picture: { value: '' }, }, })), updateNoteFields: async (noteId, fields) => { updatedNotes.push({ noteId, fields }); }, storeMediaFile: async (filename) => { storedMedia.push(filename); }, }; internals.mediaGenerator = { generateAudio: async (mediaPath, _startTime, _endTime, _audioPadding, audioStreamIndex) => { mediaInputs.push( `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}`, ); return Buffer.from('audio'); }, generateScreenshot: async (mediaPath) => { mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`); return Buffer.from('image'); }, }; internals.queuePendingYoutubeMediaUpdate({ sourceUrl: 'https://www.youtube.com/watch?v=abc123', noteId: 101, startTime: 10, endTime: 12, label: 'first', audioStreamIndex: 22, audioFieldName: 'SentenceAudio', imageFieldName: 'Picture', generateAudio: true, generateImage: true, }); internals.queuePendingYoutubeMediaUpdate({ sourceUrl: 'https://youtu.be/abc123', noteId: 202, startTime: 20, endTime: 22, label: 'second', audioStreamIndex: 23, audioFieldName: 'SentenceAudio', imageFieldName: 'Picture', generateAudio: true, generateImage: true, }); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); assert.deepEqual(mediaInputs, [ 'audio:/tmp/media.mkv:youtube-cache:auto', 'image:/tmp/media.mkv:youtube-cache', 'audio:/tmp/media.mkv:youtube-cache:auto', 'image:/tmp/media.mkv:youtube-cache', ]); assert.deepEqual( updatedNotes.map((update) => update.noteId), [101, 202], ); const firstUpdate = updatedNotes[0]; const secondUpdate = updatedNotes[1]; assert.ok(firstUpdate); assert.ok(secondUpdate); assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/); assert.match(firstUpdate.fields.Picture ?? '', /^ message.includes('YouTube media cache ready. Adding media to 2 queued cards.'), ), true, ); }); test('AnkiIntegration reports partial queued YouTube media updates separately from failures', async () => { const osdMessages: string[] = []; const updatedNotes: Array<{ noteId: number; fields: Record }> = []; const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = []; const integration = new AnkiIntegration( { fields: { image: 'Picture', }, media: { imageFormat: 'jpg', }, behavior: { notificationType: 'osd', }, }, {} as never, {} as never, (text) => { osdMessages.push(text); }, ); const internals = integration as unknown as { client: { notesInfo: (noteIds: number[]) => Promise; updateNoteFields: (noteId: number, fields: Record) => Promise; storeMediaFile: () => Promise; }; mediaGenerator: { generateAudio: () => Promise; generateScreenshot: () => Promise; }; queuePendingYoutubeMediaUpdate: (job: { sourceUrl: string; noteId: number; startTime: number; endTime: number; label: string | number; audioFieldName?: string; imageFieldName?: string; generateAudio: boolean; generateImage: boolean; }) => void; showNotification: (noteId: number, label: string | number, suffix?: string) => Promise; }; internals.client = { notesInfo: async (noteIds) => noteIds.map((noteId) => ({ noteId, fields: { SentenceAudio: { value: '' }, Picture: { value: '' }, }, })), updateNoteFields: async (noteId, fields) => { updatedNotes.push({ noteId, fields }); }, storeMediaFile: async () => undefined, }; internals.mediaGenerator = { generateAudio: async () => { throw new Error('audio stream not found'); }, generateScreenshot: async () => Buffer.from('image'), }; internals.showNotification = async (noteId, label, suffix) => { notifications.push({ noteId, label, suffix }); }; internals.queuePendingYoutubeMediaUpdate({ sourceUrl: 'https://www.youtube.com/watch?v=partial', noteId: 303, startTime: 10, endTime: 12, label: 'partial', audioFieldName: 'SentenceAudio', imageFieldName: 'Picture', generateAudio: true, generateImage: true, }); await integration.handleYoutubeMediaCacheReady('https://youtu.be/partial', '/tmp/media.mkv'); assert.equal(updatedNotes.length, 1); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^[sound:new.mp3][sound:keep.mp3]', ); assert.equal('ExpressionAudio' in merged, false); }); test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { const collaborator = createFieldGroupingMergeCollaborator({ generatedMedia: { audioField: 'SentenceAudio', audioValue: '[sound:generated.mp3]', }, }); const merged = await collaborator.computeFieldGroupingMergedFields( 11, 22, { noteId: 11, fields: { SentenceAudio: { value: '' }, }, }, { noteId: 22, fields: { SentenceAudio: { value: '' }, }, }, true, ); assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); }); test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( 202, 101, { noteId: 202, fields: { Sentence: { value: 'same sentence' }, SentenceAudio: { value: '[sound:same.mp3]' }, Picture: { value: '' }, ExpressionAudio: { value: '[sound:same.mp3]' }, }, }, { noteId: 101, fields: { Sentence: { value: 'same sentence' }, SentenceAudio: { value: '[sound:same.mp3]' }, Picture: { value: '' }, }, }, false, ); assert.equal( merged.Sentence, 'same sentencesame sentence', ); assert.equal( merged.SentenceAudio, '[sound:same.mp3][sound:same.mp3]', ); assert.equal( merged.Picture, '', ); assert.equal('ExpressionAudio' in merged, false); }); test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { const integration = new AnkiIntegration( { metadata: { pattern: '[SubMiner] %f (%t)', }, } as never, {} as never, { currentSubText: '', currentVideoPath: 'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3', currentTimePos: 426, currentSubStart: 426, currentSubEnd: 428, currentAudioStreamIndex: 3, currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', send: () => true, } as unknown as never, ); const privateApi = integration as unknown as { formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; }; const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426); assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); assert.equal(result.includes('api_key='), false); });