diff --git a/src/app/store/__tests__/quizStore.test.ts b/src/app/store/__tests__/quizStore.test.ts new file mode 100644 index 00000000..6b7899b5 --- /dev/null +++ b/src/app/store/__tests__/quizStore.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useQuizStore, getCompletedQuizzes } from '../quizStore'; +import type { Quiz } from '../quizStore'; + +const mockQuiz: Quiz = { + id: 'quiz-1', + title: 'Test Quiz', + description: 'A test quiz', + questions: [ + { + id: 'q1', + type: 'multiple-choice', + text: 'Q1?', + options: [{ id: 'a1', text: 'A', isCorrect: true }], + points: 1, + }, + { id: 'q2', type: 'true-false', text: 'Q2?', points: 1 }, + ], +}; + +function resetStore() { + sessionStorage.clear(); + useQuizStore.persist.clearStorage(); + useQuizStore.setState({ + currentQuiz: null, + currentQuestionIndex: 0, + answers: {}, + isReviewMode: false, + startTime: null, + endTime: null, + }); +} + +describe('quizStore persist middleware', () => { + beforeEach(resetStore); + + it('persists currentQuiz, currentQuestionIndex, answers, startTime, and isReviewMode to sessionStorage', () => { + vi.useFakeTimers(); + useQuizStore.getState().setCurrentQuiz(mockQuiz); + useQuizStore.getState().startQuiz(); + useQuizStore.getState().setAnswer('q1', 'A'); + useQuizStore.getState().nextQuestion(); + + const stored = JSON.parse(sessionStorage.getItem('teachlink-quiz-storage') || '{}'); + expect(stored.state).toBeDefined(); + expect(stored.state.currentQuiz).toEqual(mockQuiz); + expect(stored.state.currentQuestionIndex).toBe(1); + expect(stored.state.answers).toEqual({ q1: 'A' }); + expect(stored.state.startTime).toBeDefined(); + expect(stored.state.isReviewMode).toBe(false); + vi.useRealTimers(); + }); + + it('rehydrates correctly and resumes quiz after simulated refresh', async () => { + useQuizStore.getState().setCurrentQuiz(mockQuiz); + useQuizStore.getState().startQuiz(); + useQuizStore.getState().setAnswer('q1', 'A'); + useQuizStore.getState().nextQuestion(); + + const persistedData = sessionStorage.getItem('teachlink-quiz-storage'); + + resetStore(); + + sessionStorage.setItem('teachlink-quiz-storage', persistedData!); + + await useQuizStore.persist.rehydrate(); + + const stateAfter = useQuizStore.getState(); + expect(stateAfter.currentQuiz).toEqual(mockQuiz); + expect(stateAfter.currentQuestionIndex).toBe(1); + expect(stateAfter.answers).toEqual({ q1: 'A' }); + expect(stateAfter.startTime).toBeInstanceOf(Date); + }); + + it('clears sessionStorage and saves history on endQuiz', () => { + useQuizStore.getState().setCurrentQuiz(mockQuiz); + useQuizStore.getState().startQuiz(); + useQuizStore.getState().setAnswer('q1', 'A'); + useQuizStore.getState().setAnswer('q2', 'true'); + + useQuizStore.getState().endQuiz(); + + const stored = sessionStorage.getItem('teachlink-quiz-storage'); + expect(stored).toBeNull(); + + const history = getCompletedQuizzes(); + expect(history).toHaveLength(1); + expect(history[0].quizId).toBe('quiz-1'); + expect(history[0].answers).toEqual({ q1: 'A', q2: 'true' }); + }); + + it('resets quiz state and clears persisted data on resetQuiz', () => { + useQuizStore.getState().setCurrentQuiz(mockQuiz); + useQuizStore.getState().startQuiz(); + useQuizStore.getState().setAnswer('q1', 'A'); + + useQuizStore.getState().resetQuiz(); + + const state = useQuizStore.getState(); + expect(state.currentQuiz).toBeNull(); + expect(state.currentQuestionIndex).toBe(0); + expect(state.answers).toEqual({}); + expect(state.startTime).toBeNull(); + expect(state.isReviewMode).toBe(false); + expect(state.endTime).toBeNull(); + }); + + it('does not overwrite startTime on re-request if already set', () => { + useQuizStore.getState().setCurrentQuiz(mockQuiz); + useQuizStore.getState().startQuiz(); + const originalStartTime = useQuizStore.getState().startTime; + + useQuizStore.getState().startQuiz(); + + expect(useQuizStore.getState().startTime).toBe(originalStartTime); + }); +}); diff --git a/src/app/store/quizStore.ts b/src/app/store/quizStore.ts index 471e108d..8855c37c 100644 --- a/src/app/store/quizStore.ts +++ b/src/app/store/quizStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; export interface Option { id: string; @@ -29,6 +30,14 @@ export interface Quiz { questions: Question[]; } +export interface CompletedQuiz { + quizId: string; + title: string; + answers: Record; + startTime: string; + endTime: string; +} + interface QuizState { currentQuiz: Quiz | null; currentQuestionIndex: number; @@ -46,6 +55,8 @@ interface QuizState { resetQuiz: () => void; } +const QUIZ_HISTORY_KEY = 'teachlink-quiz-history'; + const initialState = { currentQuiz: null, currentQuestionIndex: 0, @@ -55,34 +66,94 @@ const initialState = { endTime: null, }; -export const useQuizStore = create((set, get) => ({ - ...initialState, +export function getCompletedQuizzes(): CompletedQuiz[] { + if (typeof window === 'undefined') return []; + try { + return JSON.parse(localStorage.getItem(QUIZ_HISTORY_KEY) || '[]'); + } catch { + return []; + } +} + +export const useQuizStore = create()( + persist( + (set, get) => ({ + ...initialState, + + setCurrentQuiz: (quiz) => set({ currentQuiz: quiz }), + + setAnswer: (questionId, answer) => + set((state) => ({ + answers: { ...state.answers, [questionId]: answer }, + })), - setCurrentQuiz: (quiz) => set({ currentQuiz: quiz }), + nextQuestion: () => + set((state) => ({ + currentQuestionIndex: Math.min( + state.currentQuestionIndex + 1, + (state.currentQuiz?.questions.length || 1) - 1, + ), + })), - setAnswer: (questionId, answer) => - set((state) => ({ - answers: { ...state.answers, [questionId]: answer }, - })), + previousQuestion: () => + set((state) => ({ + currentQuestionIndex: Math.max(state.currentQuestionIndex - 1, 0), + })), - nextQuestion: () => - set((state) => ({ - currentQuestionIndex: Math.min( - state.currentQuestionIndex + 1, - (state.currentQuiz?.questions.length || 1) - 1, - ), - })), + startQuiz: () => + set((state) => { + if (state.startTime) return {}; + return { startTime: new Date() }; + }), - previousQuestion: () => - set((state) => ({ - currentQuestionIndex: Math.max(state.currentQuestionIndex - 1, 0), - })), + endQuiz: () => { + const state = get(); + const endTime = new Date(); - startQuiz: () => set({ startTime: new Date() }), + if (state.currentQuiz) { + try { + const history: CompletedQuiz[] = JSON.parse( + localStorage.getItem(QUIZ_HISTORY_KEY) || '[]', + ); + history.push({ + quizId: state.currentQuiz.id, + title: state.currentQuiz.title, + answers: state.answers, + startTime: state.startTime?.toISOString() ?? '', + endTime: endTime.toISOString(), + }); + localStorage.setItem(QUIZ_HISTORY_KEY, JSON.stringify(history)); + } catch { + // localStorage unavailable + } + } - endQuiz: () => set({ endTime: new Date() }), + set({ endTime }); + useQuizStore.persist.clearStorage(); + }, - toggleReviewMode: () => set((state) => ({ isReviewMode: !state.isReviewMode })), + toggleReviewMode: () => set((state) => ({ isReviewMode: !state.isReviewMode })), - resetQuiz: () => set(initialState), -})); + resetQuiz: () => set(initialState), + }), + { + name: 'teachlink-quiz-storage', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => ({ + currentQuiz: state.currentQuiz, + currentQuestionIndex: state.currentQuestionIndex, + answers: state.answers, + startTime: state.startTime, + isReviewMode: state.isReviewMode, + }), + merge: (persisted, current) => { + const data = persisted as Partial; + return { + ...current, + ...data, + startTime: data.startTime ? new Date(data.startTime as unknown as string) : null, + }; + }, + }, + ), +);