import assert from 'node:assert/strict'; import test from 'node:test'; import { FieldGroupingMergeCollaborator, type FieldGroupingMergeNoteInfo, } from './field-grouping-merge'; import type { AnkiConnectConfig } from '../types/anki'; function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { return ( availableFieldNames.find( (name) => name === preferredName || name.toLowerCase() === preferredName.toLowerCase(), ) ?? null ); } function createCollaborator( options: { config?: Partial; currentSubtitleText?: string; generatedMedia?: { audioField?: string; audioValue?: string; imageField?: string; imageValue?: string; miscInfoValue?: string; }; warnings?: Array<{ fieldName: string; reason: string; detail?: string }>; fieldGroupingProvider?: 'kiku' | 'senren' | null; } = {}, ) { const warnings = options.warnings ?? []; const config = { fields: { sentence: 'Sentence', audio: 'ExpressionAudio', image: 'Picture', miscInfo: 'MiscInfo', ...(options.config?.fields ?? {}), }, ...(options.config ?? {}), } as AnkiConnectConfig; return { collaborator: new FieldGroupingMergeCollaborator({ getConfig: () => config, getEffectiveSentenceCardConfig: () => ({ sentenceField: 'Sentence', audioField: 'SentenceAudio', fieldGroupingProvider: options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider, }), getCurrentSubtitleText: () => options.currentSubtitleText, resolveFieldName, resolveNoteFieldName: (noteInfo, preferredName) => { if (!preferredName) return null; return resolveFieldName(Object.keys(noteInfo.fields), preferredName); }, extractFields: (fields) => Object.fromEntries( Object.entries(fields).map(([key, value]) => [key.toLowerCase(), value.value || '']), ), processSentence: (mpvSentence) => `${mpvSentence}::processed`, generateMediaForMerge: async () => options.generatedMedia ?? {}, warnFieldParseOnce: (fieldName, reason, detail) => { warnings.push({ fieldName, reason, detail }); }, }), warnings, }; } function makeNote(noteId: number, fields: Record): FieldGroupingMergeNoteInfo { return { noteId, fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, { value }])), }; } test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => { const { collaborator } = createCollaborator({ config: { fields: { image: 'Illustration', sentence: 'SentenceText', audio: 'CustomWordAudio', miscInfo: 'ExtraInfo', }, }, }); assert.deepEqual(collaborator.getGroupableFieldNames(), [ 'Sentence', 'SentenceAudio', 'Picture', 'Illustration', 'SentenceText', 'ExtraInfo', 'SentenceFurigana', ]); }); test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => { const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { Sentence: 'original sentence', SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]', Picture: '', MiscInfo: 'original misc', ExpressionAudio: '[sound:word.mp3]', }), makeNote(200, { Sentence: 'new sentence', SentenceAudio: '[sound:new.mp3]', Picture: '', MiscInfo: 'new misc', }), false, ); assert.equal( merged.Sentence, 'original sentencenew sentence', ); assert.equal( merged.SentenceAudio, '[sound:original-a.mp3] [sound:original-b.mp3][sound:new.mp3]', ); assert.equal( merged.Picture, '', ); assert.equal( merged.MiscInfo, 'original miscnew misc', ); assert.equal('ExpressionAudio' in merged, false); }); test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => { const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( 200, 300, makeNote(200, { Sentence: 'new sentence', SentenceAudio: '[sound:new.mp3]', Picture: '', MiscInfo: 'new misc', }), makeNote(300, { Sentence: 'original sentence', SentenceAudio: '[sound:original.mp3]', Picture: '', MiscInfo: 'original misc', }), false, ); assert.equal( merged.Sentence, 'original sentencenew sentence', ); assert.equal( merged.SentenceAudio, '[sound:original.mp3][sound:new.mp3]', ); assert.equal( merged.Picture, '', ); assert.equal( merged.MiscInfo, 'original miscnew misc', ); }); test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => { const { collaborator, warnings } = createCollaborator({ currentSubtitleText: 'subtitle line', }); const merged = await collaborator.computeFieldGroupingMergedFields( 3, 4, makeNote(3, { Sentence: 'keep sentence', SentenceAudio: '', }), makeNote(4, { Sentence: 'source sentence', SentenceAudio: '[sound:source.mp3]', }), false, ); assert.equal( merged.Sentence, 'source sentencekeep sentence', ); assert.equal(merged.SentenceAudio, '[sound:source.mp3]'); assert.equal(warnings.length, 4); assert.deepEqual( warnings.map((entry) => entry.reason), ['invalid-group-id', 'no-usable-span-entries', 'invalid-group-id', 'no-usable-span-entries'], ); }); test('computeFieldGroupingMergedFields uses generated media only when includeGeneratedMedia is true', async () => { const generatedMedia = { audioField: 'SentenceAudio', audioValue: '[sound:generated.mp3]', imageField: 'Picture', imageValue: '', miscInfoValue: 'generated misc', }; const { collaborator: withoutGenerated } = createCollaborator({ generatedMedia }); const { collaborator: withGenerated } = createCollaborator({ generatedMedia }); const keep = makeNote(10, { SentenceAudio: '', Picture: '', MiscInfo: '', }); const source = makeNote(11, { SentenceAudio: '', Picture: '', MiscInfo: '', }); const without = await withoutGenerated.computeFieldGroupingMergedFields( 10, 11, keep, source, false, ); const withMedia = await withGenerated.computeFieldGroupingMergedFields( 10, 11, keep, source, true, ); assert.deepEqual(without, {}); assert.equal(withMedia.SentenceAudio, '[sound:generated.mp3]'); assert.equal(withMedia.Picture, ''); assert.equal(withMedia.MiscInfo, 'generated misc'); }); test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { word: '語', sentence: '', sentenceAudio: '[sound:original.opus]', picture: '', miscInfo: 'Show EP1 (0:01:00)', }), makeNote(200, { word: '語', sentence: '', sentenceAudio: '[sound:new.opus]', picture: '', miscInfo: 'Show EP2 (0:02:00)', }), false, ); assert.equal( merged.sentence, '' + '', ); assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]'); assert.equal(merged.picture, ''); assert.equal( merged.miscInfo, 'Show EP1 (0:01:00)Show EP2 (0:02:00)', ); }); test('Senren merge warns for invalid source audio when kept audio is empty', async () => { const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = []; const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren', warnings, }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { SentenceAudio: '' }), makeNote(200, { SentenceAudio: 'invalid audio' }), false, ); assert.equal(merged.SentenceAudio, 'invalid audio'); assert.deepEqual(warnings, [ { fieldName: 'SentenceAudio', reason: 'missing-sound-tag', detail: undefined, }, ]); }); test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { sentence: 'plain legacy sentence', sentenceAudio: '[sound:a.opus][sound:b.opus]', miscInfo: 'pinned stray text', }), makeNote(200, { sentence: 'new sentence', sentenceAudio: '[sound:c.opus]', miscInfo: 'new misc', }), false, ); assert.equal( merged.sentence, 'plain legacy sentencenew sentence', ); assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]'); assert.equal( merged.miscInfo, 'pinnedstray text' + 'new misc', ); }); test('Senren merge rebases numbered groups from an appended source note', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]', miscInfo: 'keep onekeep two', }), makeNote(200, { sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]', miscInfo: 'source two', }), false, ); assert.equal( merged.sentenceAudio, '[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]', ); assert.equal( merged.miscInfo, 'keep onekeep two' + 'source two', ); }); test('Senren merge rebases plain source groups after empty and sparse kept fields', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]', sentence: '', miscInfo: 'keep first', }), makeNote(200, { sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]', sentence: 'source first', miscInfo: 'source firstsource second', }), false, ); assert.equal(merged.sentence, 'source first'); assert.equal( merged.miscInfo, 'keep firstsource first' + 'source second', ); }); test('Senren merge keeps ungrouped text in place around an existing group span', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { miscInfo: 'leadingmiddletrailing', sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]', }), makeNote(200, { miscInfo: 'appended', sentenceAudio: '[sound:d.opus]', }), false, ); // Order must follow the source field, and the two ungrouped runs must stay separate. assert.equal( merged.miscInfo, 'leadingmiddle' + 'trailingappended', ); assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]'); }); test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { miscInfo: 'ab' }), makeNote(200, { miscInfo: 'next' }), false, ); assert.equal( merged.miscInfo, 'abnext', ); const openTags = merged.miscInfo!.match(//g)?.length ?? 0; assert.equal(openTags, closeTags); }); test('Senren merge closes unclosed trailing markup before appending later scenes', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { miscInfo: 'leadingtail' }), makeNote(200, { miscInfo: 'next' }), false, ); assert.equal( merged.miscInfo, 'leadingtail' + 'next', ); }); test('Kiku merge clears SentenceFurigana when either note lacks it', async () => { const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { SentenceFurigana: 'original furigana', }), makeNote(200, { SentenceFurigana: '', }), false, ); assert.equal(merged.SentenceFurigana, ''); }); test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => { const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' }); const merged = await collaborator.computeFieldGroupingMergedFields( 300, 200, makeNote(300, { SentenceFurigana: '', }), makeNote(200, { SentenceFurigana: 'duplicate furigana', }), false, ); assert.equal(merged.SentenceFurigana, 'duplicate furigana'); });