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 8a8a700ccb
commit a39ca2cbac
2 changed files with 41 additions and 5 deletions
@@ -20,6 +20,7 @@ function createMaturityHarness(config: AnkiConnectConfig): {
findNotesResult: number[];
notesInfoResult: HarnessNoteInfo[];
findNotesByQuery: Map<string, number[]>;
failedQueries: Set<string>;
};
createSiblingManager: () => KnownWordCacheManager;
cleanup: () => void;
@@ -31,12 +32,16 @@ function createMaturityHarness(config: AnkiConnectConfig): {
findNotesResult: [] as number[],
notesInfoResult: [] as HarnessNoteInfo[],
findNotesByQuery: new Map<string, number[]>(),
failedQueries: new Set<string>(),
};
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) ?? [];
}
@@ -220,7 +225,10 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
assert.equal(manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }), null);
assert.equal(
manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
null,
);
} finally {
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 () => {
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
createMaturityHarness(maturityConfig());
+9 -4
View File
@@ -389,13 +389,18 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true;
try {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const tierSets = this.isMaturityTrackingEnabled()
? await fetchKnownWordMaturityTierSets(
let tierSets = null;
if (this.isMaturityTrackingEnabled()) {
try {
tierSets = await fetchKnownWordMaturityTierSets(
(query, options) => this.deps.client.findNotes(query, options),
this.getKnownWordQueryScopes().map((scope) => scope.query),
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);
if (this.noteEntriesById.size === 0) {