/* 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"; // ──────────────── Per-cert config ──────────────── // The Final Exam mode is now cert-aware. Length, title, blurb, and which // question array to load all depend on the current certMode. The shape // here mirrors what each cert's real exam looks like so the simulation // is faithful: SY0-701 is 90q/90min, CCNA 200-301 is 100q/120min. // // STORAGE_KEY is informational only — actual persistence flows through // UserData.getExamAttempts(certId)/setExamAttempts(certId, arr), which // namespaces by cert internally. const CERT_CONFIG = { ccna: { titleSuffix: "CCNA Cumulative", examName: "CCNA", lengthMinutes: 120, questionCount: 100, 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.", storageKey: "ccna.final_exam.attempts", // informational }, ccnp: { // CCNP final-exam content is deferred. The FinalExamMode renders an // under-construction screen instead of falling back to the CCNA exam. titleSuffix: "CCNP Enterprise", examName: "CCNP", underConstruction: true, lengthMinutes: 0, questionCount: 0, blurb: "", storageKey: null, }, netplus: { titleSuffix: "Network+ N10-009", examName: "Net+", lengthMinutes: 90, questionCount: 100, blurb: "A timed, 100-question simulation modeled on the N10-009. The real Network+ gives you 90 minutes. So does this. Covers all five exam domains weighted to CompTIA objectives. No feedback until you submit.", storageKey: "final_exam.attempts.netplus", }, "aplus1": { // A+ Core 1 (220-1201) — 99-question final exam, 90 minutes (matching CompTIA). titleSuffix: "A+ Core 1 (220-1201)", examName: "A+ Core 1", lengthMinutes: 90, questionCount: 99, blurb: "A timed, 99-question simulation modeled on the 220-1201. The real A+ Core 1 gives you 90 minutes. So does this. Covers all five domains: Mobile Devices, Networking, Hardware, Virtualization & Cloud, and Hardware/Network Troubleshooting. No feedback until you submit.", storageKey: "final_exam.attempts.aplus1", }, "aplus2": { // A+ Core 2 (220-1202) — 100-question final exam, 90 minutes. titleSuffix: "A+ Core 2 (220-1202)", examName: "A+ Core 2", lengthMinutes: 90, questionCount: 100, blurb: "A timed, 100-question simulation modeled on the 220-1202. The real A+ Core 2 gives you 90 minutes. So does this. Covers all four domains: Operating Systems, Security, Software Troubleshooting, and Operational Procedures. No feedback until you submit.", storageKey: "final_exam.attempts.aplus2", }, secplus: { titleSuffix: "Security+ SY0-701", examName: "Security+", lengthMinutes: 90, questionCount: 90, blurb: "A timed, 90-question simulation modeled on the SY0-701. The real Security+ gives you 90 minutes. So does this. Drawn from the Practice question bank; covers all five exam domains weighted to CompTIA objectives. No feedback until you submit.", storageKey: "final_exam.attempts.secplus", }, }; function getCertConfig() { const cert = (window.UserData && typeof window.UserData.getCertMode === "function" ? window.UserData.getCertMode() : null) || "ccna"; return { cert, cfg: CERT_CONFIG[cert] || CERT_CONFIG.ccna }; } function getQuestionsFor(cert) { const byCert = window.FINAL_EXAM_BY_CERT || {}; return byCert[cert] || window.FINAL_EXAM_QUESTIONS || []; } // ──────────────── 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; } // Grade a drag-drop submission. The component records answers[idx] as the // result object it returned: { score, placements }. All-or-nothing in the // final exam — only 100% counts as correct, matching the binary scoring // used for mc/short questions. function gradeDD(userResult) { if (!userResult || typeof userResult !== "object") return false; return userResult.score === 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, cfg }) { const counts = React.useMemo(() => { const c = { mc: 0, short: 0, dd: 0, byDomain: {} }; questions.forEach((q) => { c[q.type] = (c[q.type] || 0) + 1; 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 — " + cfg.titleSuffix), React.createElement( "p", { className: "fe-blurb" }, cfg.blurb ), React.createElement( "div", { className: "fe-card-row fe-card-row-meta" }, 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" }, cfg.lengthMinutes), React.createElement("div", { className: "fe-card-label" }, "Minutes") ) ), React.createElement( "div", { className: "fe-card-row fe-card-row-types" }, 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") ), counts.dd > 0 && React.createElement( "div", { className: "fe-card" }, React.createElement("div", { className: "fe-card-num" }, counts.dd), React.createElement("div", { className: "fe-card-label" }, "Drag-and-drop") ) ), 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 (", cfg.lengthMinutes, " min)" ) ) ); } // ──────────────── Running exam ──────────────── function Running({ questions, onSubmit, onExit, cfg }) { const [answers, setAnswers] = React.useState(() => new Array(questions.length).fill("")); const [idx, setIdx] = React.useState(0); const [secondsLeft, setSecondsLeft] = React.useState(cfg.lengthMinutes * 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-sm btn-ghost fe-exit", onClick: () => { if (window.confirm("Leave the exam? Your progress on this attempt will be lost.")) { onExit(); } }, }, "Exit" ), 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) ); }) ) : q.type === "dd" ? // Drag-and-drop renders its own UI. answers[idx] is the result // object the component returned on last submit, or undefined. // The component is keyed by q.id so navigating away & back resets // its internal state — by design (real-exam vibe). Stored result // is what we grade against; the visible state is per-mount. React.createElement(window.DragDropQuestion, { key: q.id, question: q, onSubmit: (result) => setAnswer(result), }) : 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" ) ) ) ) ); } // ──────────────── Review item (one per question in the review list) ──────────────── // Wraps the per-question review UI with a "Report this question" button that // opens the shared ReportQuestionModal (defined in quiz.jsx). Each item has // its own modal state so multiple can be reported in one session. function ReviewItem({ g, certMode }) { const [reportOpen, setReportOpen] = React.useState(false); return React.createElement( "div", { 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.type === "dd" && React.createElement( "div", { className: "fe-review-ans" }, React.createElement( "div", { className: "fe-ans-row" }, React.createElement("span", { className: "fe-ans-label" }, "Your placements:"), React.createElement( "span", { className: "fe-ans-text" }, (() => { if (!g.user || !g.user.placements) return React.createElement("em", null, "(not submitted)"); const ans = g.q.ans || {}; const items = g.q.items || []; const zones = (g.q.diagram && g.q.diagram.zones) || []; const zoneLabel = (zid) => (zones.find((z) => z.id === zid) || {}).label || zid; const lines = items.map((it) => { const placed = g.user.placements[it.id]; const expected = ans[it.id]; const ok = placed === expected; return `${ok ? "✓" : "✗"} ${it.t} → ${placed ? zoneLabel(placed) : "(unplaced)"}${ok ? "" : ` (expected: ${zoneLabel(expected)})`}`; }); return lines.join(" • "); })() ) ), g.user && typeof g.user.score === "number" && React.createElement( "div", { className: "fe-ans-row" }, React.createElement("span", { className: "fe-ans-label" }, "Score:"), React.createElement("span", { className: "fe-ans-text fe-ans-canon" }, `${Math.round(g.user.score * 100)}% (need 100% for credit)`) ) ), g.q.explanation && React.createElement( "div", { className: "fe-review-ex" }, React.createElement("strong", null, "Why: "), g.q.explanation ), React.createElement( "div", { className: "fe-review-report-row" }, React.createElement( "button", { className: "fb-report-btn", onClick: () => setReportOpen(true), title: "Report a problem with this question", }, "⚑ Report this question" ) ), reportOpen && window.ReportQuestionModal && React.createElement(window.ReportQuestionModal, { q: { id: g.q.id, stem: g.q.question, exam: certMode }, source: "final_exam", certHint: certMode, onClose: () => setReportOpen(false), }) ); } // ──────────────── Results ──────────────── function Results({ questions, answers, finishedAt, startedAt, timedOut, onRestart }) { // Read current cert for the report-question form (sent in the API payload // so the operator knows which cert the question belongs to). const cert = getCertConfig().cert; const graded = React.useMemo( () => questions.map((q, i) => { const user = answers[i]; const correct = q.type === "mc" ? gradeMC(user, q.correct) : q.type === "dd" ? gradeDD(user) : 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 = window.UserData ? window.UserData.getExamAttempts() : []; arr.push({ finishedAt, startedAt, total: graded.length, correct: totalCorrect, minutesUsed, timedOut, }); // Keep last 20 const trimmed = arr.slice(-20); if (window.UserData) window.UserData.setExamAttempts(trimmed); } catch (_) { /* storage 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(ReviewItem, { key: g.q.id, g: g, certMode: cert, }) ) ) ), React.createElement( "div", { className: "fe-actions" }, React.createElement( "button", { className: "btn btn-primary", onClick: onRestart }, "Take exam again" ) ) ); } // ──────────────── Under-construction stub ──────────────── // Rendered for certs whose final-exam content isn't built yet. // Uses the terminal aesthetic to stay on-brand and communicate the // release status neutrally without drawing attention to gaps. // Box title and message adapt to the current cert. Whether to advertise // Learn/Practice as "live now" depends on whether the cert has content; // we check window.CONCEPTS for that. function UnderConstruction({ cert, cfg }) { // Build box title centred to the 45-char interior. Use the cert's // examName (e.g. "Net+") plus a fixed " — FINAL EXAM MODE" tag. const boxTitle = `${cfg.examName.toUpperCase()} — FINAL EXAM MODE`; const innerWidth = 45; let titleLine; if (boxTitle.length >= innerWidth) { titleLine = boxTitle.slice(0, innerWidth); } else { const padTotal = innerWidth - boxTitle.length; const left = Math.floor(padTotal / 2); const right = padTotal - left; titleLine = " ".repeat(left) + boxTitle + " ".repeat(right); } const diagram = [ " ┌─────────────────────────────────────────────┐", " │ │", " │" + titleLine + "│", " │ │", " ├─────────────────────────────────────────────┤", " │ │", " │ status ............... provisioning │", " │ release .............. coming soon │", " │ question bank ........ building │", " │ │", " └─────────────────────────────────────────────┘", ].join("\n"); // Has this cert got any Learn content or Practice questions yet? If yes, // we can point users there. If no (Net+ today), we keep the copy generic. const hasContent = ((window.CONCEPTS || []).some(c => c.cert === cert)) || ((window.Q || []).some(q => q.exam === cert)); const messageChildren = hasContent ? [ React.createElement("strong", { key: "cs" }, "Coming soon."), ` The ${cfg.examName} cumulative exam is on the roadmap. Until it ships, the `, React.createElement("strong", { key: "p" }, "Practice"), " and ", React.createElement("strong", { key: "l" }, "Learn"), ` tabs are the best way to drill ${cfg.examName} material — full topic walkthroughs and a growing question bank are live now.`, ] : [ React.createElement("strong", { key: "cs" }, "Coming soon."), ` The ${cfg.examName} track is being built — content, questions, and final exam are all on the roadmap. Want to be notified when it ships, or have input on what should be covered first? Use the `, React.createElement("strong", { key: "f" }, "Send feedback"), " link in the footer.", ]; return React.createElement( "div", { className: "fe-intro fe-uc" }, React.createElement("h2", null, "Final Exam — " + cfg.titleSuffix), React.createElement( "div", { className: "fe-uc-card" }, React.createElement( "div", { className: "fe-uc-status" }, "$ status --exam ", cert ), React.createElement( "pre", { className: "fe-uc-diagram" }, diagram ), React.createElement( "div", { className: "fe-uc-message" }, ...messageChildren ) ) ); } // ──────────────── Outer state machine ──────────────── function FinalExamMode({ onExit }) { const [phase, setPhase] = React.useState("intro"); const [submission, setSubmission] = React.useState(null); // Read cert at render time so a cert switch picks up immediately. const { cert, cfg } = getCertConfig(); // Under-construction cert (e.g. CCNP) → render a stub screen instead // of trying to load questions and falling back to a different cert. if (cfg.underConstruction) { return React.createElement(UnderConstruction, { cert, cfg }); } const questions = getQuestionsFor(cert); const lastAttempt = React.useMemo(() => { try { const arr = window.UserData ? window.UserData.getExamAttempts(cert) : []; return arr.length ? arr[arr.length - 1] : null; } catch (_) { return null; } }, [phase, cert]); if (!questions.length) { return React.createElement( "div", { className: "fe-empty" }, "Final exam questions failed to load for ", React.createElement("code", null, cert), ". Check that ", React.createElement("code", null, "src/final-exam-data.js"), " is being served and defines ", React.createElement("code", null, "window.FINAL_EXAM_BY_CERT"), "." ); } if (phase === "intro") { return React.createElement(Intro, { questions, lastAttempt, cfg, onStart: () => setPhase("running"), }); } if (phase === "running") { return React.createElement(Running, { questions, cfg, onExit, onSubmit: (answers, startedAt, timedOut) => { setSubmission({ answers, startedAt, finishedAt: Date.now(), timedOut, }); setPhase("results"); }, }); } if (phase === "results" && submission) { return React.createElement(Results, { questions, cert, cfg, answers: submission.answers, startedAt: submission.startedAt, finishedAt: submission.finishedAt, timedOut: submission.timedOut, onRestart: () => { setSubmission(null); setPhase("intro"); }, }); } return null; } window.FinalExamMode = FinalExamMode; })();