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 ?? '', /^ { const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = []; const integration = new AnkiIntegration( { media: { audioPadding: 0, }, }, {} as never, { currentVideoPath: 'https://www.youtube.com/watch?v=abc123', currentAudioStreamIndex: 0, currentSubStart: 10, currentSubEnd: 12, currentTimePos: 11, } as never, () => undefined, undefined, undefined, undefined, {}, undefined, undefined, async () => '/tmp/subminer-youtube-media-cache/media.mkv', () => true, ); const internals = integration as unknown as { mediaGenerator: { generateAudio: ( path: { path: string }, startTime: number, endTime: number, audioPadding?: number, audioStreamIndex?: number, ) => Promise; }; generateAudio: () => Promise; }; internals.mediaGenerator = { generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => { audioCalls.push({ path: path.path, audioStreamIndex }); return Buffer.from('audio'); }, }; await internals.generateAudio(); assert.deepEqual(audioCalls, [ { path: '/tmp/subminer-youtube-media-cache/media.mkv', audioStreamIndex: undefined, }, ]); }); test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => { const overlayNotifications: TestOverlayNotificationPayload[] = []; const integration = new AnkiIntegration( { behavior: { notificationType: 'overlay', }, }, {} as never, {} as never, undefined, undefined, undefined, undefined, {}, undefined, (payload) => { overlayNotifications.push(payload as TestOverlayNotificationPayload); }, ); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); assert.equal(overlayNotifications.length, 1); assert.equal(overlayNotifications[0]?.title, 'SubMiner'); assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.'); }); test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => { const overlayNotifications: TestOverlayNotificationPayload[] = []; const integration = new AnkiIntegration( { behavior: { notificationType: 'overlay', }, }, {} as never, {} as never, undefined, undefined, undefined, undefined, {}, undefined, (payload) => { overlayNotifications.push(payload as TestOverlayNotificationPayload); }, ); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', { notifyNoQueued: false, }); assert.deepEqual(overlayNotifications, []); }); test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => { const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const overlayNotifications: TestOverlayNotificationPayload[] = []; const generatedFrom: Array<{ videoPath: string; timestamp: number }> = []; const cleanupPaths: string[] = []; const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png'); const integration = new AnkiIntegration( { behavior: { notificationType: 'both', }, }, {} as never, { currentVideoPath: '/tmp/show.mkv', currentTimePos: 123.45, } as never, undefined, (title, options) => { desktopNotifications.push({ title, body: options.body, icon: options.icon }); }, undefined, undefined, {}, undefined, (payload) => { overlayNotifications.push(payload as TestOverlayNotificationPayload); }, ); ( integration as unknown as { mediaGenerator: { generateNotificationIcon: (videoPath: string, timestamp: number) => Promise; writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string; scheduleNotificationIconCleanup: (filePath: string) => void; }; } ).mediaGenerator = { generateNotificationIcon: async (videoPath, timestamp) => { generatedFrom.push({ videoPath, timestamp }); return Buffer.from('png'); }, writeNotificationIconToFile: (iconBuffer, noteId) => { assert.equal(iconBuffer.toString(), 'png'); assert.equal(noteId, 42); return notificationIconPath; }, scheduleNotificationIconCleanup: (filePath) => { cleanupPaths.push(filePath); }, }; await ( integration as unknown as { showNotification: (noteId: number, label: string | number) => Promise; } ).showNotification(42, '食べる'); assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]); assert.equal(overlayNotifications.length, 1); assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated'); assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる'); assert.equal( overlayNotifications[0]?.image, `data:image/png;base64,${Buffer.from('png').toString('base64')}`, ); assert.deepEqual(overlayNotifications[0]?.actions, [ { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, ]); assert.deepEqual(desktopNotifications, [ { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: notificationIconPath, }, ]); assert.deepEqual(cleanupPaths, [notificationIconPath]); }); test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const overlayNotifications: TestOverlayNotificationPayload[] = []; const cleanupPaths: string[] = []; const integration = new AnkiIntegration( { behavior: { notificationType: 'both', }, }, {} as never, { currentVideoPath: '/tmp/show.mkv', currentTimePos: 123.45, } as never, undefined, (title, options) => { desktopNotifications.push({ title, body: options.body, icon: options.icon }); }, undefined, undefined, {}, undefined, (payload) => { overlayNotifications.push(payload as TestOverlayNotificationPayload); }, ); ( integration as unknown as { mediaGenerator: { generateNotificationIcon: () => Promise; writeNotificationIconToFile: () => string; scheduleNotificationIconCleanup: (filePath: string) => void; }; } ).mediaGenerator = { generateNotificationIcon: async () => Buffer.from('png'), writeNotificationIconToFile: () => { throw new Error('disk full'); }, scheduleNotificationIconCleanup: (filePath) => { cleanupPaths.push(filePath); }, }; await ( integration as unknown as { showNotification: (noteId: number, label: string | number) => Promise; } ).showNotification(42, '食べる'); assert.equal( overlayNotifications[0]?.image, `data:image/png;base64,${Buffer.from('png').toString('base64')}`, ); assert.deepEqual(desktopNotifications, [ { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: undefined, }, ]); assert.deepEqual(cleanupPaths, []); }); test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { const osdMessages: string[] = []; const desktopMessages: string[] = []; const overlayMessages: string[] = []; const integration = new AnkiIntegration( { behavior: { notificationType: 'both', }, }, {} as never, {} as never, (text) => { osdMessages.push(text); }, (title, options) => { desktopMessages.push(`${title}:${options.body ?? ''}`); }, undefined, undefined, {}, undefined, (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']); }); test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( 101, 202, { noteId: 101, fields: { SentenceAudio: { value: '[sound:keep.mp3]' }, ExpressionAudio: { value: '[sound:stale.mp3]' }, }, }, { noteId: 202, fields: { SentenceAudio: { value: '[sound:new.mp3]' }, }, }, false, ); assert.equal( merged.SentenceAudio, '[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); });