mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
fix(stats): serialize excluded word writes to avoid races
- Queue setExcludedWords writes so a slower in-flight save can't land after and overwrite a newer edit - Run server-sync listeners independently so one throwing listener doesn't roll back a successful write or block the rest - Add regression tests for write ordering, listener isolation, coalesced known-word snapshots, and vocabulary hook aggregate refresh/retry
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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 = 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<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: () => 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: () => {
|
||||
if (root) root.unmount();
|
||||
clock.restore();
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user