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 { setLogLevel } from '../logger'; import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache'; interface HarnessNoteInfo { noteId: number; fields: Record; } function createMaturityHarness(config: AnkiConnectConfig): { manager: KnownWordCacheManager; calls: { findNotes: number; notesInfo: number; queries: string[] }; statePath: string; clientState: { findNotesResult: number[]; notesInfoResult: HarnessNoteInfo[]; findNotesByQuery: Map; failedQueries: Set; }; createSiblingManager: () => KnownWordCacheManager; cleanup: () => void; } { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-')); const statePath = path.join(stateDir, 'known-words-cache.json'); const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] }; const clientState = { findNotesResult: [] as number[], notesInfoResult: [] as HarnessNoteInfo[], findNotesByQuery: new Map(), failedQueries: new Set(), }; const deps = { client: { findNotes: async (query: string) => { calls.findNotes += 1; calls.queries.push(query); if (clientState.failedQueries.has(query)) { throw new Error(`Anki unavailable for query: ${query}`); } if (clientState.findNotesByQuery.has(query)) { return clientState.findNotesByQuery.get(query) ?? []; } return clientState.findNotesResult; }, notesInfo: async (noteIds: number[]) => { calls.notesInfo += 1; return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId)); }, }, getConfig: () => config, knownWordCacheStatePath: statePath, showStatusNotification: () => undefined, }; return { manager: new KnownWordCacheManager(deps), calls, statePath, clientState, createSiblingManager: () => new KnownWordCacheManager(deps), cleanup: () => { fs.rmSync(stateDir, { recursive: true, force: true }); }, }; } // The four queries a maturity refresh issues, in one place so a query-string // change lands in a single spot. function setTierQueries( clientState: { findNotesByQuery: Map }, tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] }, ): void { clientState.findNotesByQuery.set('deck:"Mining"', tiers.all); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young); clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning); } function maturityConfig(overrides: Partial = {}): AnkiConnectConfig { return { deck: 'Mining', fields: { word: 'Word' }, knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60, }, ...overrides, }; } test('lifecycle config key is unchanged when maturity is disabled', () => { const disabled: AnkiConnectConfig = { knownWords: { highlightEnabled: true, refreshMinutes: 60 }, }; // Upgrading users keep their persisted cache: the key must not gain fields // while maturity is off. assert.equal( getKnownWordCacheLifecycleConfig(disabled), '{"refreshMinutes":60,"scope":"all","fieldsWord":""}', ); const enabled: AnkiConnectConfig = { knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 }, }; assert.equal( getKnownWordCacheLifecycleConfig(enabled), '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}', ); const customThreshold: AnkiConnectConfig = { knownWords: { highlightEnabled: true, maturityEnabled: true, matureThresholdDays: 30, refreshMinutes: 60, }, }; assert.equal( getKnownWordCacheLifecycleConfig(customThreshold), '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}', ); }); test('a cache built under the old tier rules is invalidated', () => { const config: AnkiConnectConfig = { knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 }, }; // v1 rules put lapsed cards in young because the interval queries did not // exclude is:learn; those persisted tiers must not be served under v2. assert.notEqual( getKnownWordCacheLifecycleConfig(config), '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', ); }); test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => { const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 2, fields: { Word: { value: '犬' } } }, { noteId: 3, fields: { Word: { value: '鳥' } } }, { noteId: 4, fields: { Word: { value: '魚' } } }, ]; await manager.refresh(true); assert.equal(calls.findNotes, 4); assert.equal(manager.getKnownWordTier('猫'), 'mature'); assert.equal(manager.getKnownWordTier('犬'), 'young'); assert.equal(manager.getKnownWordTier('鳥'), 'learning'); assert.equal(manager.getKnownWordTier('魚'), 'new'); assert.equal(manager.getKnownWordTier('馬'), null); // Boolean matching still works alongside tiers. assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.isKnownWord('魚'), true); } finally { cleanup(); } }); test('a note with cards in several tiers counts as its most mature card', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] }); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; await manager.refresh(true); assert.equal(manager.getKnownWordTier('猫'), 'mature'); } finally { cleanup(); } }); test('a word matched by several notes takes the most mature note tier', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 2, fields: { Word: { value: '猫' } } }, ]; await manager.refresh(true); assert.equal(manager.getKnownWordTier('猫'), 'young'); } finally { cleanup(); } }); test('tiers are reading-aware for words with several readings', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } }, { noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } }, ]; await manager.refresh(true); assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature'); assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning'); // No reading given: fail-open across readings, most mature wins. assert.equal(manager.getKnownWordTier('床'), 'mature'); // Unknown reading for a reading-locked word: no match, no tier. assert.equal(manager.getKnownWordTier('床', 'しょう'), null); } finally { cleanup(); } }); test('reading-only fallback resolves tiers unless opted out', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } }, ]; await manager.refresh(true); assert.equal(manager.getKnownWordTier('けいこく'), 'mature'); assert.equal( manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }), null, ); } finally { cleanup(); } }); test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => { const config = maturityConfig(); config.knownWords = { ...config.knownWords, maturityEnabled: false }; const { manager, calls, clientState, cleanup } = createMaturityHarness(config); try { clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; await manager.refresh(true); assert.equal(calls.findNotes, 1); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.getKnownWordTier('猫'), null); } finally { cleanup(); } }); test('refresh preserves known-word cache when maturity lookup fails', async () => { const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig()); const originalInfo = console.info; const infoLogs: string[] = []; setLogLevel('info'); try { console.info = (...args: unknown[]) => { infoLogs.push(args.map((value) => String(value)).join(' ')); }; clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn'); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; await manager.refresh(true); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.getKnownWordTier('猫'), null); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { version: number; tiers: Record; }; assert.equal(persisted.version, 4); assert.deepEqual(persisted.tiers, {}); assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/); } finally { console.info = originalInfo; setLogLevel(undefined); cleanup(); } }); test('tiers persist to v4 state and reload without refetching', async () => { const { manager, calls, statePath, clientState, createSiblingManager, cleanup } = createMaturityHarness(maturityConfig()); const originalDateNow = Date.now; try { Date.now = () => 120_000; setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 2, fields: { Word: { value: '犬' } } }, ]; await manager.refresh(true); const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { version: number; tiers: Record; }; assert.equal(persisted.version, 4); assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' }); const callsBeforeReload = calls.findNotes; const reloaded = createSiblingManager(); reloaded.startLifecycle(); try { assert.equal(reloaded.getKnownWordTier('猫'), 'mature'); assert.equal(reloaded.getKnownWordTier('犬'), 'learning'); assert.equal(calls.findNotes, callsBeforeReload); } finally { reloaded.stopLifecycle(); } } finally { Date.now = originalDateNow; cleanup(); } }); test('appendFromNoteInfo marks freshly mined notes as new tier', async () => { const { manager, cleanup } = createMaturityHarness(maturityConfig()); try { manager.appendFromNoteInfo({ noteId: 7, fields: { Word: { value: '猫' } }, }); assert.equal(manager.isKnownWord('猫'), true); assert.equal(manager.getKnownWordTier('猫'), 'new'); } finally { cleanup(); } }); test('appendFromNoteInfo preserves an existing maturity tier', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] }); clientState.notesInfoResult = [ { noteId: 7, fields: { Word: { value: '猫' } } }, { noteId: 8, fields: { Word: { value: '犬' } } }, ]; await manager.refresh(true); manager.appendFromNoteInfo({ noteId: 7, fields: { Word: { value: '子猫' } }, }); manager.appendFromNoteInfo({ noteId: 8, fields: { Word: { value: '子犬' } }, }); assert.equal(manager.getKnownWordTier('子猫'), 'mature'); assert.equal(manager.getKnownWordTier('子犬'), 'learning'); } finally { cleanup(); } }); test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => { const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); try { setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] }); clientState.notesInfoResult = [ { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } }, { noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } }, { noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } }, ]; await manager.refresh(true); // Same matching rules as getKnownWordTier, so an audit can re-derive the // rendered tier from the exact notes that produced it. assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]); assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]); assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]); assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []); assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]); assert.deepEqual( [ ...manager.getKnownWordMatchNoteIds('けいこく', undefined, { allowReadingOnlyMatch: false, }), ], [], ); assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []); } finally { cleanup(); } });