Add backlog tasks and launcher time helper tests

- Track follow-up cleanup work in Backlog.md
- Replace Date.now usage with shared nowMs helper
- Add launcher args/parser and core regression tests
This commit is contained in:
2026-03-27 02:01:36 -07:00
parent a3ddfa0641
commit 854179b9c1
32 changed files with 2357 additions and 152 deletions

View File

@@ -0,0 +1,201 @@
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<AnkiConnectConfig>;
currentSubtitleText?: string;
generatedMedia?: {
audioField?: string;
audioValue?: string;
imageField?: string;
imageValue?: string;
miscInfoValue?: string;
};
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
} = {},
) {
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',
}),
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<string, string>): FieldGroupingMergeNoteInfo {
return {
noteId,
fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, { value }])),
};
}
test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => {
const { collaborator } = createCollaborator({
config: {
fields: {
image: 'Illustration',
sentence: 'SentenceText',
audio: 'ExpressionAudio',
miscInfo: 'ExtraInfo',
},
},
});
assert.deepEqual(collaborator.getGroupableFieldNames(), [
'Sentence',
'SentenceAudio',
'Picture',
'Illustration',
'SentenceText',
'ExtraInfo',
'SentenceFurigana',
]);
});
test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => {
const { collaborator } = createCollaborator({
config: {
fields: {
audio: 'CustomAudio',
},
},
});
const merged = await collaborator.computeFieldGroupingMergedFields(
1,
2,
makeNote(1, {
SentenceAudio: '[sound:keep.mp3]',
CustomAudio: '[sound:stale.mp3]',
}),
makeNote(2, {
SentenceAudio: '[sound:new.mp3]',
}),
false,
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
);
assert.equal(merged.CustomAudio, merged.SentenceAudio);
});
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: '<span data-group-id="abc">keep sentence</span>',
SentenceAudio: '',
}),
makeNote(4, {
Sentence: 'source sentence',
SentenceAudio: '[sound:source.mp3]',
}),
false,
);
assert.equal(
merged.Sentence,
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>',
);
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
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: '<img src="generated.png">',
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, '<span data-group-id="11">[sound:generated.mp3]</span>');
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});

View File

@@ -0,0 +1,411 @@
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<string, { value: string }>;
};
function createHarness(
options: {
kikuEnabled?: boolean;
kikuFieldGrouping?: 'auto' | 'manual' | 'disabled';
deck?: string;
noteIds?: number[];
notesInfo?: NoteInfo[][];
duplicateNoteId?: 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;
},
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<string, string>;
};
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 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: `<img src="${noteId}.png">` },
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');
});