fix(anki): preserve known-word cache when maturity fetch fails

- Catch errors from fetchKnownWordMaturityTierSets so a failed tier query no longer aborts the whole refresh
- Known words still cache; tiers just stay null until the next successful refresh
- Add regression test covering findNotes failure on a tier query
This commit is contained in:
2026-07-22 23:23:07 -07:00
parent bc8dce870b
commit e0dbe5c8cd
2 changed files with 41 additions and 5 deletions
@@ -20,6 +20,7 @@ function createMaturityHarness(config: AnkiConnectConfig): {
findNotesResult: number[]; findNotesResult: number[];
notesInfoResult: HarnessNoteInfo[]; notesInfoResult: HarnessNoteInfo[];
findNotesByQuery: Map<string, number[]>; findNotesByQuery: Map<string, number[]>;
failedQueries: Set<string>;
}; };
createSiblingManager: () => KnownWordCacheManager; createSiblingManager: () => KnownWordCacheManager;
cleanup: () => void; cleanup: () => void;
@@ -31,12 +32,16 @@ function createMaturityHarness(config: AnkiConnectConfig): {
findNotesResult: [] as number[], findNotesResult: [] as number[],
notesInfoResult: [] as HarnessNoteInfo[], notesInfoResult: [] as HarnessNoteInfo[],
findNotesByQuery: new Map<string, number[]>(), findNotesByQuery: new Map<string, number[]>(),
failedQueries: new Set<string>(),
}; };
const deps = { const deps = {
client: { client: {
findNotes: async (query: string) => { findNotes: async (query: string) => {
calls.findNotes += 1; calls.findNotes += 1;
calls.queries.push(query); calls.queries.push(query);
if (clientState.failedQueries.has(query)) {
throw new Error(`Anki unavailable for query: ${query}`);
}
if (clientState.findNotesByQuery.has(query)) { if (clientState.findNotesByQuery.has(query)) {
return clientState.findNotesByQuery.get(query) ?? []; return clientState.findNotesByQuery.get(query) ?? [];
} }
@@ -220,7 +225,10 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
await manager.refresh(true); await manager.refresh(true);
assert.equal(manager.getKnownWordTier('けいこく'), 'mature'); assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
assert.equal(manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }), null); assert.equal(
manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
null,
);
} finally { } finally {
cleanup(); cleanup();
} }
@@ -245,6 +253,29 @@ test('getKnownWordTier returns null and skips tier queries when maturity is disa
} }
}); });
test('refresh preserves known-word cache when maturity lookup fails', async () => {
const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21');
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<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, {});
} finally {
cleanup();
}
});
test('tiers persist to v4 state and reload without refetching', async () => { test('tiers persist to v4 state and reload without refetching', async () => {
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } = const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
createMaturityHarness(maturityConfig()); createMaturityHarness(maturityConfig());
+9 -4
View File
@@ -389,13 +389,18 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true; this.isRefreshingKnownWords = true;
try { try {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById(); const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const tierSets = this.isMaturityTrackingEnabled() let tierSets = null;
? await fetchKnownWordMaturityTierSets( if (this.isMaturityTrackingEnabled()) {
try {
tierSets = await fetchKnownWordMaturityTierSets(
(query, options) => this.deps.client.findNotes(query, options), (query, options) => this.deps.client.findNotes(query, options),
this.getKnownWordQueryScopes().map((scope) => scope.query), this.getKnownWordQueryScopes().map((scope) => scope.query),
getMatureIntervalThresholdDays(this.deps.getConfig()), getMatureIntervalThresholdDays(this.deps.getConfig()),
) );
: null; } catch (error) {
log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message);
}
}
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b); const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteEntriesById.size === 0) { if (this.noteEntriesById.size === 0) {