mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 23:54:31 -07:00
fix(stats): report complete vocabulary totals and new-word history (#202)
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
initializeExcludedWordsStore,
|
||||
resetExcludedWordsStoreForTests,
|
||||
setExcludedWords,
|
||||
subscribeExcludedWordsServerSync,
|
||||
} from './useExcludedWords';
|
||||
import { BASE_URL } from '../lib/api-client';
|
||||
|
||||
@@ -199,3 +200,91 @@ test('initializeExcludedWordsStore retries after transient database load failure
|
||||
resetExcludedWordsStoreForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('a failing server-sync listener neither rolls back the write nor blocks other listeners', async () => {
|
||||
resetExcludedWordsStoreForTests();
|
||||
const { values: storage, restore } = installLocalStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
|
||||
const notified: string[] = [];
|
||||
const unsubscribeFirst = subscribeExcludedWordsServerSync(() => {
|
||||
notified.push('first');
|
||||
throw new Error('listener exploded');
|
||||
});
|
||||
const unsubscribeSecond = subscribeExcludedWordsServerSync(() => {
|
||||
notified.push('second');
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = [{ headword: 'する', word: 'する', reading: 'する' }];
|
||||
await assert.doesNotReject(() => setExcludedWords(rows));
|
||||
|
||||
assert.deepEqual(notified, ['first', 'second']);
|
||||
assert.deepEqual(getExcludedWordsSnapshot(), rows);
|
||||
assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows));
|
||||
} finally {
|
||||
unsubscribeFirst();
|
||||
unsubscribeSecond();
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalConsoleError;
|
||||
restore();
|
||||
resetExcludedWordsStoreForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('overlapping writes serialize so an older list cannot overwrite a newer edit', async () => {
|
||||
resetExcludedWordsStoreForTests();
|
||||
const { restore } = installLocalStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const sentBodies: string[] = [];
|
||||
let releaseFirst: (() => void) | null = null;
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
sentBodies.push(String(init?.body ?? ''));
|
||||
if (sentBodies.length === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
const syncs: string[] = [];
|
||||
const unsubscribe = subscribeExcludedWordsServerSync(() => {
|
||||
syncs.push(JSON.stringify(getExcludedWordsSnapshot()));
|
||||
});
|
||||
|
||||
try {
|
||||
const first = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
|
||||
const second = [...first, { headword: '犬', word: '犬', reading: 'いぬ' }];
|
||||
const third = [...second, { headword: '鳥', word: '鳥', reading: 'とり' }];
|
||||
|
||||
// The first write reaches the network before the later edits are made.
|
||||
const firstWrite = setExcludedWords(first);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const secondWrite = setExcludedWords(second);
|
||||
const thirdWrite = setExcludedWords(third);
|
||||
|
||||
const release = releaseFirst as (() => void) | null;
|
||||
assert.ok(release, 'expected the first write to be in flight');
|
||||
release();
|
||||
await Promise.all([firstWrite, secondWrite, thirdWrite]);
|
||||
|
||||
// The in-flight write finishes first, the superseded middle write is
|
||||
// dropped, and the newest list is the last thing the server is told.
|
||||
assert.deepEqual(sentBodies, [
|
||||
JSON.stringify({ words: first }),
|
||||
JSON.stringify({ words: third }),
|
||||
]);
|
||||
assert.deepEqual(getExcludedWordsSnapshot(), third);
|
||||
// Only the final revision notifies: the first write's acknowledgement was
|
||||
// already obsolete, so it must not trigger an aggregate recomputation.
|
||||
assert.deepEqual(syncs, [JSON.stringify(third)]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
globalThis.fetch = originalFetch;
|
||||
restore();
|
||||
resetExcludedWordsStoreForTests();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,6 +44,32 @@ let cachedKeys: Set<string> | null = null;
|
||||
let initialized: Promise<void> | null = null;
|
||||
let revision = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
// Fires only after the stats server acknowledged an exclusion write, so
|
||||
// subscribers can refetch server-computed aggregates without racing the POST.
|
||||
const serverSyncListeners = new Set<() => void>();
|
||||
|
||||
export function subscribeExcludedWordsServerSync(fn: () => void): () => void {
|
||||
serverSyncListeners.add(fn);
|
||||
return () => {
|
||||
serverSyncListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyServerSync(): void {
|
||||
// Listener failures are their own concern: one must not roll back a write
|
||||
// that already succeeded, nor stop the remaining listeners from running.
|
||||
for (const fn of serverSyncListeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
console.error('Excluded words server-sync listener failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-list writes are serialized so a slow earlier request cannot land after a
|
||||
// newer one and overwrite it with a stale list.
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
function readLocalStorage(): ExcludedWord[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
@@ -102,16 +128,27 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
|
||||
const normalized = dedupeExcludedWords(words);
|
||||
revision = writeRevision;
|
||||
applyWords(normalized);
|
||||
try {
|
||||
await apiClient.setExcludedWords(normalized);
|
||||
} catch (error) {
|
||||
if (revision === writeRevision) {
|
||||
revision = previousRevision;
|
||||
applyWords(previousWords);
|
||||
const write = writeChain.then(async () => {
|
||||
// A newer edit already superseded this list and carries the newest state,
|
||||
// so sending this one would push a stale list to the server.
|
||||
if (revision !== writeRevision) return;
|
||||
try {
|
||||
await apiClient.setExcludedWords(normalized);
|
||||
} catch (error) {
|
||||
if (revision === writeRevision) {
|
||||
revision = previousRevision;
|
||||
applyWords(previousWords);
|
||||
}
|
||||
console.error('Failed to persist excluded words to stats database', error);
|
||||
throw error;
|
||||
}
|
||||
console.error('Failed to persist excluded words to stats database', error);
|
||||
throw error;
|
||||
}
|
||||
// A newer edit arrived while this write was in flight, so the server state
|
||||
// this acknowledges is already obsolete. Its own acknowledgement notifies
|
||||
// with the newest list; skipping here avoids a wasted aggregate scan.
|
||||
if (revision === writeRevision) notifyServerSync();
|
||||
});
|
||||
writeChain = write.catch(() => {});
|
||||
return write;
|
||||
}
|
||||
|
||||
export function initializeExcludedWordsStore(): Promise<void> {
|
||||
@@ -155,6 +192,8 @@ export function resetExcludedWordsStoreForTests(): void {
|
||||
initialized = null;
|
||||
revision = 0;
|
||||
listeners.clear();
|
||||
serverSyncListeners.clear();
|
||||
writeChain = Promise.resolve();
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
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<typeof useVocabulary>;
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
|
||||
const previousIsReactActEnvironment = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'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,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
const restoreProperty = (name: string, descriptor: PropertyDescriptor | undefined) => {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
};
|
||||
restoreProperty('window', previousWindow);
|
||||
restoreProperty('document', previousDocument);
|
||||
restoreProperty('HTMLElement', previousHTMLElement);
|
||||
restoreProperty('IS_REACT_ACT_ENVIRONMENT', previousIsReactActEnvironment);
|
||||
};
|
||||
}
|
||||
|
||||
test('DOM harness restores the original global property descriptors', () => {
|
||||
const propertyNames = ['window', 'document', 'HTMLElement', 'IS_REACT_ACT_ENVIRONMENT'] as const;
|
||||
const before = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
|
||||
const restore = installDom();
|
||||
restore();
|
||||
|
||||
const after = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
assert.deepEqual(after, before);
|
||||
});
|
||||
|
||||
function installLocalStorage(): () => void {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const values = new Map<string, string>();
|
||||
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<number, { at: number; fn: () => 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> = {}): 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<void>;
|
||||
tick: (ms: number) => Promise<void>;
|
||||
unmount: () => Promise<void>;
|
||||
teardown: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function mountHook(): Promise<Harness> {
|
||||
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(<Probe />);
|
||||
});
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
// 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: async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
});
|
||||
clock.restore();
|
||||
// React's scheduler can still have deferred work queued; let it drain on
|
||||
// a real timer while the DOM globals it reads are still installed.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
uninstallLocalStorage();
|
||||
uninstallDom();
|
||||
resetExcludedWordsStoreForTests();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubVocabularyClient(overrides: {
|
||||
getVocabularySummary: () => Promise<StatsVocabularySummary>;
|
||||
getVocabularyCharts: () => Promise<StatsVocabularyCharts>;
|
||||
}): () => 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 {
|
||||
await 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 {
|
||||
await harness.teardown();
|
||||
restoreClient();
|
||||
}
|
||||
});
|
||||
|
||||
test('chart backfill polling stops and surfaces Retry when readiness never arrives', async () => {
|
||||
let chartCalls = 0;
|
||||
const restoreClient = stubVocabularyClient({
|
||||
getVocabularySummary: async () => summaryFixture(),
|
||||
getVocabularyCharts: async () => {
|
||||
chartCalls += 1;
|
||||
return chartsFixture({ ready: false });
|
||||
},
|
||||
});
|
||||
const harness = await mountHook();
|
||||
|
||||
try {
|
||||
await harness.flush();
|
||||
for (let poll = 0; poll < 65; poll += 1) await harness.tick(5_000);
|
||||
|
||||
assert.equal(chartCalls, 60, 'a failed backfill must not poll for the lifetime of the tab');
|
||||
assert.match(harness.state().aggregatesError ?? '', /still building/i);
|
||||
|
||||
await act(async () => {
|
||||
harness.state().refreshAggregates();
|
||||
});
|
||||
await harness.flush();
|
||||
assert.equal(chartCalls, 61, 'Retry starts one fresh bounded polling cycle');
|
||||
assert.equal(harness.state().aggregatesError, null);
|
||||
} finally {
|
||||
await 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 {
|
||||
await 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 {
|
||||
await harness.teardown();
|
||||
restoreClient();
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow response from a superseded refresh cannot replace the newest aggregates', async () => {
|
||||
let summaryCalls = 0;
|
||||
let releaseSuperseded: (() => void) | null = null;
|
||||
const restoreClient = stubVocabularyClient({
|
||||
getVocabularySummary: async () => {
|
||||
summaryCalls += 1;
|
||||
const call = summaryCalls;
|
||||
// The second call is the one that gets superseded while still in flight.
|
||||
if (call === 2) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSuperseded = resolve;
|
||||
});
|
||||
}
|
||||
return { ...summaryFixture(), uniqueWords: call };
|
||||
},
|
||||
getVocabularyCharts: async () => chartsFixture(),
|
||||
});
|
||||
const harness = await mountHook();
|
||||
|
||||
try {
|
||||
await harness.flush();
|
||||
assert.equal(harness.state().summary?.uniqueWords, 1);
|
||||
|
||||
// First refresh stalls, then a second refresh supersedes it and resolves.
|
||||
await act(async () => {
|
||||
harness.state().refreshAggregates();
|
||||
});
|
||||
await harness.flush();
|
||||
await act(async () => {
|
||||
harness.state().refreshAggregates();
|
||||
});
|
||||
await harness.flush();
|
||||
|
||||
assert.equal(summaryCalls, 3);
|
||||
assert.equal(harness.state().summary?.uniqueWords, 3);
|
||||
|
||||
const release = releaseSuperseded as (() => void) | null;
|
||||
assert.ok(release, 'expected the superseded request to still be in flight');
|
||||
release();
|
||||
await harness.flush();
|
||||
|
||||
assert.equal(
|
||||
harness.state().summary?.uniqueWords,
|
||||
3,
|
||||
'the superseded response must not overwrite the newest totals',
|
||||
);
|
||||
} finally {
|
||||
await harness.teardown();
|
||||
restoreClient();
|
||||
}
|
||||
});
|
||||
@@ -1,16 +1,46 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
|
||||
import { subscribeExcludedWordsServerSync } from './useExcludedWords';
|
||||
import type {
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
StatsVocabularyCharts,
|
||||
StatsVocabularySummary,
|
||||
} from '../types/stats';
|
||||
|
||||
const AGGREGATE_RETRY_BASE_MS = 1_000;
|
||||
const AGGREGATE_RETRY_MAX_MS = 30_000;
|
||||
const AGGREGATE_RETRY_LIMIT = 5;
|
||||
const CHART_BACKFILL_POLL_MS = 1_000;
|
||||
const CHART_BACKFILL_SLOW_POLL_MS = 5_000;
|
||||
const CHART_BACKFILL_FAST_POLLS = 30;
|
||||
const CHART_BACKFILL_POLL_LIMIT = 60;
|
||||
|
||||
function aggregateRetryDelayMs(attempt: number): number {
|
||||
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
export function useVocabulary() {
|
||||
const [words, setWords] = useState<VocabularyEntry[]>([]);
|
||||
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
|
||||
const [knownWords, setKnownWords] = useState<Set<string>>(new Set());
|
||||
const [summary, setSummary] = useState<StatsVocabularySummary | null>(null);
|
||||
const [charts, setCharts] = useState<StatsVocabularyCharts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [aggregatesError, setAggregatesError] = useState<string | null>(null);
|
||||
// Bumped by `reload` after maintenance rewrites the vocabulary tables.
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const reload = useCallback(() => setReloadToken((token) => token + 1), []);
|
||||
// Bumped independently when only the server-computed summary/charts are
|
||||
// stale, e.g. after the exclusion list changes on the server.
|
||||
const [aggregatesToken, setAggregatesToken] = useState(0);
|
||||
const refreshAggregates = useCallback(() => setAggregatesToken((token) => token + 1), []);
|
||||
const reload = useCallback(() => {
|
||||
setReloadToken((token) => token + 1);
|
||||
setAggregatesToken((token) => token + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => subscribeExcludedWordsServerSync(refreshAggregates), [refreshAggregates]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -51,5 +81,88 @@ export function useVocabulary() {
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
return { words, kanji, knownWords, loading, error, reload };
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAggregatesError(null);
|
||||
const client = getStatsClient();
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const schedule = (fn: () => void, delayMs: number): void => {
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
fn();
|
||||
}, delayMs);
|
||||
timers.add(timer);
|
||||
};
|
||||
|
||||
const loadSummary = (attempt: number): void => {
|
||||
void client
|
||||
.getVocabularySummary()
|
||||
.then((nextSummary) => {
|
||||
if (!cancelled) setSummary(nextSummary);
|
||||
})
|
||||
.catch((summaryError: unknown) => {
|
||||
console.error('Failed to load vocabulary summary', summaryError);
|
||||
if (cancelled) return;
|
||||
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
|
||||
schedule(() => loadSummary(attempt + 1), aggregateRetryDelayMs(attempt));
|
||||
} else {
|
||||
setAggregatesError((previous) => previous ?? 'Vocabulary totals failed to load.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadCharts = (attempt: number, readyPolls: number): void => {
|
||||
void client
|
||||
.getVocabularyCharts()
|
||||
.then((nextCharts) => {
|
||||
if (cancelled) return;
|
||||
setCharts(nextCharts);
|
||||
if (!nextCharts.ready) {
|
||||
const completedPolls = readyPolls + 1;
|
||||
if (completedPolls < CHART_BACKFILL_POLL_LIMIT) {
|
||||
schedule(
|
||||
() => loadCharts(0, completedPolls),
|
||||
completedPolls < CHART_BACKFILL_FAST_POLLS
|
||||
? CHART_BACKFILL_POLL_MS
|
||||
: CHART_BACKFILL_SLOW_POLL_MS,
|
||||
);
|
||||
} else {
|
||||
setAggregatesError(
|
||||
(previous) =>
|
||||
previous ?? 'Vocabulary charts are still building. Retry to check again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((chartError: unknown) => {
|
||||
console.error('Failed to load vocabulary charts', chartError);
|
||||
if (cancelled) return;
|
||||
if (attempt + 1 < AGGREGATE_RETRY_LIMIT) {
|
||||
schedule(() => loadCharts(attempt + 1, readyPolls), aggregateRetryDelayMs(attempt));
|
||||
} else {
|
||||
setAggregatesError((previous) => previous ?? 'Vocabulary charts failed to load.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadSummary(0);
|
||||
loadCharts(0, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
};
|
||||
}, [aggregatesToken]);
|
||||
|
||||
return {
|
||||
words,
|
||||
kanji,
|
||||
knownWords,
|
||||
summary,
|
||||
charts,
|
||||
loading,
|
||||
error,
|
||||
aggregatesError,
|
||||
refreshAggregates,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user