{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"test-runner","type":"registry:ui","title":"TestRunner","description":"An attempt end to end \u2014 questions, confirm-before-submit, and the result. Renders an ungraded attempt as awaiting grading rather than a failure.","package":"classroom","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["types"],"files":[{"path":"components\/fancy\/test-runner\/TestRunner.tsx","content":"import { useMemo, useState } from 'react';\r\nimport {\r\n    Badge,\r\n    Button,\r\n    Callout,\r\n    Card,\r\n    cn,\r\n    Heading,\r\n    Modal,\r\n    Progress,\r\n    Text,\r\n} from '@particle-academy\/react-fancy';\r\nimport {\r\n    QuestionRenderer,\r\n    type AnswerValue,\r\n    answerValueToPayload,\r\n} from '.\/QuestionRenderer';\r\nimport type { AnswerInput, Test, TestAttempt } from '..\/types';\r\n\r\nexport interface TestRunnerProps {\r\n    test: Test;\r\n    onSubmit: (answers: AnswerInput[]) => Promise<TestAttempt>;\r\n    onFinished?: (attempt: TestAttempt) => void;\r\n}\r\n\r\nexport function TestRunner({ test, onSubmit, onFinished }: TestRunnerProps) {\r\n    const questions = useMemo(() => test.questions ?? [], [test.questions]);\r\n    const [answers, setAnswers] = useState<Record<number, AnswerValue>>({});\r\n    const [submitting, setSubmitting] = useState(false);\r\n    const [result, setResult] = useState<TestAttempt | null>(null);\r\n    const [error, setError] = useState<string | null>(null);\r\n    const [confirmOpen, setConfirmOpen] = useState(false);\r\n\r\n    const answeredCount = Object.values(answers).filter(\r\n        (a) => a !== null && a !== undefined,\r\n    ).length;\r\n    const percent = questions.length\r\n        ? Math.round((answeredCount \/ questions.length) * 100)\r\n        : 0;\r\n    const allAnswered = answeredCount === questions.length && questions.length > 0;\r\n\r\n    async function handleSubmit(): Promise<void> {\r\n        setConfirmOpen(false);\r\n        setSubmitting(true);\r\n        setError(null);\r\n        try {\r\n            const payload: AnswerInput[] = questions.map(\r\n                (q) => answerValueToPayload(q.id, answers[q.id] ?? null) as AnswerInput,\r\n            );\r\n            const attempt = await onSubmit(payload);\r\n            setResult(attempt);\r\n            onFinished?.(attempt);\r\n        } catch (e) {\r\n            const message = e instanceof Error ? e.message : String(e);\r\n            setError(message);\r\n        } finally {\r\n            setSubmitting(false);\r\n        }\r\n    }\r\n\r\n    if (result) {\r\n        return <TestResult attempt={result} \/>;\r\n    }\r\n\r\n    return (\r\n        <>\r\n            <Card\r\n                variant=\"outlined\"\r\n                padding=\"none\"\r\n                className=\"!rounded-xl !shadow-sm overflow-hidden\"\r\n            >\r\n                <div className=\"px-6 py-5 border-b border-secondary-200\">\r\n                    <div className=\"flex items-start justify-between gap-4\">\r\n                        <div>\r\n                            <Heading as=\"h1\" size=\"xl\" weight=\"bold\">\r\n                                {test.title}\r\n                            <\/Heading>\r\n                            {test.description && (\r\n                                <Text className=\"!mt-1\" color=\"muted\">\r\n                                    {test.description}\r\n                                <\/Text>\r\n                            )}\r\n                        <\/div>\r\n                        <div className=\"flex flex-col items-end gap-2\">\r\n                            {test.is_final && (\r\n                                <Badge color=\"amber\" variant=\"soft\" size=\"md\">\r\n                                    Final exam\r\n                                <\/Badge>\r\n                            )}\r\n                            <Text size=\"xs\" color=\"muted\">\r\n                                Pass at {test.passing_score}%\r\n                            <\/Text>\r\n                        <\/div>\r\n                    <\/div>\r\n\r\n                    <div className=\"mt-4 grid gap-1.5\">\r\n                        <Progress\r\n                            value={percent}\r\n                            max={100}\r\n                            color=\"red\"\r\n                            size=\"sm\"\r\n                        \/>\r\n                        <div className=\"flex items-center justify-between\">\r\n                            <Text size=\"xs\" color=\"muted\">\r\n                                {answeredCount} of {questions.length} answered\r\n                            <\/Text>\r\n                            <Text size=\"xs\" weight=\"semibold\" className=\"!text-brand\">\r\n                                {percent}%\r\n                            <\/Text>\r\n                        <\/div>\r\n                    <\/div>\r\n                <\/div>\r\n\r\n                <div className=\"px-6 py-6 grid gap-5\">\r\n                    {questions.map((q, i) => (\r\n                        <Card\r\n                            key={q.id}\r\n                            variant=\"outlined\"\r\n                            padding=\"md\"\r\n                            className=\"!rounded-lg\"\r\n                        >\r\n                            <div className=\"flex items-center justify-between mb-3\">\r\n                                <Text size=\"xs\" weight=\"semibold\" className=\"uppercase tracking-wide\" color=\"muted\">\r\n                                    Question {i + 1} of {questions.length}\r\n                                <\/Text>\r\n                                <Badge color=\"zinc\" variant=\"soft\" size=\"sm\">\r\n                                    {q.points} pt{q.points === 1 ? '' : 's'}\r\n                                <\/Badge>\r\n                            <\/div>\r\n                            <QuestionRenderer\r\n                                question={q}\r\n                                value={answers[q.id] ?? null}\r\n                                onChange={(next) =>\r\n                                    setAnswers((prev) => ({ ...prev, [q.id]: next }))\r\n                                }\r\n                            \/>\r\n                        <\/Card>\r\n                    ))}\r\n                <\/div>\r\n\r\n                {error && (\r\n                    <div className=\"px-6 pb-4\">\r\n                        <Callout color=\"red\">{error}<\/Callout>\r\n                    <\/div>\r\n                )}\r\n\r\n                <div className=\"px-6 py-4 border-t border-secondary-200 bg-secondary-50\/50 flex items-center justify-between gap-3\">\r\n                    <Text size=\"sm\" color=\"muted\">\r\n                        {allAnswered\r\n                            ? \"You've answered every question.\"\r\n                            : `${questions.length - answeredCount} question${questions.length - answeredCount === 1 ? '' : 's'} remaining`}\r\n                    <\/Text>\r\n                    <Button\r\n                        loading={submitting}\r\n                        disabled={submitting || answeredCount === 0}\r\n                        onClick={() => setConfirmOpen(true)}\r\n                        className=\"!bg-brand hover:!bg-primary-600 disabled: !text-white !font-semibold !px-6 !py-2.5 !rounded-md !shadow-sm\"\r\n                    >\r\n                        {submitting ? 'Submitting\u2026' : 'Submit test'}\r\n                    <\/Button>\r\n                <\/div>\r\n            <\/Card>\r\n\r\n            <Modal open={confirmOpen} onClose={() => setConfirmOpen(false)} size=\"sm\">\r\n                <Card variant=\"flat\" padding=\"lg\">\r\n                    <Heading as=\"h2\" size=\"lg\" weight=\"bold\">\r\n                        Submit your answers?\r\n                    <\/Heading>\r\n                    <Text color=\"muted\" className=\"!mt-2\">\r\n                        {allAnswered\r\n                            ? `You've answered all ${questions.length} questions. You can't change your answers after submitting.`\r\n                            : `You have ${questions.length - answeredCount} unanswered question${questions.length - answeredCount === 1 ? '' : 's'}. Unanswered questions count as zero.`}\r\n                    <\/Text>\r\n                    <div className=\"mt-6 flex justify-end gap-3\">\r\n                        <Button\r\n                            variant=\"ghost\"\r\n                            onClick={() => setConfirmOpen(false)}\r\n                            className=\"hover:!text-brand !px-4 !py-2\"\r\n                        >\r\n                            Keep working\r\n                        <\/Button>\r\n                        <Button\r\n                            onClick={handleSubmit}\r\n                            className=\"!bg-brand hover:!bg-primary-600 !text-white !font-semibold !px-5 !py-2 !rounded-md\"\r\n                        >\r\n                            Submit\r\n                        <\/Button>\r\n                    <\/div>\r\n                <\/Card>\r\n            <\/Modal>\r\n        <\/>\r\n    );\r\n}\r\n\r\nfunction TestResult({ attempt }: { attempt: TestAttempt }) {\r\n    const passed = attempt.passed === true;\r\n    const pending = attempt.passed === null;\r\n    const score = attempt.score !== null ? Number(attempt.score) : null;\r\n\r\n    return (\r\n        <Card\r\n            variant=\"outlined\"\r\n            padding=\"none\"\r\n            className=\"!rounded-xl !shadow-sm overflow-hidden\"\r\n        >\r\n            <div\r\n                className={cn(\r\n                    'px-6 py-5',\r\n                    pending ? 'bg-blue-50' : passed ? 'bg-emerald-50' : 'bg-red-50',\r\n                )}\r\n            >\r\n                <div className=\"flex items-center gap-3\">\r\n                    <div\r\n                        className={cn(\r\n                            'flex h-12 w-12 items-center justify-center rounded-full text-xl font-bold',\r\n                            pending\r\n                                ? 'bg-blue-100 text-blue-700'\r\n                                : passed\r\n                                ? 'bg-emerald-100 text-emerald-700'\r\n                                : 'bg-red-100 text-red-700',\r\n                        )}\r\n                    >\r\n                        {pending ? '\u2026' : passed ? '\u2713' : '\u2715'}\r\n                    <\/div>\r\n                    <div>\r\n                        <Heading as=\"h2\" size=\"xl\" weight=\"bold\">\r\n                            {pending ? 'Awaiting grading' : passed ? 'You passed!' : 'Did not pass'}\r\n                        <\/Heading>\r\n                        <Text className=\"!mt-0.5\" color=\"muted\">\r\n                            {pending\r\n                                ? 'Some answers require manual review.'\r\n                                : passed\r\n                                ? 'Nice work. Your certificate is being issued.'\r\n                                : 'Review the material and try again.'}\r\n                        <\/Text>\r\n                    <\/div>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            <div className=\"px-6 py-5 grid sm:grid-cols-3 gap-4\">\r\n                <Stat label=\"Score\" value={score !== null ? `${score}%` : '\u2014'} highlight \/>\r\n                <Stat\r\n                    label=\"Points awarded\"\r\n                    value={\r\n                        attempt.points_awarded !== null && attempt.max_score !== null\r\n                            ? `${attempt.points_awarded} \/ ${attempt.max_score}`\r\n                            : '\u2014'\r\n                    }\r\n                \/>\r\n                <Stat label=\"Attempt\" value={`#${attempt.attempt_number}`} \/>\r\n            <\/div>\r\n        <\/Card>\r\n    );\r\n}\r\n\r\nfunction Stat({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {\r\n    return (\r\n        <div className=\"rounded-lg border border-secondary-200 bg-secondary-50\/50 px-4 py-3\">\r\n            <Text size=\"xs\" weight=\"semibold\" className=\"uppercase tracking-wide\" color=\"muted\">\r\n                {label}\r\n            <\/Text>\r\n            <div\r\n                className={cn(\r\n                    'mt-1 text-2xl font-bold',\r\n                    highlight ? 'text-brand' : 'text-secondary-900',\r\n                )}\r\n            >\r\n                {value}\r\n            <\/div>\r\n        <\/div>\r\n    );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/test-runner\/TestRunner.tsx"}]}