Files
SubMiner/src/anki-integration/note-update-workflow.test.ts
T

412 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
NoteUpdateWorkflow,
type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle';
function setWordAndSentenceCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
): void {
assert.equal(cardKind, 'word-and-sentence');
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
}
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = [];
const warnings: string[] = [];
const deps: NoteUpdateWorkflowDeps = {
client: {
notesInfo: async (_noteIds: number[]) =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[],
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
},
getConfig: () => ({
fields: {
sentence: 'Sentence',
},
media: {},
behavior: {},
}),
getCurrentSubtitleText: () => 'subtitle-text',
getCurrentSubtitleStart: () => 12.3,
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
out[key.toLowerCase()] = value.value;
}
return out;
},
findDuplicateNote: async (_expression, _excludeNoteId, _noteInfo) => null,
handleFieldGroupingAuto: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
undefined,
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
setCardTypeFields: setWordAndSentenceCardTypeFields,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null;
const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null,
generateImageFilename: () => 'image_1.jpg',
generateImage: async () => null,
formatMiscInfoPattern: () => '',
addConfiguredTagsToNote: async () => undefined,
showNotification: async (noteId: number, label: string | number) => {
notifications.push({ noteId, label });
},
showOsdNotification: (_text: string) => undefined,
beginUpdateProgress: (_text: string) => undefined,
endUpdateProgress: () => undefined,
logWarn: (message: string, ..._args: unknown[]) => warnings.push(message),
logInfo: (_message: string) => undefined,
logError: (_message: string) => undefined,
};
return {
workflow: new NoteUpdateWorkflow(deps),
updates,
notifications,
warnings,
deps,
};
}
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
const harness = createWorkflowHarness();
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.noteId, 42);
assert.equal(harness.updates[0]?.fields.Sentence, 'subtitle-text');
assert.equal(harness.notifications.length, 1);
});
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'tokugi' },
Sentence: { value: '' },
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
});
});
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
IsWordAndSentenceCard: 'x',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'sentence expression' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: 'x' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [];
await harness.workflow.execute(777);
assert.equal(harness.updates.length, 0);
assert.equal(harness.notifications.length, 0);
assert.equal(harness.warnings.length, 1);
});
test('NoteUpdateWorkflow updates note before auto field grouping merge', async () => {
const harness = createWorkflowHarness();
const callOrder: string[] = [];
let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
notesInfoCallCount += 1;
if (notesInfoCallCount === 1) {
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
}
return [
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'subtitle-text' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
};
harness.deps.client.updateNoteFields = async (noteId, fields) => {
callOrder.push('update');
harness.updates.push({ noteId, fields });
};
harness.deps.handleFieldGroupingAuto = async (
_originalNoteId,
_newNoteId,
newNoteInfo,
_expression,
) => {
callOrder.push('auto');
assert.equal(newNoteInfo.fields.Sentence?.value, 'subtitle-text');
};
await harness.workflow.execute(42);
assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1);
});
test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word audio', async () => {
const harness = createWorkflowHarness();
let receivedLeadInSeconds = 0;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
ExpressionAudio: { value: '[sound:word.mp3]' },
Sentence: { value: '' },
Picture: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
},
media: {
generateImage: true,
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
},
behavior: {},
});
harness.deps.getAnimatedImageLeadInSeconds = async () => 1.25;
harness.deps.generateImage = async (leadInSeconds?: number) => {
receivedLeadInSeconds = leadInSeconds ?? 0;
return Buffer.from('image');
};
await harness.workflow.execute(42);
assert.equal(receivedLeadInSeconds, 1.25);
});
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
const harness = createWorkflowHarness();
const sidebarContext = {
source: 'subtitle-sidebar' as const,
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
};
let audioContext: unknown = null;
let imageContext: unknown = null;
let miscInfoStartTime: number | undefined;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'sidebar previous line' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'avif',
},
behavior: {},
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
};
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
imageContext = context ?? null;
return Buffer.from('image');
};
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
miscInfoStartTime = startTimeSeconds;
return `start:${startTimeSeconds}`;
};
(
harness.deps as NoteUpdateWorkflowDeps & {
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
}
).consumeSubtitleMiningContext = () => sidebarContext;
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
assert.deepEqual(audioContext, sidebarContext);
assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10);
});