feat(stats): add v1 immersion stats dashboard (#19)

This commit is contained in:
2026-03-20 02:43:28 -07:00
committed by GitHub
parent 42abdd1268
commit 6749ff843c
555 changed files with 46356 additions and 2553 deletions

View File

@@ -0,0 +1,535 @@
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';
import { KnownWordCacheManager } from './known-word-cache';
async function waitForCondition(
condition: () => boolean,
timeoutMs = 500,
intervalMs = 10,
): Promise<void> {
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<string, { value: string }> }>;
findNotesByQuery: Map<string, number[]>;
};
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<string, { value: string }> }>,
findNotesByQuery: new Map<string, number[]>(),
};
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);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
manager.startLifecycle();
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
} finally {
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);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now() - 61_000,
scope: '{"refreshMinutes":1,"scope":"is:note","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 {
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":"is:note","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;
words: string[];
notes?: Record<string, string[]>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
});
} 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<string, { value: string }>,
},
{
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<void>((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;
words: string[];
};
assert.equal(
persisted.scope,
'{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
);
assert.deepEqual(persisted.words, ['猫']);
} 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 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();
}
});