Gateprep / frontend /src /hooks /useTestEngine.js
banu4prasad's picture
frontend and DB code refactor
a17d02d
Raw
History Blame Contribute Delete
12 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { adminAPI, testAPI } from '../api/api'
import { getEndTimeMs } from '../utils/testEngineUtils'
import useAntiCheat from './useAntiCheat'
export default function useTestEngine() {
const { testId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isPreview = searchParams.get('preview') === 'true'
const [test, setTest] = useState(null)
const [attempt, setAttempt] = useState(null)
const [questions, setQuestions] = useState([])
const [answers, setAnswers] = useState({})
const [timings, setTimings] = useState({})
const [marked, setMarked] = useState(new Set())
const [visited, setVisited] = useState(new Set())
const [current, setCurrent] = useState(0)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [showCalc, setShowCalc] = useState(false)
const [natInput, setNatInput] = useState('')
const [tabViolations, setTabViolations] = useState(0)
const [fsViolations, setFsViolations] = useState(0)
const [showFsWarning, setShowFsWarning] = useState(false)
const [attemptNumber, setAttemptNumber] = useState(null)
const [maxAttempts, setMaxAttempts] = useState(6)
const [started, setStarted] = useState(false)
const [starting, setStarting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [showPalette, setShowPalette] = useState(false)
const submitLockRef = useRef(false)
const autoSaveRef = useRef(null)
const tabViolRef = useRef(0)
const fsViolRef = useRef(0)
const attemptRef = useRef(null)
const answersRef = useRef({})
const questionsRef = useRef([])
const timingsRef = useRef({})
const testIdRef = useRef(testId)
const questionStartRef = useRef(Date.now())
const loadedRef = useRef(false)
useEffect(() => { testIdRef.current = testId }, [testId])
useEffect(() => { attemptRef.current = attempt }, [attempt])
useEffect(() => { answersRef.current = answers }, [answers])
useEffect(() => { questionsRef.current = questions }, [questions])
useEffect(() => { timingsRef.current = timings }, [timings])
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setStarted(false)
setAccepted(false)
setAttempt(null)
setQuestions([])
setAnswers({})
setTimings({})
setMarked(new Set())
setVisited(new Set())
setCurrent(0)
attemptRef.current = null
questionsRef.current = []
answersRef.current = {}
timingsRef.current = {}
loadedRef.current = false
try {
const testRes = await testAPI.getTest(testId)
if (cancelled) return
setTest(testRes.data)
} catch (err) {
toast.error(err.response?.data?.detail || 'Failed to load test')
navigate('/tests')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
clearInterval(autoSaveRef.current)
}
}, [testId, navigate])
const beginTest = useCallback(async () => {
if (!accepted || starting || started) return
setStarting(true)
loadedRef.current = false
try {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen?.()
}
} catch {
// Browsers may reject fullscreen in some environments; show recovery after load.
}
let attemptData
let qData
if (isPreview) {
attemptData = {
id: 'preview',
attempt_number: 'Preview',
max_attempts: '∞',
started_at: new Date().toISOString(),
}
const qRes = await adminAPI.getQuestions(testId)
qData = qRes.data
} else {
const attemptRes = await testAPI.startTest(testId)
attemptData = attemptRes.data
const qRes = await testAPI.getQuestions(testId, attemptData.id)
qData = qRes.data
}
setAttempt(attemptData)
attemptRef.current = attemptData
setAttemptNumber(attemptData.attempt_number || 1)
setMaxAttempts(attemptData.max_attempts || 6)
setQuestions(qData)
questionsRef.current = qData
setVisited(qData[0] ? new Set([qData[0].id]) : new Set())
setAnswers({})
setTimings({})
setMarked(new Set())
setCurrent(0)
setNatInput('')
setTabViolations(0)
setFsViolations(0)
setShowFsWarning(false)
tabViolRef.current = 0
fsViolRef.current = 0
answersRef.current = {}
timingsRef.current = {}
setStarted(true)
setTimeout(() => {
loadedRef.current = true
if (!document.fullscreenElement) {
setShowFsWarning(true)
}
}, 2000)
} catch (err) {
console.error('TestEngine beginTest error:', err)
toast.error(err.response?.data?.detail || 'Failed to start test')
navigate('/tests')
} finally {
setStarting(false)
}
}, [accepted, navigate, starting, started, testId, isPreview])
const doSubmitCore = useCallback(async (auto = false) => {
if (submitLockRef.current) return
submitLockRef.current = true
setSubmitting(true)
clearInterval(autoSaveRef.current)
try {
if (document.fullscreenElement) await document.exitFullscreen()
} catch {}
if (isPreview) {
if (auto) toast('Time up! Auto-submitted preview.', { duration: 5000 })
else toast.success('Preview submitted successfully! (No data saved)')
submitLockRef.current = false
setSubmitting(false)
navigate(`/admin/tests/${testIdRef.current}`)
return
}
const currentAttempt = attemptRef.current
const currentAnswers = answersRef.current
const currentQuestions = questionsRef.current
const currentTimings = timingsRef.current
if (!currentAttempt) {
submitLockRef.current = false
setSubmitting(false)
return
}
try {
const ans = currentQuestions.map(q => ({
question_id: q.id,
selected_answer: currentAnswers[q.id] || null,
time_spent_seconds: currentTimings[q.id] || 0,
}))
const res = await testAPI.submitTest(testIdRef.current, currentAttempt.id, ans)
if (auto) toast('Time up! Auto-submitted.', { duration: 5000 })
else toast.success('Test submitted successfully!')
if (res.data?.persisted === false && res.data?.result) {
const result = res.data.result
const resultId = result.client_result_id || result.attempt_id || res.data.id
sessionStorage.setItem(`practice-result:${resultId}`, JSON.stringify(result))
navigate(`/results/${resultId}`, { state: { result } })
return
}
navigate(`/results/${res.data.id}`)
} catch (err) {
toast.error(err.response?.data?.detail || 'Submit failed. Please try again.')
submitLockRef.current = false
setSubmitting(false)
}
}, [isPreview, navigate])
const doSubmitRef = useRef(doSubmitCore)
useEffect(() => { doSubmitRef.current = doSubmitCore }, [doSubmitCore])
const doSubmit = useCallback((auto = false) => doSubmitRef.current(auto), [])
const handleTimerExpire = useCallback(() => {
doSubmitRef.current(true)
}, [])
useAntiCheat({
attemptRef,
doSubmitRef,
fsViolRef,
isPreview,
loadedRef,
setFsViolations,
setShowFsWarning,
setTabViolations,
tabViolRef,
testIdRef,
})
useEffect(() => {
const q = questions[current]
if (!q) return
questionStartRef.current = Date.now()
return () => {
const elapsed = Math.floor((Date.now() - questionStartRef.current) / 1000)
if (elapsed > 0) {
setTimings(t => {
const updated = { ...t, [q.id]: (t[q.id] || 0) + elapsed }
timingsRef.current = updated
return updated
})
}
}
}, [current, questions])
useEffect(() => {
const q = questions[current]
if (q) setVisited(v => new Set([...v, q.id]))
}, [current, questions])
useEffect(() => {
const q = questions[current]
if (q?.question_type === 'nat') setNatInput(answers[q.id] || '')
}, [current, questions, answers])
useEffect(() => {
if (!attempt || isPreview) return
autoSaveRef.current = setInterval(() => {
const ans = Object.entries(answersRef.current).map(([qid, sel]) => ({
question_id: +qid,
selected_answer: sel,
time_spent_seconds: timingsRef.current[+qid] || 0,
}))
if (ans.length > 0) {
testAPI.saveAnswers(testIdRef.current, attemptRef.current?.id, ans).catch(() => {})
}
}, 30000)
return () => clearInterval(autoSaveRef.current)
}, [attempt, isPreview])
const currentQuestion = questions[current]
const setMCQ = useCallback((letter) => {
if (!currentQuestion) return
setAnswers(a => {
const updated = { ...a, [currentQuestion.id]: a[currentQuestion.id] === letter ? undefined : letter }
answersRef.current = updated
return updated
})
}, [currentQuestion])
const toggleMSQ = useCallback((letter) => {
if (!currentQuestion) return
setAnswers(a => {
const cur = (a[currentQuestion.id] || '').split(',').filter(Boolean)
const next = cur.includes(letter) ? cur.filter(l => l !== letter) : [...cur, letter].sort()
const updated = { ...a, [currentQuestion.id]: next.join(',') || undefined }
answersRef.current = updated
return updated
})
}, [currentQuestion])
const commitNAT = useCallback(() => {
if (!currentQuestion) return
setAnswers(a => {
const updated = { ...a, [currentQuestion.id]: natInput.trim() || undefined }
answersRef.current = updated
return updated
})
}, [currentQuestion, natInput])
const saveAndNext = useCallback(() => {
if (currentQuestion?.question_type === 'nat') commitNAT()
if (current < questions.length - 1) setCurrent(c => c + 1)
}, [commitNAT, current, currentQuestion, questions.length])
const markAndNext = useCallback(() => {
if (!currentQuestion) return
setMarked(m => {
const n = new Set(m)
n.has(currentQuestion.id) ? n.delete(currentQuestion.id) : n.add(currentQuestion.id)
return n
})
if (current < questions.length - 1) setCurrent(c => c + 1)
}, [current, currentQuestion, questions.length])
const clearResponse = useCallback(() => {
if (!currentQuestion) return
setAnswers(a => {
const n = { ...a }
delete n[currentQuestion.id]
answersRef.current = n
return n
})
setNatInput('')
}, [currentQuestion])
const subjects = useMemo(() => {
return [...new Set(questions.map(q => q.subject || 'General'))]
}, [questions])
const answered = useMemo(() => {
return Object.values(answers).filter(Boolean).length
}, [answers])
const notAnswered = questions.length - answered
const totalViolations = tabViolations + fsViolations
const endTimeMs = useMemo(
() => getEndTimeMs(attempt?.started_at, test?.duration_minutes),
[attempt?.started_at, test?.duration_minutes]
)
return {
accepted,
answered,
answers,
attemptNumber,
beginTest,
clearResponse,
commitNAT,
current,
currentQuestion,
doSubmit,
endTimeMs,
fsViolations,
handleTimerExpire,
loading,
markAndNext,
marked,
maxAttempts,
natInput,
navigate,
notAnswered,
questions,
saveAndNext,
setAccepted,
setCurrent,
setNatInput,
setShowCalc,
setShowConfirm,
setShowFsWarning,
setShowPalette,
setMCQ,
showCalc,
showConfirm,
showFsWarning,
showPalette,
started,
starting,
subjects,
submitting,
tabViolations,
test,
toggleMSQ,
totalViolations,
visited,
}
}