import assert from 'node:assert/strict'; import test from 'node:test'; import { FieldGroupingService } from './field-grouping'; import type { KikuMergePreviewResponse } from '../types/anki'; type NoteInfo = { noteId: number; fields: Record; }; function createHarness( options: { kikuEnabled?: boolean; kikuFieldGrouping?: 'auto' | 'manual' | 'disabled'; deck?: string; noteIds?: number[]; notesInfo?: NoteInfo[][]; duplicateNoteId?: number | null; trackedDuplicateNoteIds?: number[] | null; hasAllConfiguredFields?: boolean; manualHandled?: boolean; expression?: string | null; currentSentenceImageField?: string | undefined; onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void; } = {}, ) { const calls: string[] = []; const findNotesQueries: Array<{ query: string; maxRetries?: number }> = []; const noteInfoRequests: number[][] = []; const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = []; const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = []; const noteInfoQueue = [...(options.notesInfo ?? [])]; const notes = options.noteIds ?? [2]; const service = new FieldGroupingService({ getConfig: () => ({ fields: { word: 'Expression', }, }), getEffectiveSentenceCardConfig: () => ({ model: 'Sentence', sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: options.kikuEnabled ?? true, kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto', kikuDeleteDuplicateInAuto: true, }), isUpdateInProgress: () => false, getDeck: options.deck ? () => options.deck : undefined, withUpdateProgress: async (_message, action) => { calls.push('withUpdateProgress'); return action(); }, showOsdNotification: (text) => { calls.push(`osd:${text}`); }, findNotes: async (query, findNotesOptions) => { findNotesQueries.push({ query, maxRetries: findNotesOptions?.maxRetries }); return notes; }, notesInfo: async (noteIds) => { noteInfoRequests.push([...noteIds]); return noteInfoQueue.shift() ?? []; }, extractFields: (fields) => Object.fromEntries( Object.entries(fields).map(([key, value]) => [key.toLowerCase(), value.value || '']), ), findDuplicateNote: async (expression, excludeNoteId) => { duplicateRequests.push({ expression, excludeNoteId }); return options.duplicateNoteId ?? 99; }, getTrackedDuplicateNoteIds: () => options.trackedDuplicateNoteIds ?? null, hasAllConfiguredFields: () => options.hasAllConfiguredFields ?? true, processNewCard: async (noteId, processOptions) => { processCalls.push({ noteId, options: processOptions }); options.onProcessNewCard?.(noteId, processOptions); }, getSentenceCardImageFieldName: () => options.currentSentenceImageField, resolveFieldName: (availableFieldNames, preferredName) => availableFieldNames.find( (name) => name === preferredName || name.toLowerCase() === preferredName.toLowerCase(), ) ?? null, computeFieldGroupingMergedFields: async () => ({}), getNoteFieldMap: (noteInfo) => Object.fromEntries( Object.entries(noteInfo.fields).map(([key, value]) => [key, value.value || '']), ), handleFieldGroupingAuto: async (originalNoteId, newNoteId, _newNoteInfo, expression) => { autoCalls.push({ originalNoteId, newNoteId, expression }); }, handleFieldGroupingManual: async (originalNoteId, newNoteId, _newNoteInfo, expression) => { manualCalls.push({ originalNoteId, newNoteId, expression }); return options.manualHandled ?? true; }, }); return { service, calls, findNotesQueries, noteInfoRequests, duplicateRequests, processCalls, autoCalls, manualCalls, }; } type SuccessfulPreview = KikuMergePreviewResponse & { ok: true; compact: { action: { keepNoteId: number; deleteNoteId: number; deleteDuplicate: boolean; }; mergedFields: Record; }; full: { result: { wouldDeleteNoteId: number | null; }; }; }; test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', async () => { const harness = createHarness({ kikuEnabled: false }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']); assert.equal(harness.findNotesQueries.length, 0); }); test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled', async () => { const harness = createHarness({ kikuFieldGrouping: 'disabled' }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']); assert.equal(harness.findNotesQueries.length, 0); }); test('triggerFieldGroupingForLastAddedCard stops when an update is already in progress', async () => { const service = new FieldGroupingService({ getConfig: () => ({ fields: { word: 'Expression' } }), getEffectiveSentenceCardConfig: () => ({ model: 'Sentence', sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, kikuFieldGrouping: 'auto', kikuDeleteDuplicateInAuto: true, }), isUpdateInProgress: () => true, withUpdateProgress: async () => { throw new Error('should not be called'); }, showOsdNotification: () => {}, findNotes: async () => [], notesInfo: async () => [], extractFields: () => ({}), findDuplicateNote: async () => null, hasAllConfiguredFields: () => true, processNewCard: async () => {}, getSentenceCardImageFieldName: () => undefined, resolveFieldName: () => null, computeFieldGroupingMergedFields: async () => ({}), getNoteFieldMap: () => ({}), handleFieldGroupingAuto: async () => {}, handleFieldGroupingManual: async () => true, }); await service.triggerFieldGroupingForLastAddedCard(); }); test('triggerFieldGroupingForLastAddedCard finds the newest note and hands off to auto grouping', async () => { const harness = createHarness({ deck: 'Anime Deck', noteIds: [3, 7, 5], notesInfo: [ [ { noteId: 7, fields: { Expression: { value: 'word-7' }, Sentence: { value: 'line-7' }, }, }, ], [ { noteId: 7, fields: { Expression: { value: 'word-7' }, Sentence: { value: 'line-7' }, }, }, ], ], duplicateNoteId: 42, hasAllConfiguredFields: true, }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(harness.findNotesQueries, [ { query: '"deck:Anime Deck" added:1', maxRetries: undefined }, ]); assert.deepEqual(harness.noteInfoRequests, [[7], [7]]); assert.deepEqual(harness.duplicateRequests, [{ expression: 'word-7', excludeNoteId: 7 }]); assert.deepEqual(harness.autoCalls, [ { originalNoteId: 42, newNoteId: 7, expression: 'word-7', }, ]); }); test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids before duplicate lookup', async () => { const harness = createHarness({ noteIds: [7], notesInfo: [ [ { noteId: 7, fields: { Expression: { value: 'word-7' }, Sentence: { value: 'line-7' }, }, }, ], [ { noteId: 7, fields: { Expression: { value: 'word-7' }, Sentence: { value: 'line-7' }, }, }, ], ], trackedDuplicateNoteIds: [12, 40, 25], duplicateNoteId: 99, hasAllConfiguredFields: true, }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(harness.duplicateRequests, []); assert.deepEqual(harness.autoCalls, [ { originalNoteId: 40, newNoteId: 7, expression: 'word-7', }, ]); }); test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => { const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = []; const harness = createHarness({ noteIds: [11], notesInfo: [ [ { noteId: 11, fields: { Expression: { value: 'word-11' }, Sentence: { value: 'line-11' }, }, }, ], [ { noteId: 11, fields: { Expression: { value: 'word-11' }, Sentence: { value: 'line-11' }, }, }, ], ], duplicateNoteId: 13, hasAllConfiguredFields: false, onProcessNewCard: (noteId, options) => { processCalls.push({ noteId, options }); }, }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]); assert.deepEqual(harness.manualCalls, []); }); test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => { const harness = createHarness({ kikuFieldGrouping: 'manual', noteIds: [9], notesInfo: [ [ { noteId: 9, fields: { Expression: { value: 'word-9' }, Sentence: { value: 'line-9' }, }, }, ], [ { noteId: 9, fields: { Expression: { value: 'word-9' }, Sentence: { value: 'line-9' }, }, }, ], ], duplicateNoteId: 77, manualHandled: false, }); await harness.service.triggerFieldGroupingForLastAddedCard(); assert.deepEqual(harness.manualCalls, [ { originalNoteId: 77, newNoteId: 9, expression: 'word-9', }, ]); assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled'); }); test('buildFieldGroupingPreview returns merged compact and full previews', async () => { const service = new FieldGroupingService({ getConfig: () => ({ fields: { word: 'Expression' } }), getEffectiveSentenceCardConfig: () => ({ model: 'Sentence', sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, kikuFieldGrouping: 'auto', kikuDeleteDuplicateInAuto: true, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), showOsdNotification: () => {}, findNotes: async () => [], notesInfo: async (noteIds) => noteIds.map((noteId) => ({ noteId, fields: { Sentence: { value: `sentence-${noteId}` }, SentenceAudio: { value: `[sound:${noteId}.mp3]` }, Picture: { value: `` }, MiscInfo: { value: `misc-${noteId}` }, }, })), extractFields: () => ({}), findDuplicateNote: async () => null, hasAllConfiguredFields: () => true, processNewCard: async () => {}, getSentenceCardImageFieldName: () => undefined, resolveFieldName: (availableFieldNames, preferredName) => availableFieldNames.find( (name) => name === preferredName || name.toLowerCase() === preferredName.toLowerCase(), ) ?? null, computeFieldGroupingMergedFields: async () => ({ Sentence: 'merged sentence', SentenceAudio: 'merged audio', Picture: 'merged picture', MiscInfo: 'merged misc', }), getNoteFieldMap: (noteInfo) => Object.fromEntries( Object.entries(noteInfo.fields).map(([key, value]) => [key, value.value || '']), ), handleFieldGroupingAuto: async () => {}, handleFieldGroupingManual: async () => true, }); const preview = await service.buildFieldGroupingPreview(1, 2, true); assert.equal(preview.ok, true); if (!preview.ok) { throw new Error(preview.error); } const successPreview = preview as SuccessfulPreview; assert.deepEqual(successPreview.compact.action, { keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, }); assert.equal(successPreview.compact.mergedFields.Sentence, 'merged sentence'); assert.equal(successPreview.full.result.wouldDeleteNoteId, 2); }); test('buildFieldGroupingPreview reports missing notes cleanly', async () => { const service = new FieldGroupingService({ getConfig: () => ({ fields: { word: 'Expression' } }), getEffectiveSentenceCardConfig: () => ({ model: 'Sentence', sentenceField: 'Sentence', audioField: 'SentenceAudio', lapisEnabled: false, kikuEnabled: true, kikuFieldGrouping: 'auto', kikuDeleteDuplicateInAuto: true, }), isUpdateInProgress: () => false, withUpdateProgress: async (_message, action) => action(), showOsdNotification: () => {}, findNotes: async () => [], notesInfo: async () => [ { noteId: 1, fields: { Sentence: { value: 'sentence-1' }, }, }, ], extractFields: () => ({}), findDuplicateNote: async () => null, hasAllConfiguredFields: () => true, processNewCard: async () => {}, getSentenceCardImageFieldName: () => undefined, resolveFieldName: () => null, computeFieldGroupingMergedFields: async () => ({}), getNoteFieldMap: () => ({}), handleFieldGroupingAuto: async () => {}, handleFieldGroupingManual: async () => true, }); const preview = await service.buildFieldGroupingPreview(1, 2, false); assert.equal(preview.ok, false); if (preview.ok) { throw new Error('expected preview to fail'); } assert.equal(preview.error, 'Could not load selected notes'); });