import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { AnkiConnectConfig } from '../types/anki'; import { KnownWordCacheManager } from './known-word-cache'; async function waitForCondition( condition: () => boolean, timeoutMs = 500, intervalMs = 10, ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (condition()) { return; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error('Timed out waiting for condition'); } function createKnownWordCacheHarness(config: AnkiConnectConfig): { manager: KnownWordCacheManager; calls: { findNotes: number; notesInfo: number; }; statePath: string; clientState: { findNotesResult: number[]; notesInfoResult: Array<{ noteId: number; fields: Record }>; findNotesByQuery: Map; }; cleanup: () => void; } { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-')); const statePath = path.join(stateDir, 'known-words-cache.json'); const calls = { findNotes: 0, notesInfo: 0, }; const clientState = { findNotesResult: [] as number[], notesInfoResult: [] as Array<{ noteId: number; fields: Record }>, findNotesByQuery: new Map(), }; const manager = new KnownWordCacheManager({ client: { findNotes: async (query) => { calls.findNotes += 1; if (clientState.findNotesByQuery.has(query)) { return clientState.findNotesByQuery.get(query) ?? []; } return clientState.findNotesResult; }, notesInfo: async (noteIds) => { calls.notesInfo += 1; return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId)); }, }, getConfig: () => config, knownWordCacheStatePath: statePath, showStatusNotification: () => undefined, }); return { manager, calls, statePath, clientState, cleanup: () => { fs.rmSync(stateDir, { recursive: true, force: true }); }, }; } test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without immediate refresh', async () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, refreshMinutes: 60, }, }; const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config); const originalDateNow = Date.now; try { Date.now = () => 120_000; fs.writeFileSync( statePath, JSON.stringify({ version: 2, refreshedAtMs: 120_000, scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}', words: ['猫'], notes: { '1': ['猫'], }, }), 'utf-8', ); manager.startLifecycle(); assert.equal(manager.isKnownWord('猫'), true); assert.equal(calls.findNotes, 0); assert.equal(calls.notesInfo, 0); // v2 states carry no readings, so they load usable but stale to trigger a // prompt upgrade refresh. assert.equal( ( manager as unknown as { getMsUntilNextRefresh: () => number; } ).getMsUntilNextRefresh(), 0, ); } finally { Date.now = originalDateNow; manager.stopLifecycle(); cleanup(); } }); test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, refreshMinutes: 60, }, }; const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config); const originalDateNow = Date.now; try { Date.now = () => 120_000; fs.writeFileSync( statePath, JSON.stringify({ version: 3, refreshedAtMs: 120_000, scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}', notes: { '1': [{ word: '猫', reading: 'ねこ' }], }, }), 'utf-8', ); manager.startLifecycle(); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('猫', 'ねこ'), true); assert.equal(manager.isKnownWord('猫', 'びょう'), false); assert.equal(calls.findNotes, 0); assert.equal(calls.notesInfo, 0); assert.equal( ( manager as unknown as { getMsUntilNextRefresh: () => number; } ).getMsUntilNextRefresh() > 0, true, ); } finally { Date.now = originalDateNow; manager.stopLifecycle(); cleanup(); } }); test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted cache', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, refreshMinutes: 1, }, }; const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config); const originalDateNow = Date.now; try { Date.now = () => 120_000; fs.writeFileSync( statePath, JSON.stringify({ version: 2, refreshedAtMs: 59_000, scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}', words: ['猫'], notes: { '1': ['猫'], }, }), 'utf-8', ); clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '犬' }, }, }, ]; manager.startLifecycle(); await waitForCondition(() => calls.findNotes === 1 && calls.notesInfo === 1); assert.equal(manager.isKnownWord('猫'), false); assert.equal(manager.isKnownWord('犬'), true); } finally { Date.now = originalDateNow; manager.stopLifecycle(); cleanup(); } }); test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => { const config: AnkiConnectConfig = { deck: 'Mining', fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, cleanup } = createKnownWordCacheHarness(config); try { manager.appendFromNoteInfo({ noteId: 1, fields: { Word: { value: '猫' }, }, }); assert.equal(manager.isKnownWord('猫'), true); config.fields = { ...config.fields, word: 'Expression', }; ( manager as unknown as { loadKnownWordCacheState: () => void; } ).loadKnownWordCacheState(); assert.equal(manager.isKnownWord('猫'), false); } finally { cleanup(); } }); test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config); try { fs.writeFileSync( statePath, JSON.stringify({ version: 2, refreshedAtMs: 1, scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}', words: ['猫', '犬'], notes: { '1': ['猫'], '2': ['犬'], }, }), 'utf-8', ); ( manager as unknown as { loadKnownWordCacheState: () => void; } ).loadKnownWordCacheState(); clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '鳥' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), false); assert.equal(manager.isKnownWord('犬'), false); assert.equal(manager.isKnownWord('鳥'), true); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { version: number; notes?: Record>; }; assert.equal(persisted.version, 3); assert.deepEqual(persisted.notes, { '1': [{ word: '鳥', reading: null }], }); } finally { cleanup(); } }); test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesByQuery.set('', [1]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), true); } finally { cleanup(); } }); test('KnownWordCacheManager skips malformed note info without fields', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1, 2]; clientState.notesInfoResult = [ { noteId: 1, fields: undefined as unknown as Record, }, { noteId: 2, fields: { Word: { value: '猫' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('犬'), false); } finally { cleanup(); } }); test('KnownWordCacheManager preserves cache state key captured before refresh work', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, refreshMinutes: 1, }, }; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-key-')); const statePath = path.join(stateDir, 'known-words-cache.json'); let notesInfoStarted = false; let releaseNotesInfo!: () => void; const notesInfoGate = new Promise((resolve) => { releaseNotesInfo = resolve; }); const manager = new KnownWordCacheManager({ client: { findNotes: async () => [1], notesInfo: async () => { notesInfoStarted = true; await notesInfoGate; return [ { noteId: 1, fields: { Word: { value: '猫' }, }, }, ]; }, }, getConfig: () => config, knownWordCacheStatePath: statePath, showStatusNotification: () => undefined, }); try { const refreshPromise = manager.refresh(true); await waitForCondition(() => notesInfoStarted); config.fields = { ...config.fields, word: 'Expression', }; releaseNotesInfo(); await refreshPromise; const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { scope: string; notes: Record>; }; assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}'); assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] }); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); } }); test('KnownWordCacheManager does not borrow fields from other decks during refresh', async () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, decks: { Mining: [], Reading: ['AltWord'], }, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.findNotesByQuery.set('deck:"Reading"', []); clientState.notesInfoResult = [ { noteId: 1, fields: { AltWord: { value: '猫' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), false); } finally { cleanup(); } }); test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, decks: { Mining: ['Word'], }, }, }; const { manager, cleanup } = createKnownWordCacheHarness(config); try { manager.appendFromNoteInfo({ noteId: 1, fields: { Word: { value: '猫' }, }, }); assert.equal(manager.isKnownWord('猫'), true); config.knownWords = { ...config.knownWords, decks: { Mining: ['Expression'], }, }; ( manager as unknown as { loadKnownWordCacheState: () => void; } ).loadKnownWordCacheState(); assert.equal(manager.isKnownWord('猫'), false); } finally { cleanup(); } }); test('KnownWordCacheManager preserves deck-specific field mappings during refresh', async () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, decks: { Mining: ['Expression'], Reading: ['Word'], }, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.findNotesByQuery.set('deck:"Reading"', [2]); clientState.notesInfoResult = [ { noteId: 1, fields: { Expression: { value: '猫' }, Word: { value: 'should-not-count' }, }, }, { noteId: 2, fields: { Word: { value: '犬' }, Expression: { value: 'also-ignored' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('犬'), true); assert.equal(manager.isKnownWord('should-not-count'), false); assert.equal(manager.isKnownWord('also-ignored'), false); } finally { cleanup(); } }); test('KnownWordCacheManager uses the current deck fields for immediate append', () => { const config: AnkiConnectConfig = { deck: 'Mining', fields: { word: 'Word', }, knownWords: { highlightEnabled: true, decks: { Mining: ['Expression'], Reading: ['Word'], }, }, }; const { manager, cleanup } = createKnownWordCacheHarness(config); try { manager.appendFromNoteInfo({ noteId: 1, fields: { Expression: { value: '猫' }, Word: { value: 'should-not-count' }, }, }); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('should-not-count'), false); } finally { cleanup(); } }); test('KnownWordCacheManager uses all configured deck fields for immediate append without a current deck', () => { const config: AnkiConnectConfig = { deck: '', fields: { word: 'Expression', }, knownWords: { highlightEnabled: true, decks: { 'Kaishi 1.5k': ['Word'], Minecraft: ['Expression', 'Word'], }, }, }; const { manager, cleanup } = createKnownWordCacheHarness(config); try { manager.appendFromNoteInfo({ noteId: 1, fields: { Expression: { value: '別人' }, }, }); assert.equal(manager.isKnownWord('別人'), true); } finally { cleanup(); } }); test('KnownWordCacheManager reports immediate append cache clears as mutations', () => { const config: AnkiConnectConfig = { fields: { word: 'Expression', }, knownWords: { highlightEnabled: true, refreshMinutes: 60, }, }; const { manager, statePath, cleanup } = createKnownWordCacheHarness(config); try { fs.writeFileSync( statePath, JSON.stringify({ version: 2, refreshedAtMs: Date.now(), scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}', words: ['猫'], notes: { '1': ['猫'], }, }), 'utf-8', ); manager.startLifecycle(); assert.equal(manager.isKnownWord('猫'), true); config.fields = { word: 'Word' }; const changed = manager.appendFromNoteInfo({ noteId: 2, fields: { Word: { value: '' }, }, }); assert.equal(changed, true); assert.equal(manager.isKnownWord('猫'), false); } finally { manager.stopLifecycle(); cleanup(); } }); test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, addMinedWordsImmediately: false, }, }; const { manager, statePath, cleanup } = createKnownWordCacheHarness(config); try { manager.appendFromNoteInfo({ noteId: 1, fields: { Expression: { value: '猫' }, }, }); assert.equal(manager.isKnownWord('猫'), false); assert.equal(fs.existsSync(statePath), false); } finally { cleanup(); } }); test('KnownWordCacheManager disambiguates known words by note reading', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, 'Word Reading': { value: 'ゆか' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('床'), true); assert.equal(manager.isKnownWord('床', 'ゆか'), true); assert.equal(manager.isKnownWord('床', 'ユカ'), true); // Same spelling, different word (床/とこ "bed") must not match. assert.equal(manager.isKnownWord('床', 'とこ'), false); // Note readings stay matchable as kana words. assert.equal(manager.isKnownWord('ゆか'), true); assert.equal(manager.isKnownWord('とこ'), false); } finally { cleanup(); } }); test('KnownWordCacheManager suppresses reading-only matches when disallowed', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '警告' }, 'Word Reading': { value: 'けいこく' }, }, }, ]; await manager.refresh(true); // Reading-only match stays available for kana subtitle text… assert.equal(manager.isKnownWord('けいこく'), true); // …but a kanji token's reading (渓谷/けいこく) must not borrow 警告's. assert.equal( manager.isKnownWord('けいこく', undefined, { allowReadingOnlyMatch: false }), false, ); // Mined word texts still match regardless of the flag. assert.equal(manager.isKnownWord('警告', undefined, { allowReadingOnlyMatch: false }), true); } finally { cleanup(); } }); test('KnownWordCacheManager does not match single-kana text by reading alone', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1, 2]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '夜' }, 'Word Reading': { value: 'よ' }, }, }, { noteId: 2, fields: { Word: { value: 'え' }, }, }, ]; await manager.refresh(true); // よ must not count as known just because 夜 is read よ. assert.equal(manager.isKnownWord('よ'), false); assert.equal(manager.isKnownWord('ヨ'), false); assert.equal(manager.isKnownWord('夜'), true); assert.equal(manager.isKnownWord('夜', 'よ'), true); // A literal single-kana word entry still matches via the word map. assert.equal(manager.isKnownWord('え'), true); } finally { cleanup(); } }); test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => { const config: AnkiConnectConfig = { fields: { word: 'Expression', }, knownWords: { highlightEnabled: true, decks: { 'Kaishi 1.5k': ['Word'], }, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, 'Word Reading': { value: 'ゆか' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('床', 'ゆか'), true); assert.equal(manager.isKnownWord('床', 'とこ'), false); } finally { cleanup(); } }); test('KnownWordCacheManager matches words without readings in any reading', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('床'), true); assert.equal(manager.isKnownWord('床', 'とこ'), true); } finally { cleanup(); } }); test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: 'お 決[き]まり' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('お決まり'), true); assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true); assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false); } finally { cleanup(); } }); test('KnownWordCacheManager treats non-kana reading fields as words', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Reading: { value: '漢字' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('漢字'), true); } finally { cleanup(); } }); test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => { const config: AnkiConnectConfig = { fields: { word: 'Word', }, knownWords: { highlightEnabled: true, }, }; const { manager, clientState, cleanup } = createKnownWordCacheHarness(config); try { clientState.findNotesResult = [1]; clientState.notesInfoResult = [ { noteId: 1, fields: { Reading: { value: 'たべる' }, }, }, ]; await manager.refresh(true); assert.equal(manager.isKnownWord('たべる'), true); assert.equal(manager.isKnownWord('タベル'), true); } finally { cleanup(); } });