mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-04 06:12:06 -07:00
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
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;
|
|
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<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 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: `<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');
|
|
});
|