Skip to content

Commit 69b4325

Browse files
[codex] Split progress source tests
Split the web progress source tests into focused files with shared typed support.\n\nValidated with npm run test in apps/web: 15 files, 135 tests passed after rebasing onto current main.
1 parent f885966 commit 69b4325

7 files changed

Lines changed: 1641 additions & 1513 deletions

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"dev": "vite",
77
"build": "tsc -b && vite build",
88
"preview": "vite preview",
9-
"test": "vitest run src/appData/domain.test.ts src/localDb/reviews.test.ts src/localDb/reviewSchedule.test.ts src/localDb/cards.test.ts src/screens/review/*.test.tsx src/appData/progress/progressSource.test.tsx src/appData/progress/progressInvalidation.test.tsx src/api.test.ts",
9+
"test": "vitest run src/appData/domain.test.ts src/localDb/reviews.test.ts src/localDb/reviewSchedule.test.ts src/localDb/cards.test.ts src/screens/review/*.test.tsx src/appData/progress/progressSource.summarySeries.test.tsx src/appData/progress/progressSource.cache.test.tsx src/appData/progress/progressSource.reviewSchedule.test.tsx src/appData/progress/progressSource.lifecycle.test.tsx src/appData/progress/progressInvalidation.test.tsx src/api.test.ts",
1010
"test:e2e": "FLASHCARDS_E2E_TARGET=prod playwright test",
1111
"test:e2e:local": "node ./scripts/check-local-e2e-stack.mjs && FLASHCARDS_E2E_TARGET=local playwright test",
1212
"test:e2e:prod": "FLASHCARDS_E2E_TARGET=prod playwright test"
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// @vitest-environment jsdom
2+
import { describe, expect, it, vi } from "vitest";
3+
import type {
4+
ProgressReviewSchedule,
5+
ProgressSeries,
6+
ProgressSummaryPayload,
7+
} from "../../types";
8+
import {
9+
buildCurrentReviewScheduleInput,
10+
buildCurrentReviewScheduleScopeKey,
11+
buildCurrentSeriesInput,
12+
buildCurrentSeriesScopeKey,
13+
buildCurrentSummaryScopeKey,
14+
buildServerReviewSchedule,
15+
buildServerSeries,
16+
buildServerSummary,
17+
createDeferredPromise,
18+
flushEffects,
19+
linkedCloudSettings,
20+
loadProgressReviewScheduleMock,
21+
loadProgressSeriesMock,
22+
loadProgressSummaryMock,
23+
renderHarness,
24+
replaceProgressReviewScheduleBucketCount,
25+
reviewScheduleOnlySections,
26+
seriesOnlySections,
27+
storePersistedProgressReviewScheduleForTest,
28+
storePersistedProgressSeriesForTest,
29+
storePersistedProgressSummaryForTest,
30+
summaryAndSeriesSections,
31+
swapFirstProgressReviewScheduleBuckets,
32+
} from "./progressSourceTestSupport";
33+
34+
describe("useProgressSource cache", () => {
35+
it("hydrates matching server cache before remote refresh completes", async () => {
36+
const cachedSummary = buildServerSummary(6, "2026-04-18T09:10:00.000Z");
37+
const cachedSeries = buildServerSeries(6, "2026-04-18T09:10:00.000Z");
38+
const deferredSummary = createDeferredPromise<ProgressSummaryPayload>();
39+
const deferredSeries = createDeferredPromise<ProgressSeries>();
40+
storePersistedProgressSummaryForTest(buildCurrentSummaryScopeKey(), cachedSummary);
41+
storePersistedProgressSeriesForTest(buildCurrentSeriesScopeKey(), cachedSeries);
42+
loadProgressSummaryMock.mockImplementation(() => deferredSummary.promise);
43+
loadProgressSeriesMock.mockImplementation(() => deferredSeries.promise);
44+
45+
const harness = renderHarness({
46+
sessionVerificationState: "verified",
47+
cloudSettings: linkedCloudSettings,
48+
progressServerInvalidationVersion: 0,
49+
sections: summaryAndSeriesSections,
50+
});
51+
52+
await flushEffects();
53+
54+
expect(harness.getApi().progressSourceState.summary.serverBase?.generatedAt).toBe("2026-04-18T09:10:00.000Z");
55+
expect(harness.getApi().progressSourceState.summary.renderedSnapshot?.summary.activeReviewDays).toBe(6);
56+
expect(harness.getApi().progressSourceState.series.serverBase?.generatedAt).toBe("2026-04-18T09:10:00.000Z");
57+
expect(harness.getApi().progressSourceState.series.renderedSnapshot?.dailyReviews).toContainEqual({
58+
date: buildCurrentSeriesInput().to,
59+
reviewCount: 6,
60+
});
61+
62+
deferredSummary.resolve(buildServerSummary(7, "2026-04-18T09:11:00.000Z"));
63+
deferredSeries.resolve(buildServerSeries(7, "2026-04-18T09:11:00.000Z"));
64+
await flushEffects();
65+
66+
expect(harness.getApi().progressSourceState.summary.renderedSnapshot?.summary.activeReviewDays).toBe(7);
67+
expect(harness.getApi().progressSourceState.series.renderedSnapshot?.dailyReviews).toContainEqual({
68+
date: buildCurrentSeriesInput().to,
69+
reviewCount: 7,
70+
});
71+
});
72+
73+
it("treats corrupt and mismatched cache entries as misses", async () => {
74+
const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
75+
const deferredSummary = createDeferredPromise<ProgressSummaryPayload>();
76+
const deferredSeries = createDeferredPromise<ProgressSeries>();
77+
const summaryScopeKey = buildCurrentSummaryScopeKey();
78+
const seriesScopeKey = buildCurrentSeriesScopeKey();
79+
storePersistedProgressSummaryForTest("other-scope", buildServerSummary(6, "2026-04-18T09:10:00.000Z"));
80+
window.localStorage.setItem(`flashcards-progress-server-summary:${summaryScopeKey}`, window.localStorage.getItem("flashcards-progress-server-summary:other-scope") ?? "");
81+
window.localStorage.setItem(`flashcards-progress-server-series:${seriesScopeKey}`, "{not-json");
82+
loadProgressSummaryMock.mockImplementation(() => deferredSummary.promise);
83+
loadProgressSeriesMock.mockImplementation(() => deferredSeries.promise);
84+
85+
const harness = renderHarness({
86+
sessionVerificationState: "verified",
87+
cloudSettings: linkedCloudSettings,
88+
progressServerInvalidationVersion: 0,
89+
sections: summaryAndSeriesSections,
90+
});
91+
92+
await flushEffects();
93+
94+
expect(harness.getApi().progressSourceState.summary.serverBase).toBeNull();
95+
expect(harness.getApi().progressSourceState.series.serverBase).toBeNull();
96+
expect(warningSpy).toHaveBeenCalledWith("progress_cache_miss", expect.objectContaining({
97+
reason: "scope_mismatch",
98+
section: "summary",
99+
}));
100+
expect(warningSpy).toHaveBeenCalledWith("progress_cache_miss", expect.objectContaining({
101+
reason: "invalid_json",
102+
section: "series",
103+
}));
104+
105+
warningSpy.mockRestore();
106+
});
107+
108+
it("treats malformed cached series dates as invalid-shape misses before loading remote series", async () => {
109+
const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
110+
const currentSeriesInput = buildCurrentSeriesInput();
111+
const seriesScopeKey = buildCurrentSeriesScopeKey();
112+
const malformedCachedSeries = {
113+
version: 1,
114+
scopeKey: seriesScopeKey,
115+
savedAt: "2026-04-18T09:00:00.000Z",
116+
serverBase: {
117+
timeZone: currentSeriesInput.timeZone,
118+
from: "not-a-local-date",
119+
to: currentSeriesInput.to,
120+
generatedAt: "2026-04-18T09:10:00.000Z",
121+
dailyReviews: [
122+
{
123+
date: currentSeriesInput.to,
124+
reviewCount: 12,
125+
},
126+
],
127+
},
128+
} as const;
129+
loadProgressSeriesMock.mockResolvedValueOnce(buildServerSeries(8, "2026-04-18T09:23:00.000Z"));
130+
window.localStorage.setItem(
131+
`flashcards-progress-server-series:${seriesScopeKey}`,
132+
JSON.stringify(malformedCachedSeries),
133+
);
134+
135+
try {
136+
const harness = renderHarness({
137+
sessionVerificationState: "verified",
138+
cloudSettings: linkedCloudSettings,
139+
progressServerInvalidationVersion: 0,
140+
sections: seriesOnlySections,
141+
});
142+
143+
await flushEffects();
144+
145+
expect(warningSpy).toHaveBeenCalledWith("progress_cache_miss", expect.objectContaining({
146+
reason: "invalid_shape",
147+
section: "series",
148+
}));
149+
expect(loadProgressSeriesMock).toHaveBeenCalledWith(currentSeriesInput);
150+
expect(harness.getApi().progressSourceState.series.serverBase?.generatedAt).toBe("2026-04-18T09:23:00.000Z");
151+
expect(harness.getApi().progressSourceState.series.renderedSnapshot?.dailyReviews).toContainEqual({
152+
date: currentSeriesInput.to,
153+
reviewCount: 8,
154+
});
155+
} finally {
156+
warningSpy.mockRestore();
157+
}
158+
});
159+
160+
const invalidProgressReviewScheduleCacheCases: ReadonlyArray<Readonly<{
161+
name: string;
162+
serverBase: ProgressReviewSchedule;
163+
}>> = [
164+
{
165+
name: "negative bucket count",
166+
serverBase: replaceProgressReviewScheduleBucketCount(buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"), 0, -1),
167+
},
168+
{
169+
name: "fractional bucket count",
170+
serverBase: replaceProgressReviewScheduleBucketCount(buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"), 0, 1.5),
171+
},
172+
{
173+
name: "negative totalCards",
174+
serverBase: {
175+
...buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"),
176+
totalCards: -1,
177+
},
178+
},
179+
{
180+
name: "fractional totalCards",
181+
serverBase: {
182+
...buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"),
183+
totalCards: 7.5,
184+
},
185+
},
186+
{
187+
name: "totalCards mismatch",
188+
serverBase: {
189+
...buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"),
190+
totalCards: 99,
191+
},
192+
},
193+
{
194+
name: "unstable bucket order",
195+
serverBase: swapFirstProgressReviewScheduleBuckets(buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z")),
196+
},
197+
];
198+
199+
for (const invalidCase of invalidProgressReviewScheduleCacheCases) {
200+
it(`treats cached review schedule with ${invalidCase.name} as an invalid-shape miss before loading remote schedule`, async () => {
201+
const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
202+
const deferredReviewSchedule = createDeferredPromise<ProgressReviewSchedule>();
203+
const reviewScheduleScopeKey = buildCurrentReviewScheduleScopeKey();
204+
storePersistedProgressReviewScheduleForTest(reviewScheduleScopeKey, invalidCase.serverBase);
205+
loadProgressReviewScheduleMock.mockImplementation(() => deferredReviewSchedule.promise);
206+
207+
try {
208+
const harness = renderHarness({
209+
sessionVerificationState: "verified",
210+
cloudSettings: linkedCloudSettings,
211+
progressServerInvalidationVersion: 0,
212+
sections: reviewScheduleOnlySections,
213+
});
214+
215+
await flushEffects();
216+
217+
expect(warningSpy).toHaveBeenCalledWith("progress_cache_miss", expect.objectContaining({
218+
reason: "invalid_shape",
219+
section: "review_schedule",
220+
}));
221+
expect(loadProgressReviewScheduleMock).toHaveBeenCalledWith(buildCurrentReviewScheduleInput());
222+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase).toBeNull();
223+
224+
deferredReviewSchedule.resolve(buildServerReviewSchedule(8, "2026-04-18T09:24:00.000Z"));
225+
await flushEffects();
226+
227+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase?.generatedAt).toBe("2026-04-18T09:24:00.000Z");
228+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase?.buckets[0]?.count).toBe(8);
229+
} finally {
230+
warningSpy.mockRestore();
231+
}
232+
});
233+
}
234+
235+
it("treats cached review schedules for another timezone as misses before loading remote schedule", async () => {
236+
const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
237+
const deferredReviewSchedule = createDeferredPromise<ProgressReviewSchedule>();
238+
const reviewScheduleScopeKey = buildCurrentReviewScheduleScopeKey();
239+
storePersistedProgressReviewScheduleForTest(reviewScheduleScopeKey, {
240+
...buildServerReviewSchedule(4, "2026-04-18T09:10:00.000Z"),
241+
timeZone: "UTC",
242+
});
243+
loadProgressReviewScheduleMock.mockImplementation(() => deferredReviewSchedule.promise);
244+
245+
try {
246+
const harness = renderHarness({
247+
sessionVerificationState: "verified",
248+
cloudSettings: linkedCloudSettings,
249+
progressServerInvalidationVersion: 0,
250+
sections: reviewScheduleOnlySections,
251+
});
252+
253+
await flushEffects();
254+
255+
expect(warningSpy).toHaveBeenCalledWith("progress_cache_miss", expect.objectContaining({
256+
reason: "time_zone_mismatch",
257+
section: "review_schedule",
258+
}));
259+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase).toBeNull();
260+
261+
deferredReviewSchedule.resolve(buildServerReviewSchedule(8, "2026-04-18T09:24:00.000Z"));
262+
await flushEffects();
263+
264+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase?.timeZone).toBe("Europe/Madrid");
265+
expect(harness.getApi().progressSourceState.reviewSchedule.serverBase?.buckets[0]?.count).toBe(8);
266+
} finally {
267+
warningSpy.mockRestore();
268+
}
269+
});
270+
});

0 commit comments

Comments
 (0)