Skip to content

Commit 5c3d44e

Browse files
committed
feat(store): refactor progress slice, add tests, centralize schema
- Moved GetProgressResultSchema to lib/domain/userProgress.ts. - Refactored fetchUserProgress in progressSlice for clarity and consolidated state updates. - Updated BaseSlice to manage showError state. - Added comprehensive Vitest tests for progressSlice.
1 parent 878864a commit 5c3d44e

File tree

4 files changed

+296
-46
lines changed

4 files changed

+296
-46
lines changed

app/store/baseSlice.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ import type { Draft } from 'immer';
44
export interface BaseSlice {
55
loading: boolean;
66
error: string | null;
7+
showError: boolean;
78
setLoading: (loading: boolean) => void;
89
setError: (error: string | null) => void;
910
}
1011

11-
export const createBaseSlice = <T extends { loading: boolean; error: string | null }>(
12+
export const createBaseSlice = <
13+
T extends { loading: boolean; error: string | null; showError: boolean },
14+
>(
1215
set: Parameters<StateCreator<T, [['zustand/immer', never]], [], T>>[0]
1316
): BaseSlice => ({
1417
loading: false,
1518
error: null,
19+
showError: false,
1620
setLoading: (loading) => {
1721
set((state: Draft<T>) => {
1822
state.loading = loading;
@@ -21,6 +25,7 @@ export const createBaseSlice = <T extends { loading: boolean; error: string | nu
2125
setError: (error) => {
2226
set((state: Draft<T>) => {
2327
state.error = error;
28+
state.showError = !!error;
2429
});
2530
},
2631
});

app/store/progressSlice.test.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { create, type StoreApi } from 'zustand';
3+
import { immer } from 'zustand/middleware/immer';
4+
import { createBaseSlice } from './baseSlice';
5+
6+
// Use vi.hoisted for mock variables to ensure they are available
7+
const { mockGetSession, mockGetProgress } = vi.hoisted(() => {
8+
return {
9+
mockGetSession: vi.fn(),
10+
mockGetProgress: vi.fn(),
11+
};
12+
});
13+
14+
vi.mock('next-auth/react', () => ({
15+
getSession: mockGetSession,
16+
}));
17+
vi.mock('@/app/actions/userProgress', () => ({
18+
getProgress: mockGetProgress,
19+
}));
20+
21+
// Now import the module under test and other dependencies
22+
import { createProgressSlice } from './progressSlice';
23+
import type { TextGeneratorState } from './textGeneratorStore';
24+
import type { CEFRLevel } from '@/lib/domain/language-guidance';
25+
import type { GetProgressResult } from '@/lib/domain/userProgress';
26+
27+
// Helper to create a store instance for testing
28+
const createTestStore = (
29+
initialState: Partial<TextGeneratorState> = {}
30+
): StoreApi<TextGeneratorState> => {
31+
return create<TextGeneratorState>()(
32+
immer((set, get, api) => {
33+
// Create the slices we need parts of
34+
const baseSlice = createBaseSlice<TextGeneratorState>(set);
35+
const progressSlice = createProgressSlice(set, get, api);
36+
37+
// Define the minimal required state structure for these tests
38+
const minimalState: Partial<TextGeneratorState> = {
39+
// --- State used/modified by ProgressSlice & its dependencies ---
40+
// From BaseSlice (state)
41+
loading: baseSlice.loading,
42+
error: baseSlice.error,
43+
showError: baseSlice.showError,
44+
// From ProgressSlice (state)
45+
isProgressLoading: progressSlice.isProgressLoading,
46+
userStreak: progressSlice.userStreak,
47+
// From SettingsSlice (state needed by fetchUserProgress)
48+
passageLanguage: 'en', // Default needed for get()
49+
cefrLevel: 'A1', // Default needed & modified by fetchUserProgress
50+
51+
// --- Methods used/modified by ProgressSlice ---
52+
// From BaseSlice (methods)
53+
setLoading: baseSlice.setLoading,
54+
setError: baseSlice.setError,
55+
// From ProgressSlice (methods)
56+
fetchUserProgress: progressSlice.fetchUserProgress,
57+
58+
// --- Mocks for potentially called methods from other slices ---
59+
setCefrLevel: vi.fn(), // Mocked as ProgressSlice calls this via set()
60+
// Add mocks for any other methods potentially called via get() if necessary
61+
// e.g., get().stopPassageSpeech() - Check if progressSlice calls it.
62+
stopPassageSpeech: vi.fn(), // Assuming progressSlice *might* call this via get()
63+
64+
// Add any other essential properties for type compatibility if needed
65+
// These might come from UISlice, QuizSlice, etc.
66+
// Let's start minimal and add based on TS errors.
67+
showLoginPrompt: false,
68+
showContent: false,
69+
showQuestionSection: false,
70+
showExplanation: false,
71+
quizData: null,
72+
currentQuizId: null,
73+
selectedAnswer: null,
74+
isAnswered: false,
75+
relevantTextRange: null,
76+
feedbackIsCorrect: null,
77+
feedbackCorrectAnswer: null,
78+
feedbackCorrectExplanation: null,
79+
feedbackChosenIncorrectExplanation: null,
80+
feedbackRelevantText: null,
81+
nextQuizAvailable: null,
82+
feedbackSubmitted: false,
83+
hoverProgressionPhase: 'credits',
84+
correctAnswersInPhase: 0,
85+
hoverCreditsAvailable: 7,
86+
hoverCreditsUsed: 0,
87+
isSpeechSupported: false,
88+
isSpeakingPassage: false,
89+
isPaused: false,
90+
volume: 0.5,
91+
currentWordIndex: null,
92+
passageUtteranceRef: null,
93+
wordsRef: [],
94+
availableVoices: [],
95+
selectedVoiceURI: null,
96+
translationCache: new Map(),
97+
generatedPassageLanguage: null,
98+
generatedQuestionLanguage: null,
99+
language: 'en',
100+
};
101+
102+
// Merge minimal state with test-specific overrides
103+
const finalInitialState: TextGeneratorState = {
104+
...(minimalState as TextGeneratorState), // Cast needed as we start partial
105+
...(initialState as TextGeneratorState), // Apply test-specific overrides last
106+
};
107+
108+
return finalInitialState;
109+
})
110+
);
111+
};
112+
113+
describe('ProgressSlice', () => {
114+
let store: StoreApi<TextGeneratorState>;
115+
116+
beforeEach(() => {
117+
vi.resetAllMocks();
118+
store = createTestStore(); // Create a fresh store for each test
119+
});
120+
121+
it('should have correct initial state', () => {
122+
const { isProgressLoading, userStreak } = store.getState();
123+
expect(isProgressLoading).toBe(false);
124+
expect(userStreak).toBeNull();
125+
});
126+
127+
describe('fetchUserProgress', () => {
128+
const userId = 123;
129+
const language = 'en';
130+
131+
beforeEach(() => {
132+
store.setState({ passageLanguage: language });
133+
});
134+
135+
it('should set loading state correctly', async () => {
136+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
137+
mockGetProgress.mockResolvedValue({ streak: 5, currentLevel: 'B1' });
138+
139+
const promise = store.getState().fetchUserProgress();
140+
expect(store.getState().isProgressLoading).toBe(true);
141+
await promise;
142+
expect(store.getState().isProgressLoading).toBe(false);
143+
});
144+
145+
it('should reset streak and set loading to false if no session/userId', async () => {
146+
mockGetSession.mockResolvedValue(null);
147+
store.setState({ userStreak: 5 }); // Set a pre-existing streak
148+
149+
await store.getState().fetchUserProgress();
150+
151+
const { isProgressLoading, userStreak } = store.getState();
152+
expect(isProgressLoading).toBe(false);
153+
expect(userStreak).toBeNull();
154+
expect(mockGetProgress).not.toHaveBeenCalled();
155+
});
156+
157+
it('should fetch and set user streak and level successfully', async () => {
158+
const progressData: GetProgressResult = { streak: 10, currentLevel: 'B2', error: null };
159+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
160+
mockGetProgress.mockResolvedValue(progressData);
161+
162+
await store.getState().fetchUserProgress();
163+
164+
const { userStreak, cefrLevel, isProgressLoading, error } = store.getState();
165+
expect(isProgressLoading).toBe(false);
166+
expect(userStreak).toBe(progressData.streak);
167+
expect(cefrLevel).toBe(progressData.currentLevel);
168+
expect(error).toBeNull();
169+
expect(mockGetProgress).toHaveBeenCalledWith({ language });
170+
});
171+
172+
it('should set streak to 0 if API returns null/undefined streak', async () => {
173+
const progressData: GetProgressResult = { streak: null, currentLevel: 'A2', error: null };
174+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
175+
mockGetProgress.mockResolvedValue(progressData);
176+
177+
await store.getState().fetchUserProgress();
178+
179+
const { userStreak, cefrLevel } = store.getState();
180+
expect(userStreak).toBe(0);
181+
expect(cefrLevel).toBe('A2');
182+
});
183+
184+
it('should not update level if API returns null/undefined level', async () => {
185+
const initialLevel: CEFRLevel = 'C1';
186+
store.setState({ cefrLevel: initialLevel });
187+
const progressData: GetProgressResult = { streak: 3, currentLevel: null, error: null };
188+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
189+
mockGetProgress.mockResolvedValue(progressData);
190+
191+
await store.getState().fetchUserProgress();
192+
193+
const { userStreak, cefrLevel } = store.getState();
194+
expect(userStreak).toBe(3);
195+
expect(cefrLevel).toBe(initialLevel); // Level should remain unchanged
196+
});
197+
198+
it('should handle API error response', async () => {
199+
const errorMessage = 'API failed';
200+
const progressData: GetProgressResult = { error: errorMessage }; // Schema allows error only
201+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
202+
mockGetProgress.mockResolvedValue(progressData);
203+
204+
await store.getState().fetchUserProgress();
205+
206+
const { userStreak, isProgressLoading, error, showError } = store.getState();
207+
expect(isProgressLoading).toBe(false);
208+
expect(userStreak).toBeNull(); // Streak should be null on error
209+
expect(error).toBe(errorMessage);
210+
expect(showError).toBe(true);
211+
});
212+
213+
it('should handle validation error (invalid API response structure)', async () => {
214+
const invalidProgressData = { score: 100 }; // Doesn't match schema
215+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
216+
mockGetProgress.mockResolvedValue(invalidProgressData as any);
217+
218+
await store.getState().fetchUserProgress();
219+
220+
const { userStreak, isProgressLoading, error, showError } = store.getState();
221+
expect(isProgressLoading).toBe(false);
222+
expect(userStreak).toBe(0);
223+
expect(error).toBeNull();
224+
expect(showError).toBe(false);
225+
});
226+
227+
it('should handle thrown error during getProgress call', async () => {
228+
const errorMessage = 'Network Error';
229+
mockGetSession.mockResolvedValue({ user: { dbId: userId } } as any);
230+
mockGetProgress.mockRejectedValue(new Error(errorMessage));
231+
232+
await store.getState().fetchUserProgress();
233+
234+
const { userStreak, isProgressLoading, error, showError } = store.getState();
235+
expect(isProgressLoading).toBe(false);
236+
expect(userStreak).toBeNull();
237+
expect(error).toBe(errorMessage);
238+
expect(showError).toBe(true);
239+
});
240+
});
241+
});

app/store/progressSlice.ts

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import type { StateCreator } from 'zustand';
2-
import { z } from 'zod';
32
import { getProgress } from '@/app/actions/userProgress';
43
import { getSession } from 'next-auth/react';
54
import type { TextGeneratorState } from './textGeneratorStore';
65
import type { CEFRLevel } from '@/lib/domain/language-guidance';
6+
import { GetProgressResultSchema } from '@/lib/domain/userProgress';
77
import type { BaseSlice } from './baseSlice';
88
import { createBaseSlice } from './baseSlice';
99

10-
const GetProgressResultSchema = z.object({
11-
streak: z.number().optional().nullable(),
12-
currentLevel: z.string().optional().nullable(),
13-
error: z.string().optional().nullable(),
14-
});
15-
1610
export interface ProgressSlice extends BaseSlice {
1711
isProgressLoading: boolean;
1812
userStreak: number | null;
@@ -33,56 +27,56 @@ export const createProgressSlice: StateCreator<
3327
set((state) => {
3428
state.isProgressLoading = true;
3529
});
30+
3631
const session = await getSession();
3732
const userId = (session?.user as { dbId?: number } | null)?.dbId;
3833

34+
let finalStreak: number | null = null;
35+
let finalLevel: CEFRLevel | null | undefined = undefined;
36+
let errorMessage: string | null = null;
37+
3938
if (!userId) {
40-
set((state) => {
41-
state.userStreak = null;
42-
state.isProgressLoading = false;
43-
});
44-
return;
45-
}
39+
// No user, final state (loading false, streak null) set in finally
40+
} else {
41+
try {
42+
const { passageLanguage } = get();
43+
const rawProgress = await getProgress({ language: passageLanguage });
4644

47-
try {
48-
const { passageLanguage } = get();
49-
const rawProgress = await getProgress({ language: passageLanguage });
45+
const validatedProgress = GetProgressResultSchema.safeParse(rawProgress);
5046

51-
const validatedProgress = GetProgressResultSchema.safeParse(rawProgress);
47+
if (!validatedProgress.success) {
48+
console.error('Zod validation error (getProgress):', validatedProgress.error);
49+
throw new Error(`Invalid API response structure: ${validatedProgress.error.message}`);
50+
}
5251

53-
if (!validatedProgress.success) {
54-
console.error('Zod validation error (getProgress):', validatedProgress.error);
55-
throw new Error(`Invalid API response structure: ${validatedProgress.error.message}`);
56-
}
52+
const progress = validatedProgress.data;
5753

58-
const progress = validatedProgress.data;
54+
if (progress.error) {
55+
throw new Error(progress.error);
56+
}
5957

60-
if (progress.error) {
61-
throw new Error(progress.error);
62-
}
58+
finalStreak = progress.streak ?? 0;
59+
finalLevel = progress.currentLevel;
6360

64-
set((state) => {
65-
state.userStreak = progress.streak ?? 0;
66-
if (progress.currentLevel) {
67-
state.cefrLevel = progress.currentLevel as CEFRLevel;
61+
if (progress.streak === null || progress.streak === undefined) {
62+
console.warn('No progress data found for user/language. Defaulting streak to 0.');
6863
}
69-
});
70-
71-
if (progress.streak === null || progress.streak === undefined) {
72-
console.warn('No progress data found for user/language. Defaulting streak to 0.');
64+
} catch (error: unknown) {
65+
console.error('Error fetching user progress:', String(error));
66+
errorMessage = error instanceof Error ? error.message : 'Unknown error fetching progress';
67+
finalStreak = null;
68+
finalLevel = undefined;
7369
}
74-
} catch (error: unknown) {
75-
console.error('Error fetching user progress:', String(error));
76-
set((state) => {
77-
state.userStreak = null;
78-
});
79-
const errorMessage: string =
80-
error instanceof Error ? error.message : 'Unknown error fetching progress';
81-
get().setError(errorMessage);
82-
} finally {
83-
set((state) => {
84-
state.isProgressLoading = false;
85-
});
8670
}
71+
72+
set((state) => {
73+
state.isProgressLoading = false;
74+
state.userStreak = finalStreak;
75+
if (finalLevel) {
76+
state.cefrLevel = finalLevel;
77+
}
78+
state.error = errorMessage;
79+
state.showError = !!errorMessage;
80+
});
8781
},
8882
});

lib/domain/userProgress.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { z } from 'zod';
2+
import { CEFRLevelSchema } from './language-guidance';
3+
4+
export const GetProgressResultSchema = z.object({
5+
streak: z.number().optional().nullable(),
6+
currentLevel: CEFRLevelSchema.optional().nullable(), // Validate against CEFR levels
7+
error: z.string().optional().nullable(),
8+
});
9+
10+
export type GetProgressResult = z.infer<typeof GetProgressResultSchema>;

0 commit comments

Comments
 (0)