diff --git a/src/__tests__/store/quizStore.test.ts b/src/__tests__/store/quizStore.test.ts new file mode 100644 index 0000000..78a8dcf --- /dev/null +++ b/src/__tests__/store/quizStore.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for #637 — quizStore persistence and resume flow + */ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useQuizStore } from '../../store/quizStore'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), + mergeItem: jest.fn(() => Promise.resolve()), + clear: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), + multiGet: jest.fn(() => Promise.resolve([])), + multiSet: jest.fn(() => Promise.resolve()), + multiRemove: jest.fn(() => Promise.resolve()), + multiMerge: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, + appLogger: { errorSync: jest.fn(), warnSync: jest.fn(), infoSync: jest.fn() }, +})); + +const QUIZ_SESSION_KEY = '@teachlink_quiz_session'; + +describe('quizStore — persistence and resume flow (#637)', () => { + beforeEach(() => { + useQuizStore.setState({ + session: { + quizId: null, + sectionId: null, + courseId: null, + currentQuestionIndex: 0, + selectedAnswers: {}, + startedAt: null, + answers: {}, + startTime: null, + selectedOption: null, + }, + quizId: null, + quizProgress: {}, + }); + jest.clearAllMocks(); + }); + + describe('selectAnswer persistence', () => { + it('persists answer to AsyncStorage when selected', async () => { + const { selectAnswer } = useQuizStore.getState(); + + await useQuizStore.getState().startQuiz('quiz-1', 'section-1', 'course-1'); + + selectAnswer('question-1', 'answer-a'); + + // Wait for async persistence + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + QUIZ_SESSION_KEY, + expect.stringContaining('question-1') + ); + }); + + it('persists multiple answers to AsyncStorage', async () => { + const { selectAnswer } = useQuizStore.getState(); + + await useQuizStore.getState().startQuiz('quiz-1', 'section-1', 'course-1'); + + selectAnswer('question-1', 'answer-a'); + selectAnswer('question-2', 'answer-b'); + selectAnswer('question-3', 'answer-c'); + + // Wait for async persistence + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(AsyncStorage.setItem).toHaveBeenCalled(); + const calls = (AsyncStorage.setItem as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + const storedData = JSON.parse(lastCall[1]); + + expect(storedData.data.selectedAnswers).toEqual({ + 'question-1': 'answer-a', + 'question-2': 'answer-b', + 'question-3': 'answer-c', + }); + }); + + it('persists multi-select answers as arrays', async () => { + const { selectAnswer } = useQuizStore.getState(); + + await useQuizStore.getState().startQuiz('quiz-1', 'section-1', 'course-1'); + + selectAnswer('question-1', 'answer-a', true); + selectAnswer('question-1', 'answer-b', true); + + // Wait for async persistence + await new Promise(resolve => setTimeout(resolve, 0)); + + const calls = (AsyncStorage.setItem as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + const storedData = JSON.parse(lastCall[1]); + + expect(storedData.data.selectedAnswers['question-1']).toEqual(['answer-a', 'answer-b']); + }); + }); + + describe('resume after simulated app kill', () => { + it('retrieves answers from store after state reset', async () => { + const mockSession = { + quizId: 'quiz-1', + sectionId: 'section-1', + courseId: 'course-1', + currentQuestionIndex: 2, + selectedAnswers: { + 'question-1': 'answer-a', + 'question-2': 'answer-b', + }, + startedAt: new Date().toISOString(), + answers: {}, + startTime: null, + selectedOption: null, + }; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ + version: 1, + data: mockSession, + }) + ); + + // Simulate app kill by resetting state + useQuizStore.setState({ + session: { + quizId: null, + sectionId: null, + courseId: null, + currentQuestionIndex: 0, + selectedAnswers: {}, + startedAt: null, + answers: {}, + startTime: null, + selectedOption: null, + }, + quizId: null, + quizProgress: {}, + }); + + // Load progress (simulating app relaunch) + await useQuizStore.getState().loadQuizProgress('course-1'); + + const { session } = useQuizStore.getState(); + + expect(session.quizId).toBe('quiz-1'); + expect(session.selectedAnswers).toEqual({ + 'question-1': 'answer-a', + 'question-2': 'answer-b', + }); + expect(session.currentQuestionIndex).toBe(2); + }); + + it('clears session if quizId does not match', async () => { + const mockSession = { + quizId: 'quiz-2', // Different quiz + sectionId: 'section-1', + courseId: 'course-1', + currentQuestionIndex: 2, + selectedAnswers: { + 'question-1': 'answer-a', + }, + startedAt: new Date().toISOString(), + answers: {}, + startTime: null, + selectedOption: null, + }; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ + version: 1, + data: mockSession, + }) + ); + + await useQuizStore.getState().loadQuizProgress('course-1'); + + // Session should not be restored since it's for a different quiz + const { session } = useQuizStore.getState(); + + expect(session.quizId).toBe('quiz-2'); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith(QUIZ_SESSION_KEY); + }); + }); + + describe('resetQuiz', () => { + it('clears all quiz state and removes from AsyncStorage', async () => { + await useQuizStore.getState().startQuiz('quiz-1', 'section-1', 'course-1'); + useQuizStore.getState().selectAnswer('question-1', 'answer-a'); + + // Wait for async persistence + await new Promise(resolve => setTimeout(resolve, 0)); + + await useQuizStore.getState().resetQuiz(); + + const { session, quizId } = useQuizStore.getState(); + + expect(session.quizId).toBeNull(); + expect(session.selectedAnswers).toEqual({}); + expect(quizId).toBeNull(); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith(QUIZ_SESSION_KEY); + }); + }); + + describe('goToQuestion persistence', () => { + it('persists current question index to AsyncStorage', async () => { + await useQuizStore.getState().startQuiz('quiz-1', 'section-1', 'course-1'); + + useQuizStore.getState().goToQuestion(5); + + // Wait for async persistence + await new Promise(resolve => setTimeout(resolve, 0)); + + const calls = (AsyncStorage.setItem as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + const storedData = JSON.parse(lastCall[1]); + + expect(storedData.data.currentQuestionIndex).toBe(5); + }); + }); +}); diff --git a/src/components/mobile/MobileQuizManager/index.tsx b/src/components/mobile/MobileQuizManager/index.tsx index 1eb71bf..e1974fc 100644 --- a/src/components/mobile/MobileQuizManager/index.tsx +++ b/src/components/mobile/MobileQuizManager/index.tsx @@ -1,19 +1,19 @@ import { LinearGradient } from 'expo-linear-gradient'; -import React, { useEffect, useState, useCallback } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import QuizCarousel from './QuizCarousel'; -import QuizProgress from './QuizProgress'; -import QuizResults from './QuizResults'; import { useAnalytics } from '../../../hooks/useAnalytics'; import { useInAppReview, useReviewMetrics } from '../../../hooks/useInAppReview'; import { ReviewTrigger } from '../../../services/inAppReview'; import { useQuizStore } from '../../../store/quizStore'; -import { Quiz, Course } from '../../../types/course'; +import { Course, Quiz } from '../../../types/course'; import logger from '../../../utils/logger'; import { AnalyticsEvent, ScreenName } from '../../../utils/trackingEvents'; import PrimaryButton from '../../common/PrimaryButton'; +import QuizCarousel from './QuizCarousel'; +import QuizProgress from './QuizProgress'; +import QuizResults from './QuizResults'; interface MobileQuizManagerProps { /** The quiz data to display and manage */ @@ -50,6 +50,7 @@ export default function MobileQuizManager({ const [currentView, setCurrentView] = useState('intro'); const [quizResults, setQuizResults] = useState<{ score: number; passed: boolean } | null>(null); + const [showResumePrompt, setShowResumePrompt] = useState(false); const { trackEvent, trackScreen } = useAnalytics(); const { requestReview } = useInAppReview(); const { trackPerfectQuiz } = useReviewMetrics(); @@ -57,12 +58,21 @@ export default function MobileQuizManager({ useEffect(() => { const init = async () => { await loadQuizProgress(courseId); - initializeQuiz(quiz.id); + + // Check for in-progress session with same quizId + const hasInProgressSession = session.quizId === quiz.id && + Object.keys(session.selectedAnswers).length > 0; + + if (hasInProgressSession) { + setShowResumePrompt(true); + } else { + initializeQuiz(quiz.id); + } }; void init(); trackScreen(ScreenName.QUIZ, { quizId: quiz.id, courseId }); setCurrentView('intro'); - }, [courseId, quiz.id, loadQuizProgress, initializeQuiz, trackScreen]); + }, [courseId, quiz.id, loadQuizProgress, initializeQuiz, trackScreen, session.quizId, session.selectedAnswers]); const handleStartQuiz = async () => { try { @@ -74,6 +84,18 @@ export default function MobileQuizManager({ } }; + const handleResumeQuiz = () => { + setShowResumePrompt(false); + setCurrentView('questions'); + trackEvent(AnalyticsEvent.QUIZ_STARTED, { quizId: quiz.id, courseId, resumed: true }); + }; + + const handleStartFresh = async () => { + await resetSession(); + setShowResumePrompt(false); + await handleStartQuiz(); + }; + const handleQuestionChange = useCallback( (index: number) => { // Use requestAnimationFrame to ensure smooth updates @@ -246,6 +268,51 @@ export default function MobileQuizManager({ ); + + // Render resume prompt modal + if (showResumePrompt) { + const answeredCount = Object.keys(session.selectedAnswers).length; + return ( + setShowResumePrompt(false)} + > + + + + 📝 + + Resume Quiz? + + You have an incomplete quiz in progress. You've answered {answeredCount} of {quiz.questions.length} questions. + + + + Start Fresh + + + + Resume Quiz + + + + + + + ); } // Render questions screen @@ -588,4 +655,76 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 12, }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + modalContent: { + backgroundColor: '#ffffff', + borderRadius: 16, + padding: 24, + width: '100%', + maxWidth: 400, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + modalIconContainer: { + alignItems: 'center', + marginBottom: 16, + }, + modalIcon: { + fontSize: 48, + }, + modalTitle: { + fontSize: 22, + fontWeight: 'bold', + color: '#111827', + textAlign: 'center', + marginBottom: 12, + }, + modalText: { + fontSize: 16, + color: '#4b5563', + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + }, + modalButtons: { + gap: 12, + }, + modalButton: { + borderRadius: 12, + overflow: 'hidden', + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + }, + modalButtonSecondary: { + backgroundColor: '#e5e7eb', + }, + modalButtonPrimary: { + backgroundColor: 'transparent', + }, + modalButtonGradient: { + width: '100%', + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + }, + modalButtonSecondaryText: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + }, + modalButtonPrimaryText: { + fontSize: 16, + fontWeight: '700', + color: '#ffffff', + }, });