Files
SubMiner/src/anki-integration.test.ts
T
sudacode 028636c76d feat(youtube): queue media for background cache and fill fields when rea
- Add `youtube.mediaCache.maxHeight` config option (default 720p)
- Background mode creates text-only cards while cache downloads, queues media updates, fills audio/image fields once cached file is ready
- Announce cache download start and readiness via overlay/OSD notifications
- Skip mpv stream indexes when generating audio from a YouTube cached file
2026-06-23 20:45:28 -07:00

1196 lines
33 KiB
TypeScript

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<number[]>;
onNotesInfo?: () => Promise<unknown[]>;
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<number[]>;
notesInfo: () => Promise<unknown[]>;
};
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<number[]>;
notesInfo: () => Promise<unknown[]>;
};
};
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<AnkiConnectConfig>;
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<string, string> = {};
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<string, { value: string }>;
}) => 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<void>((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<AnkiConnectConfig>,
mpvSentence: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentence: (sentence: string, fields: Record<string, string>) => string;
}
).processSentence(mpvSentence, noteFields);
}
function processSentenceFuriganaWithConfig(
config: Partial<AnkiConnectConfig>,
sentenceFurigana: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => 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, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
});
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入した</b>',
},
);
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
});
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: false,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入</b>',
},
);
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
});
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
const processed = processSentenceFuriganaWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
{
expression: '特技',
sentence: '不思議な特技を',
},
);
assert.equal(
processed,
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
);
});
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<boolean>;
};
fieldGroupingService: {
triggerFieldGroupingForLastAddedCard: () => Promise<void>;
};
};
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<void>;
}
).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<string, string> }> = [];
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<unknown[]>;
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
storeMediaFile: (filename: string, data: Buffer) => Promise<void>;
};
mediaGenerator: {
generateAudio: (
path: MediaInput,
startTime: number,
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
) => Promise<Buffer>;
generateScreenshot: (path: MediaInput) => Promise<Buffer>;
};
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 ?? '', /^<img src="image_/);
assert.match(secondUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(secondUpdate.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 4);
assert.equal(
osdMessages.some((message) =>
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<string, string> }> = [];
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<unknown[]>;
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
storeMediaFile: () => Promise<void>;
};
mediaGenerator: {
generateAudio: () => Promise<Buffer>;
generateScreenshot: () => Promise<Buffer>;
};
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<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 () => 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 ?? '', /^<img src="image_/);
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
assert.deepEqual(notifications, [{ noteId: 303, label: 'partial', suffix: 'audio failed' }]);
assert.equal(
osdMessages.some((message) =>
message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'),
),
true,
);
});
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
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<Buffer>;
};
generateAudio: () => Promise<Buffer | null>;
};
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<Buffer>;
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<void>;
}
).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<Buffer>;
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<void>;
}
).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,
'<span data-group-id="202">[sound:new.mp3]</span><span data-group-id="101">[sound:keep.mp3]</span>',
);
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, '<span data-group-id="22">[sound:generated.mp3]</span>');
});
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: '<img src="same.png">' },
ExpressionAudio: { value: '[sound:same.mp3]' },
},
},
{
noteId: 101,
fields: {
Sentence: { value: 'same sentence' },
SentenceAudio: { value: '[sound:same.mp3]' },
Picture: { value: '<img src="same.png">' },
},
},
false,
);
assert.equal(
merged.Sentence,
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
);
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);
});