import assert from 'node:assert/strict'; import test from 'node:test'; import { Window } from 'happy-dom'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { apiClient } from '../lib/api-client'; import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords'; import { useVocabulary } from './useVocabulary'; import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats'; type VocabularyState = ReturnType; function installDom(): () => void { const previousWindow = globalThis.window; const previousDocument = globalThis.document; const previousHTMLElement = globalThis.HTMLElement; const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; const previousIsReactActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT; const window = new Window(); Object.defineProperty(globalThis, 'window', { value: window, configurable: true }); Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true }); Object.defineProperty(globalThis, 'HTMLElement', { value: window.HTMLElement, configurable: true, }); globals.IS_REACT_ACT_ENVIRONMENT = true; return () => { Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true }); Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true }); Object.defineProperty(globalThis, 'HTMLElement', { value: previousHTMLElement, configurable: true, }); globals.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment; }; } function installLocalStorage(): () => void { const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); const values = new Map(); Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key), }, }); return () => { if (previous) Object.defineProperty(globalThis, 'localStorage', previous); else delete (globalThis as { localStorage?: unknown }).localStorage; }; } interface FakeClock { tick: (ms: number) => void; restore: () => void; } /** Bun's `node:test` shim has no `mock.timers`, so the retry clock is faked here. */ function installFakeTimers(): FakeClock { const originalSetTimeout = globalThis.setTimeout; const originalClearTimeout = globalThis.clearTimeout; const timers = new Map void }>(); let now = 0; let nextId = 1; globalThis.setTimeout = ((fn: () => void, delay = 0) => { const id = nextId; nextId += 1; timers.set(id, { at: now + delay, fn }); return id; }) as unknown as typeof globalThis.setTimeout; globalThis.clearTimeout = ((id: number) => { timers.delete(id); }) as unknown as typeof globalThis.clearTimeout; return { tick: (ms: number) => { now += ms; const due = [...timers.entries()] .filter(([, timer]) => timer.at <= now) .sort(([, a], [, b]) => a.at - b.at); for (const [id, timer] of due) { timers.delete(id); timer.fn(); } }, restore: () => { globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; }, }; } function summaryFixture(): StatsVocabularySummary { return { uniqueWords: 42, uniqueWordsWithoutNames: 40, uniqueKanji: 7, newThisWeek: 3, newThisWeekWithoutNames: 2, knownWordCount: 10, knownWordCountWithoutNames: 9, }; } function chartsFixture(overrides: Partial = {}): StatsVocabularyCharts { return { ready: true, topWords: [{ wordId: 1, headword: '猫', frequency: 5 }], topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }], newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }], newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }], ...overrides, }; } interface Harness { state: () => VocabularyState; flush: () => Promise; tick: (ms: number) => Promise; unmount: () => Promise; teardown: () => void; } async function mountHook(): Promise { const uninstallDom = installDom(); const uninstallLocalStorage = installLocalStorage(); const clock = installFakeTimers(); let latest: VocabularyState | null = null; function Probe() { latest = useVocabulary(); return null; } const container = document.createElement('div'); document.body.append(container); let root: Root | null = createRoot(container); await act(async () => { root!.render(); }); const flush = async (): Promise => { // Drain promise callbacks without advancing the mocked clock. await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; return { state: () => { assert.ok(latest, 'expected the hook to have rendered'); return latest; }, flush, tick: async (ms: number) => { await act(async () => { clock.tick(ms); }); await flush(); }, unmount: async () => { await act(async () => { root?.unmount(); root = null; }); }, teardown: () => { if (root) root.unmount(); clock.restore(); uninstallLocalStorage(); uninstallDom(); resetExcludedWordsStoreForTests(); }, }; } function stubVocabularyClient(overrides: { getVocabularySummary: () => Promise; getVocabularyCharts: () => Promise; }): () => void { const original = { getVocabulary: apiClient.getVocabulary, getKanji: apiClient.getKanji, getKnownWords: apiClient.getKnownWords, getVocabularySummary: apiClient.getVocabularySummary, getVocabularyCharts: apiClient.getVocabularyCharts, setExcludedWords: apiClient.setExcludedWords, }; apiClient.getVocabulary = async () => []; apiClient.getKanji = async () => []; apiClient.getKnownWords = async () => []; apiClient.setExcludedWords = async () => {}; apiClient.getVocabularySummary = overrides.getVocabularySummary; apiClient.getVocabularyCharts = overrides.getVocabularyCharts; return () => Object.assign(apiClient, original); } test('aggregate failures retry with backoff, then surface an error that Retry clears', async () => { const originalConsoleError = console.error; console.error = () => {}; let summaryCalls = 0; let failSummary = true; const restoreClient = stubVocabularyClient({ getVocabularySummary: async () => { summaryCalls += 1; if (failSummary) throw new Error('summary unavailable'); return summaryFixture(); }, getVocabularyCharts: async () => chartsFixture(), }); const harness = await mountHook(); try { await harness.flush(); assert.equal(summaryCalls, 1); assert.equal(harness.state().aggregatesError, null, 'no error until retries are exhausted'); // Backoff is 1s, 2s, 4s, 8s across the remaining four attempts. for (const delayMs of [1_000, 2_000, 4_000, 8_000]) { await harness.tick(delayMs); } assert.equal(summaryCalls, 5, 'retries are bounded at the attempt limit'); assert.match(harness.state().aggregatesError ?? '', /totals failed to load/i); // Nothing further is scheduled once the limit is reached. await harness.tick(60_000); assert.equal(summaryCalls, 5); failSummary = false; await act(async () => { harness.state().refreshAggregates(); }); await harness.flush(); assert.equal(summaryCalls, 6); assert.equal(harness.state().aggregatesError, null); assert.deepEqual(harness.state().summary, summaryFixture()); } finally { harness.teardown(); restoreClient(); console.error = originalConsoleError; } }); test('charts poll while the backfill is pending and stop once it is ready', async () => { let chartCalls = 0; const restoreClient = stubVocabularyClient({ getVocabularySummary: async () => summaryFixture(), getVocabularyCharts: async () => { chartCalls += 1; return chartsFixture({ ready: chartCalls >= 3 }); }, }); const harness = await mountHook(); try { await harness.flush(); assert.equal(chartCalls, 1); assert.equal(harness.state().charts?.ready, false); await harness.tick(1_000); assert.equal(chartCalls, 2); await harness.tick(1_000); assert.equal(chartCalls, 3); assert.equal(harness.state().charts?.ready, true); // A ready result ends the poll. await harness.tick(60_000); assert.equal(chartCalls, 3); } finally { harness.teardown(); restoreClient(); } }); test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => { let summaryCalls = 0; let chartCalls = 0; const restoreClient = stubVocabularyClient({ getVocabularySummary: async () => { summaryCalls += 1; return summaryFixture(); }, getVocabularyCharts: async () => { chartCalls += 1; return chartsFixture(); }, }); const harness = await mountHook(); try { await harness.flush(); assert.equal(summaryCalls, 1); assert.equal(chartCalls, 1); await act(async () => { await setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]); }); await harness.flush(); assert.equal(summaryCalls, 2, 'totals must not keep counting the excluded word'); assert.equal(chartCalls, 2); } finally { harness.teardown(); restoreClient(); } }); test('pending retries are cancelled when the tab unmounts', async () => { const originalConsoleError = console.error; console.error = () => {}; let summaryCalls = 0; const restoreClient = stubVocabularyClient({ getVocabularySummary: async () => { summaryCalls += 1; throw new Error('summary unavailable'); }, getVocabularyCharts: async () => chartsFixture(), }); const harness = await mountHook(); try { await harness.flush(); assert.equal(summaryCalls, 1); await harness.unmount(); await harness.tick(60_000); assert.equal(summaryCalls, 1, 'no retry may run after unmount'); } finally { harness.teardown(); restoreClient(); console.error = originalConsoleError; } });