mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
265
src/anki-integration/duplicate.test.ts
Normal file
265
src/anki-integration/duplicate.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findDuplicateNote, type NoteInfo } from './duplicate';
|
||||
|
||||
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
const exact = names.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
const lower = preferredName.toLowerCase();
|
||||
return names.find((name) => name.toLowerCase() === lower) ?? null;
|
||||
}
|
||||
|
||||
test('findDuplicateNote matches duplicate when candidate uses alternate word/expression field name', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '食べる' },
|
||||
},
|
||||
};
|
||||
|
||||
const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
|
||||
findNotes: async () => [100, 200],
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Word: { value: '食べる' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
});
|
||||
|
||||
test('findDuplicateNote falls back to alias field query when primary field query returns no candidates', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '食べる' },
|
||||
},
|
||||
};
|
||||
|
||||
const seenQueries: string[] = [];
|
||||
const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
|
||||
findNotes: async (query) => {
|
||||
seenQueries.push(query);
|
||||
if (query.includes('"Expression:')) {
|
||||
return [];
|
||||
}
|
||||
if (query.includes('"word:') || query.includes('"Word:') || query.includes('"expression:')) {
|
||||
return [200];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Word: { value: '食べる' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
assert.equal(seenQueries.length, 2);
|
||||
});
|
||||
|
||||
test('findDuplicateNote checks both source expression/word values when both fields are present', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '昨日は雨だった。' },
|
||||
Word: { value: '雨' },
|
||||
},
|
||||
};
|
||||
|
||||
const seenQueries: string[] = [];
|
||||
const duplicateId = await findDuplicateNote('昨日は雨だった。', 100, currentNote, {
|
||||
findNotes: async (query) => {
|
||||
seenQueries.push(query);
|
||||
if (query.includes('昨日は雨だった。')) {
|
||||
return [];
|
||||
}
|
||||
if (query.includes('"Word:雨"') || query.includes('"word:雨"') || query.includes('"Expression:雨"')) {
|
||||
return [200];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Word: { value: '雨' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
assert.ok(seenQueries.some((query) => query.includes('昨日は雨だった。')));
|
||||
assert.ok(seenQueries.some((query) => query.includes('雨')));
|
||||
});
|
||||
|
||||
test('findDuplicateNote falls back to collection-wide query when deck-scoped query has no matches', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
const seenQueries: string[] = [];
|
||||
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async (query) => {
|
||||
seenQueries.push(query);
|
||||
if (query.includes('deck:Japanese')) {
|
||||
return [];
|
||||
}
|
||||
if (query.includes('"Expression:貴様"') || query.includes('"Word:貴様"')) {
|
||||
return [200];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
assert.ok(seenQueries.some((query) => query.includes('deck:Japanese')));
|
||||
assert.ok(seenQueries.some((query) => !query.includes('deck:Japanese')));
|
||||
});
|
||||
|
||||
test('findDuplicateNote falls back to plain text query when field queries miss', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
const seenQueries: string[] = [];
|
||||
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async (query) => {
|
||||
seenQueries.push(query);
|
||||
if (query.includes('Expression:') || query.includes('Word:')) {
|
||||
return [];
|
||||
}
|
||||
if (query.includes('"貴様"')) {
|
||||
return [200];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
assert.ok(seenQueries.some((query) => query.includes('Expression:')));
|
||||
assert.ok(seenQueries.some((query) => query.endsWith('"貴様"')));
|
||||
});
|
||||
|
||||
test('findDuplicateNote exact compare tolerates furigana bracket markup in candidate field', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Expression: { value: '貴様[きさま]' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
});
|
||||
|
||||
test('findDuplicateNote exact compare tolerates html wrappers in candidate field', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 200,
|
||||
fields: {
|
||||
Expression: { value: '<span data-x="1">貴様</span>' },
|
||||
},
|
||||
},
|
||||
],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
});
|
||||
|
||||
test('findDuplicateNote does not disable retries on findNotes calls', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
const seenOptions: Array<{ maxRetries?: number } | undefined> = [];
|
||||
await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async (_query, options) => {
|
||||
seenOptions.push(options);
|
||||
return [];
|
||||
},
|
||||
notesInfo: async () => [],
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.ok(seenOptions.length > 0);
|
||||
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
|
||||
});
|
||||
Reference in New Issue
Block a user