/* final-exam.jsx * * Final Exam Mode — a one-shot 100-question, 120-minute simulation of the * real CCNA. Questions come from src/final-exam-questions.json, which was * parsed once from uploads/CCNA_Study_Guides_Enhanced/section_11_cumulative_exam.md * (see parse_final_exam.py). * * Why a separate view and not just a longer Quiz: * - 51 of 100 questions are short-answer (free-text), not MCQ. The * existing Quiz UI is MCQ-only. * - Real-exam simulation means NO per-question feedback during; results * and reasoning are shown ONLY at the end. The Quiz UI shows feedback * as you go. Different mode, different contract. * - The timer is intentionally prominent. The whole point is pressure. * * Honest UX flag (also surfaced in the intro screen, not hidden): * Free-text grading is imperfect. We normalize aggressively (case, * whitespace, surrounding punctuation, common variant spellings) and * accept a match if the user's text contains the canonical answer or * vice-versa for short canonical answers. We ALWAYS show the canonical * answer next to theirs in the review, so a false-negative is easy to * spot and self-correct. * * State machine: * intro → running (Start) * running → results (Submit, or timer hits 0) * results → intro (Take again) */ (function () { "use strict"; const STORAGE_KEY = "ccna.final_exam.attempts"; const EXAM_LENGTH_MINUTES = 120; // ──────────────── Free-text grading ──────────────── // Used for short-answer questions. Returns true if `user` matches `canon` // under generous normalization. False-negatives are still possible // (impossible to avoid completely); UI ALWAYS shows the canonical answer // beside the user's so they can self-correct. function normalize(s) { if (s == null) return ""; return String(s) .toLowerCase() .replace(/[.,;:!?'"`]/g, " ") .replace(/\s+/g, " ") .trim(); } function gradeShortAnswer(userText, canon) { const u = normalize(userText); const c = normalize(canon); if (!u || !c) return false; if (u === c) return true; // Strip leading "yes." / "no." / "true." / "false." prefixes — both sides. const stripLead = (x) => x.replace(/^(yes|no|true|false)\b\s*/, "").trim(); const uS = stripLead(u); const cS = stripLead(c); if (uS && uS === cS) return true; // For short canonical answers (≤4 words), accept if user contains it. // E.g. canon "udp 514", user types "udp 514 (syslog default)". if (c.split(" ").length <= 4 && u.includes(c)) return true; // Also accept if canon contains user (user gave a shorter version). // E.g. canon "authentication, authorization, accounting", user "aaa". if (u.split(" ").length <= 2 && c.includes(u) && u.length >= 3) return true; // Numbers: tolerate trailing units / explanation in parens. const numMatch = c.match(/^([\d.\/]+)$/); if (numMatch && u.startsWith(numMatch[1])) return true; return false; } function gradeMC(userAnswer, canonAnswer) { // canonAnswer is always an array of correct letters (post-schema-migration). // userAnswer is a string (single-answer) or array (multi-select). const canonSet = new Set(Array.isArray(canonAnswer) ? canonAnswer : [canonAnswer]); const userArr = Array.isArray(userAnswer) ? userAnswer : (userAnswer ? [userAnswer] : []); if (userArr.length !== canonSet.size) return false; for (const l of userArr) if (!canonSet.has((l || "").toUpperCase())) return false; return true; } // Multi-select MCQs are those with multiple correct letters. function isMultiSelect(q) { return q.type === "mc" && Array.isArray(q.correct) && q.correct.length > 1; } function formatTime(seconds) { if (seconds < 0) seconds = 0; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } // ──────────────── Intro screen ──────────────── function Intro({ questions, lastAttempt, onStart }) { const counts = React.useMemo(() => { const c = { mc: 0, short: 0, byDomain: {} }; questions.forEach((q) => { c[q.type]++; c.byDomain[q.domain] = (c.byDomain[q.domain] || 0) + 1; }); return c; }, [questions]); return React.createElement( "div", { className: "fe-intro" }, React.createElement("h2", null, "Final Exam — CCNA Cumulative"), React.createElement( "p", { className: "fe-blurb" }, "A timed, 100-question simulation drawn from the cumulative exam in your study guide. The real CCNA gives you 120 minutes. So does this. No feedback until you submit." ), React.createElement( "div", { className: "fe-card-row" }, React.createElement( "div", { className: "fe-card" }, React.createElement("div", { className: "fe-card-num" }, questions.length), React.createElement("div", { className: "fe-card-label" }, "Questions") ), React.createElement( "div", { className: "fe-card" }, React.createElement("div", { className: "fe-card-num" }, EXAM_LENGTH_MINUTES), React.createElement("div", { className: "fe-card-label" }, "Minutes") ), React.createElement( "div", { className: "fe-card" }, React.createElement("div", { className: "fe-card-num" }, counts.mc), React.createElement("div", { className: "fe-card-label" }, "Multiple-choice") ), React.createElement( "div", { className: "fe-card" }, React.createElement("div", { className: "fe-card-num" }, counts.short), React.createElement("div", { className: "fe-card-label" }, "Short-answer") ) ), React.createElement( "div", { className: "fe-section" }, React.createElement("h3", null, "Domain coverage"), React.createElement( "ul", { className: "fe-domain-list" }, Object.entries(counts.byDomain) .sort() .map(([d, n]) => React.createElement( "li", { key: d }, React.createElement("span", { className: "fe-d-name" }, d), React.createElement("span", { className: "fe-d-n" }, `${n} questions`) ) ) ) ), React.createElement( "div", { className: "fe-warning" }, React.createElement("strong", null, "Honest note on grading: "), "Short-answer questions (about half the exam) are graded by text matching with generous normalization. The grader is conservative — if your answer is essentially right but worded differently, it may mark you wrong. The review screen always shows the expected answer next to yours so you can self-correct. Score this exam as a directional gauge, not an absolute one." ), lastAttempt && React.createElement( "div", { className: "fe-section fe-last" }, React.createElement("h3", null, "Last attempt"), React.createElement( "p", null, `${lastAttempt.correct} / ${lastAttempt.total} correct (${Math.round((lastAttempt.correct / lastAttempt.total) * 100)}%) — ${new Date(lastAttempt.finishedAt).toLocaleString()}` ) ), React.createElement( "div", { className: "fe-actions" }, React.createElement( "button", { className: "btn btn-primary fe-start", onClick: onStart }, "Start exam (", EXAM_LENGTH_MINUTES, " min)" ) ) ); } // ──────────────── Running exam ──────────────── function Running({ questions, onSubmit }) { const [answers, setAnswers] = React.useState(() => new Array(questions.length).fill("")); const [idx, setIdx] = React.useState(0); const [secondsLeft, setSecondsLeft] = React.useState(EXAM_LENGTH_MINUTES * 60); const [confirmOpen, setConfirmOpen] = React.useState(false); const startedAt = React.useRef(Date.now()); // Timer React.useEffect(() => { const tick = setInterval(() => { setSecondsLeft((s) => { if (s <= 1) { clearInterval(tick); // Use setTimeout so we exit the render cycle before onSubmit // mutates parent state. setTimeout(() => onSubmit(answers, startedAt.current, true), 0); return 0; } return s - 1; }); }, 1000); return () => clearInterval(tick); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const q = questions[idx]; const answered = answers.filter((a) => { if (Array.isArray(a)) return a.length > 0; return a && String(a).trim(); }).length; function setAnswer(v) { setAnswers((prev) => { const next = prev.slice(); next[idx] = v; return next; }); } function go(delta) { const nx = Math.max(0, Math.min(questions.length - 1, idx + delta)); setIdx(nx); } function jumpTo(i) { setIdx(i); } function handleSubmit() { setConfirmOpen(true); } function confirmSubmit() { onSubmit(answers, startedAt.current, false); } const lowTime = secondsLeft < 15 * 60; return React.createElement( "div", { className: "fe-running" }, // Timer + progress bar React.createElement( "div", { className: `fe-toolbar ${lowTime ? "fe-low" : ""}` }, React.createElement("div", { className: "fe-timer" }, formatTime(secondsLeft)), React.createElement( "div", { className: "fe-progress" }, `Question ${idx + 1} of ${questions.length} • ${answered} answered` ), React.createElement( "button", { className: "btn btn-primary fe-submit", onClick: handleSubmit }, "Submit exam" ) ), // Current question React.createElement( "div", { className: "fe-qcard" }, React.createElement("div", { className: "fe-qmeta" }, `Q${q.id} • ${q.domain}`), React.createElement( "div", { className: "fe-qtext" }, q.question, isMultiSelect(q) && React.createElement( "span", { className: "fe-multi-badge" }, `Pick ${q.correct.length}` ) ), q.type === "mc" ? React.createElement( "div", { className: "fe-options" }, q.options.map(([letter, text]) => { const multi = isMultiSelect(q); const cur = answers[idx]; const selectedSet = new Set(Array.isArray(cur) ? cur : (cur ? [cur] : [])); const isSelected = selectedSet.has(letter); const onToggle = () => { if (multi) { const next = new Set(selectedSet); if (next.has(letter)) next.delete(letter); else next.add(letter); setAnswer(Array.from(next).sort()); } else { setAnswer(letter); } }; return React.createElement( "label", { key: letter, className: `fe-opt ${isSelected ? "selected" : ""}`, }, React.createElement("input", { type: multi ? "checkbox" : "radio", name: `q-${q.id}`, checked: isSelected, onChange: onToggle, }), React.createElement("span", { className: "fe-opt-letter" }, letter), React.createElement("span", { className: "fe-opt-text" }, text) ); }) ) : React.createElement("textarea", { className: "fe-shortans", rows: 3, placeholder: "Type your answer…", value: answers[idx], onChange: (e) => setAnswer(e.target.value), }) ), // Prev/Next React.createElement( "div", { className: "fe-nav" }, React.createElement( "button", { className: "btn", disabled: idx === 0, onClick: () => go(-1) }, "← Previous" ), React.createElement( "button", { className: "btn", disabled: idx === questions.length - 1, onClick: () => go(1), }, "Next →" ) ), // Jump grid React.createElement( "div", { className: "fe-grid" }, questions.map((qq, i) => { const a = answers[i]; const hasAns = Array.isArray(a) ? a.length > 0 : !!a; return React.createElement( "button", { key: qq.id, className: `fe-grid-cell ${ i === idx ? "current" : "" } ${hasAns ? "answered" : ""}`, onClick: () => jumpTo(i), title: `Q${qq.id} ${hasAns ? "(answered)" : "(unanswered)"}`, }, qq.id ); }) ), // Submit-confirm modal confirmOpen && React.createElement( "div", { className: "fe-modal-backdrop", onClick: () => setConfirmOpen(false) }, React.createElement( "div", { className: "fe-modal", onClick: (e) => e.stopPropagation() }, React.createElement("h3", null, "Submit exam?"), React.createElement( "p", null, `${answered} of ${questions.length} answered. ${ answered < questions.length ? `${questions.length - answered} will be marked wrong.` : "All questions answered." }` ), React.createElement( "div", { className: "fe-modal-actions" }, React.createElement( "button", { className: "btn", onClick: () => setConfirmOpen(false) }, "Keep working" ), React.createElement( "button", { className: "btn btn-primary", onClick: confirmSubmit }, "Submit" ) ) ) ) ); } // ──────────────── Results ──────────────── function Results({ questions, answers, finishedAt, startedAt, timedOut, onRestart }) { const graded = React.useMemo( () => questions.map((q, i) => { const user = answers[i]; const correct = q.type === "mc" ? gradeMC(user, q.correct) : gradeShortAnswer(user, q.correct); return { q, user, correct }; }), [questions, answers] ); const totalCorrect = graded.filter((g) => g.correct).length; const pct = Math.round((totalCorrect / graded.length) * 100); // Per-domain breakdown const byDomain = {}; graded.forEach((g) => { const d = g.q.domain; if (!byDomain[d]) byDomain[d] = { total: 0, correct: 0 }; byDomain[d].total++; if (g.correct) byDomain[d].correct++; }); const verdict = pct >= 95 ? { tone: "great", text: "Exam-ready territory." } : pct >= 85 ? { tone: "good", text: "Strong. Review weak spots, then re-test." } : pct >= 75 ? { tone: "warn", text: "Borderline. Real exam is harder than practice." } : pct >= 60 ? { tone: "warn", text: "More study needed. Re-read weak domains." } : { tone: "bad", text: "Significant gaps. Step back to chapter-level review." }; const minutesUsed = Math.round((finishedAt - startedAt) / 60000); // Save attempt React.useEffect(() => { try { const arr = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); arr.push({ finishedAt, startedAt, total: graded.length, correct: totalCorrect, minutesUsed, timedOut, }); // Keep last 20 const trimmed = arr.slice(-20); localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); } catch (_) { /* localStorage may be disabled */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [filter, setFilter] = React.useState("missed"); // 'missed' | 'all' return React.createElement( "div", { className: "fe-results" }, React.createElement( "div", { className: "fe-score-hero" }, React.createElement( "div", { className: `fe-score-num tone-${verdict.tone}` }, `${totalCorrect} / ${graded.length}` ), React.createElement( "div", { className: "fe-score-pct" }, `${pct}%`, timedOut && React.createElement( "span", { className: "fe-timed-out" }, " (timed out)" ) ), React.createElement("div", { className: "fe-verdict" }, verdict.text), React.createElement( "div", { className: "fe-meta" }, `Completed in ${minutesUsed} min` ) ), React.createElement( "div", { className: "fe-section" }, React.createElement("h3", null, "By domain"), React.createElement( "table", { className: "fe-domain-table" }, React.createElement( "thead", null, React.createElement( "tr", null, React.createElement("th", null, "Domain"), React.createElement("th", null, "Score"), React.createElement("th", null, "%") ) ), React.createElement( "tbody", null, Object.entries(byDomain) .sort() .map(([d, v]) => React.createElement( "tr", { key: d }, React.createElement("td", null, d), React.createElement("td", null, `${v.correct} / ${v.total}`), React.createElement( "td", null, `${Math.round((v.correct / v.total) * 100)}%` ) ) ) ) ) ), React.createElement( "div", { className: "fe-section" }, React.createElement( "div", { className: "fe-review-header" }, React.createElement("h3", null, "Review"), React.createElement( "div", { className: "fe-filter" }, React.createElement( "button", { className: `btn btn-sm ${filter === "missed" ? "btn-primary" : ""}`, onClick: () => setFilter("missed"), }, `Missed only (${graded.filter((g) => !g.correct).length})` ), React.createElement( "button", { className: `btn btn-sm ${filter === "all" ? "btn-primary" : ""}`, onClick: () => setFilter("all"), }, `All (${graded.length})` ) ) ), React.createElement( "div", { className: "fe-review-list" }, graded .filter((g) => (filter === "missed" ? !g.correct : true)) .map((g, i) => React.createElement( "div", { key: g.q.id, className: `fe-review-item ${g.correct ? "ok" : "bad"}`, }, React.createElement( "div", { className: "fe-review-head" }, React.createElement( "span", { className: "fe-review-qnum" }, `Q${g.q.id}` ), React.createElement( "span", { className: "fe-review-domain" }, g.q.domain ), React.createElement( "span", { className: `fe-review-tag ${ g.correct ? "tag-ok" : "tag-bad" }`, }, g.correct ? "✓ correct" : "✗ wrong" ) ), React.createElement( "div", { className: "fe-review-q" }, g.q.question ), g.q.type === "mc" && React.createElement( "div", { className: "fe-review-opts" }, (() => { const correctSet = new Set(Array.isArray(g.q.correct) ? g.q.correct : [g.q.correct]); const userSet = new Set(Array.isArray(g.user) ? g.user : (g.user ? [g.user] : [])); return g.q.options.map(([letter, text]) => React.createElement( "div", { key: letter, className: `fe-review-opt ${ correctSet.has(letter) ? "is-correct" : "" } ${ userSet.has(letter) && !correctSet.has(letter) ? "was-wrong" : "" }`, }, `${letter}) ${text}` ) ); })() ), g.q.type === "short" && React.createElement( "div", { className: "fe-review-ans" }, React.createElement( "div", { className: "fe-ans-row" }, React.createElement( "span", { className: "fe-ans-label" }, "Your answer:" ), React.createElement( "span", { className: "fe-ans-text" }, g.user || React.createElement("em", null, "(blank)") ) ), React.createElement( "div", { className: "fe-ans-row" }, React.createElement( "span", { className: "fe-ans-label" }, "Expected:" ), React.createElement( "span", { className: "fe-ans-text fe-ans-canon" }, g.q.correct ) ) ), g.q.explanation && React.createElement( "div", { className: "fe-review-ex" }, React.createElement( "strong", null, "Why: " ), g.q.explanation ) ) ) ) ), React.createElement( "div", { className: "fe-actions" }, React.createElement( "button", { className: "btn btn-primary", onClick: onRestart }, "Take exam again" ) ) ); } // ──────────────── Outer state machine ──────────────── function FinalExamMode({ onExit }) { const [phase, setPhase] = React.useState("intro"); const [submission, setSubmission] = React.useState(null); const questions = window.FINAL_EXAM_QUESTIONS || []; const lastAttempt = React.useMemo(() => { try { const arr = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); return arr.length ? arr[arr.length - 1] : null; } catch (_) { return null; } }, [phase]); if (!questions.length) { return React.createElement( "div", { className: "fe-empty" }, "Final exam questions failed to load. Check that ", React.createElement("code", null, "src/final-exam-questions.json"), " is being served and the ", React.createElement("code", null, "