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, 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 }> = []; 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) => { 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) => { const out: Record = {}; 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) => 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: 'tokugi' }, }, }, ] satisfies NoteUpdateWorkflowNoteInfo[]; harness.deps.processSentenceFurigana = (sentenceFurigana) => sentenceFurigana.replace('tokugi', 'tokugi'); await harness.workflow.execute(42); assert.equal(harness.updates.length, 1); assert.deepEqual(harness.updates[0]?.fields, { Sentence: 'subtitle-text', SentenceFurigana: 'tokugi', }); }); 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); });