testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:32:00 +08:00
parent fe47bdea3c
commit 17326b2c2e
78 changed files with 4804 additions and 1054 deletions
+188 -139
View File
@@ -1,16 +1,29 @@
// components/QuizBlock.jsx
import { useState, useEffect, useRef, useCallback } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChevronLeft, ChevronRight,
Circle, CheckCircle2,
Square, CheckSquare2,
Clock, AlertTriangle, Info,
Clock, AlertTriangle, Info, ArrowRight,
} from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { useDateFormat } from "@/hooks/useDateFormat";
// Zod schema: every question must have a non-empty answer before submitting.
function buildAnswerSchema(questions = []) {
const shape = {};
for (const q of questions) {
const key = String(q.question_id);
shape[key] = q.type === "multi_select"
? z.array(z.unknown()).min(1, "This question requires at least one selection.")
: z.union([z.string(), z.number()]).refine((v) => v !== "" && v != null, "This question requires an answer.");
}
return z.object(shape);
}
function QuizSkeleton() {
return (
<div className="max-w-2xl mx-auto space-y-5">
@@ -50,7 +63,7 @@ function isQuestionAnswered(answer) {
* onActiveChange — (isActive: boolean) => void — fires when session starts/ends
* label — "Quiz" or "Assessment"
*/
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, label = "Quiz" }) => {
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, onNextContent, nextLabel, label = "Quiz" }) => {
const { fmtDateTime } = useDateFormat();
const questions = quiz?.questions ?? [];
const total = questions.length;
@@ -63,6 +76,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null);
const [reviewAttempted, setReviewAttempted] = useState(false);
const [submitError, setSubmitError] = useState(null);
// ── Timer state ───────────────────────────────────────────────────────────
const [remainingSeconds, setRemainingSeconds] = useState(null);
@@ -216,11 +230,25 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
};
const handleSubmit = useCallback(async () => {
// Zod: all questions must be answered before submission is allowed
const schema = buildAnswerSchema(questions);
const stringifiedAnswers = Object.fromEntries(
Object.entries(answers).map(([k, v]) => [String(k), v])
);
const parsed = schema.safeParse(stringifiedAnswers);
if (!parsed.success) {
const unanswered = questions.filter(
(q) => !isQuestionAnswered(answers[q.question_id])
).length;
setSubmitError(`Answer all questions before submitting — ${unanswered} still unanswered.`);
return;
}
setSubmitError(null);
setSubmitting(true);
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
setSubmitting(false);
if (res) { setResult(res); setStage("result"); }
}, [answers, onSubmit]);
}, [answers, questions, onSubmit]);
const handleOptionClick = (optionId) => {
const question = questions[currentIndex];
@@ -239,8 +267,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const handleGoToReview = () => {
setReviewAttempted(true);
const allAnswered = questions.every(q => isQuestionAnswered(answers[q.question_id]));
if (!allAnswered) return;
setStage("review");
};
@@ -382,7 +408,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ul className="space-y-2.5 text-sm text-foreground/80">
{hasTimeLimit && (
<li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" />
<span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>
The timer starts the moment you begin and{" "}
<strong>cannot be paused</strong>. Your assessment auto-submits when time runs out.
@@ -390,17 +416,17 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</li>
)}
<li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" />
<span>Answer each question before moving to the next. You can return to any answered question to change your answer.</span>
<span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>You can skip questions and return to them later. All questions must be answered before you can submit.</span>
</li>
<li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" />
<span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>
Your progress is <strong>saved automatically</strong> — you can safely resume if you lose connection.
</span>
</li>
<li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" />
<span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>Review all answers on the summary screen before final submission.</span>
</li>
</ul>
@@ -501,6 +527,20 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<p className="text-xs text-muted-foreground">Attempt #{result.attempt_number}</p>
)}
</div>
{result.passed && onNextContent && nextLabel && (
<div
onClick={onNextContent}
className="flex items-center gap-3 bg-card border rounded-xl px-4 py-3 shadow-sm hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start flex-1">
<span className="text-xs text-muted-foreground font-normal">Up next</span>
<span>{nextLabel}</span>
</div>
<ArrowRight className="size-4 text-muted-foreground shrink-0" />
</div>
)}
{!result.passed && (
<div className="flex justify-center">
<Button variant="outline" onClick={handleRetake}>Retake {label}</Button>
@@ -559,7 +599,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
{/* All questions grid */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<p className="text-sm font-semibold">All questions</p>
<div className="grid grid-cols-8 gap-2">
<div className="grid xs:grid-cols-8 lg:grid-cols-12 gap-2">
{questions.map((q, i) => {
const answered = isQuestionAnswered(answers[q.question_id]);
return (
@@ -569,7 +609,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className={`aspect-square rounded-lg text-sm font-medium transition-colors
${answered
? "bg-foreground text-background hover:opacity-80"
? "bg-primary text-background hover:opacity-80"
: "border-2 border-amber-400 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10"
}`}
>
@@ -580,7 +620,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-foreground inline-block" /> Answered
<span className="size-3 rounded-sm bg-primary inline-block" /> Answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-amber-400 inline-block" /> Unanswered
@@ -588,14 +628,21 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
<ChevronLeft className="size-4" />
Back to assessment
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit assessment"}
</Button>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
<ChevronLeft className="size-4" />
Back to assessment
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit assessment"}
</Button>
</div>
{submitError && (
<p className="text-xs text-red-600 dark:text-red-400 text-right flex items-center justify-end gap-1">
<AlertTriangle className="size-3 shrink-0" /> {submitError}
</p>
)}
</div>
</div>
);
@@ -622,7 +669,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className={`size-9 rounded-full text-sm font-semibold transition-colors
${answered
? "bg-foreground text-background hover:opacity-80"
? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border hover:bg-muted/80"
}`}
>
@@ -670,7 +717,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className="w-full flex items-center gap-4 rounded-xl border bg-card p-4 text-left hover:bg-muted/30 transition-colors"
>
<span className="shrink-0 size-8 rounded-full bg-foreground text-background text-sm font-semibold flex items-center justify-center">
<span className="shrink-0 size-8 rounded-full bg-primary text-background text-sm font-semibold flex items-center justify-center">
{i + 1}
</span>
<div className="flex-1 min-w-0">
@@ -691,14 +738,21 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
})}
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
<ChevronLeft className="size-4" />
Back to questions
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit quiz"}
</Button>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
<ChevronLeft className="size-4" />
Back to questions
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Submit quiz"}
</Button>
</div>
{submitError && (
<p className="text-xs text-red-600 dark:text-red-400 text-right flex items-center justify-end gap-1">
<AlertTriangle className="size-3 shrink-0" /> {submitError}
</p>
)}
</div>
</div>
);
@@ -776,9 +830,100 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── Left: question ── */}
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4 items-start">
{/* ── Right: sidebar — order-first on mobile so timer/progress sit above the question ── */}
<div className="space-y-2 order-first lg:order-last">
{/* Timer */}
{remainingSeconds !== null && (
<div className={`rounded-xl flex items-center justify-between gap-3 px-4 py-3 lg:block lg:p-4 ${
timeExpired
? "bg-red-600 text-white"
: remainingSeconds <= 60
? "bg-red-700 text-white"
: "bg-primary text-background"
}`}>
<p className="text-xs uppercase tracking-widest opacity-60">Time Remaining</p>
<p className="text-2xl lg:text-4xl font-bold font-mono tabular-nums lg:mt-1">
{timeExpired ? "00:00" : formatTime(remainingSeconds)}
</p>
</div>
)}
{/* Progress panel */}
<div className="rounded-xl border bg-card p-3 lg:p-4 space-y-2 lg:space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="text-muted-foreground">{answeredCount} / {total} answered</span>
</div>
{/* Number grid — scrollable after ~30 questions (3 rows on mobile, 4 on desktop) */}
<div className="overflow-y-auto max-h-24 lg:max-h-40">
<div className="grid grid-cols-10 lg:grid-cols-8 gap-1 lg:gap-1.5">
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
return (
<button
key={q.question_id}
type="button"
onClick={() => setCurrentIndex(i)}
disabled={timeExpired && submitting}
className={`aspect-square rounded text-xs font-medium transition-colors
${isCurrent
? "border-2 border-foreground bg-background text-foreground"
: answered
? "bg-primary text-background hover:opacity-80"
: "border border-border bg-background text-muted-foreground hover:bg-muted"
}`}
>
{i + 1}
</button>
);
})}
</div>
</div>
{/* Legend — desktop only */}
<div className="hidden lg:flex flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-primary inline-block shrink-0" />
Answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border border-border inline-block shrink-0" />
Not answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-foreground inline-block shrink-0" />
Current
</span>
</div>
{/* Auto-save indicator */}
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2">
<CheckCircle2 className="size-3.5 shrink-0" />
All answers saved automatically
</div>
{/* Review button */}
<Button
className="w-full"
onClick={handleGoToReview}
disabled={submitting}
>
Review &amp; submit
</Button>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div>
</div>
{/* ── Left: question — order-last on mobile so it appears below the compact sidebar ── */}
<div className="space-y-4 order-last lg:order-first">
{/* Question header */}
<div className="flex items-center gap-2.5 text-sm">
<span className="text-muted-foreground">Question {currentIndex + 1} of {total}</span>
@@ -823,102 +968,12 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" />
Previous
</Button>
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}>
<Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
{!submitting && <ChevronRight className="size-4" />}
</Button>
</div>
</div>
{/* ── Right: sidebar ── */}
<div className="space-y-3">
{/* Timer */}
{remainingSeconds !== null && (
<div className={`rounded-xl p-4 ${
timeExpired
? "bg-red-600 text-white"
: remainingSeconds <= 60
? "bg-red-700 text-white"
: "bg-foreground text-background"
}`}>
<p className="text-xs uppercase tracking-widest opacity-60">Time Remaining</p>
<p className="text-4xl font-bold font-mono mt-1 tabular-nums">
{timeExpired ? "00:00" : formatTime(remainingSeconds)}
</p>
</div>
)}
{/* Progress panel */}
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="text-muted-foreground">{answeredCount} / {total} answered</span>
</div>
{/* Number grid */}
<div className="grid grid-cols-8 gap-1.5">
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
const canJump = answered || isCurrent;
return (
<button
key={q.question_id}
type="button"
onClick={() => canJump && setCurrentIndex(i)}
disabled={(timeExpired && submitting)}
className={`aspect-square rounded text-xs font-medium transition-colors
${isCurrent
? "border-2 border-foreground bg-background text-foreground"
: answered
? "bg-foreground text-background hover:opacity-80"
: "border border-border bg-background text-muted-foreground cursor-default"
}`}
>
{i + 1}
</button>
);
})}
</div>
{/* Legend */}
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-foreground inline-block shrink-0" />
Answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border border-border inline-block shrink-0" />
Not answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-foreground inline-block shrink-0" />
Current
</span>
</div>
{/* Auto-save indicator */}
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2">
<CheckCircle2 className="size-3.5 shrink-0" />
All answers saved automatically
</div>
{/* Review button */}
<Button
className="w-full"
onClick={handleGoToReview}
disabled={submitting}
>
Review &amp; submit
</Button>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div>
</div>
</div>
</div>
);
@@ -941,25 +996,24 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div>
</div>
{/* Question number pills */}
{/* Question number pills — all freely clickable; skipped questions show as muted */}
<div className="flex flex-wrap gap-2">
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
const canJump = answered || isCurrent;
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
return (
<button
key={q.question_id}
type="button"
onClick={() => canJump && setCurrentIndex(i)}
onClick={() => setCurrentIndex(i)}
disabled={timeExpired && submitting}
className={`size-9 rounded-full text-sm font-semibold transition-colors
${isCurrent
? "border-2 border-primary bg-background text-foreground"
: answered
? "bg-foreground text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border cursor-default"
? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border hover:bg-muted/60"
}`}
>
{i + 1}
@@ -1025,16 +1079,11 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" />
Previous
</Button>
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}>
<Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
{!submitting && <ChevronRight className="size-4" />}
</Button>
</div>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-right">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div>
</div>
);