@brandon_m_behring/book-scaffold-astro 4.20.0 → 4.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/components/AssessmentTest.astro +124 -0
- package/components/ExamRunner.tsx +313 -0
- package/components/QuestionCard.astro +166 -0
- package/components/Rationale.astro +58 -11
- package/dist/components/ExamRunner.d.ts +24 -0
- package/dist/components/ExamRunner.mjs +291 -0
- package/dist/exam-manifest-DbTHo90M.d.ts +132 -0
- package/dist/index.d.ts +6 -81
- package/dist/index.mjs +49 -0
- package/dist/schemas-DDWDRUxs.d.ts +491 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.mjs +10 -0
- package/dist/{types-DJgLQGP9.d.ts → types-uerMuJwT.d.ts} +11 -490
- package/package.json +8 -1
- package/pages/answers.astro +189 -0
- package/pages/practice-exam.astro +37 -104
- package/recipes/04-component-library.md +4 -2
- package/recipes/09-validation.md +1 -1
- package/scripts/validate.mjs +42 -0
- package/src/lib/exam-manifest.ts +90 -0
- package/src/profile-kit.ts +9 -0
- package/src/profiles/academic.ts +1 -0
- package/src/profiles/course-notes.ts +1 -0
- package/src/profiles/minimal.ts +1 -0
- package/src/profiles/research-portfolio.ts +1 -0
- package/src/profiles/tools.ts +1 -0
- package/styles/exam-runner.css +90 -0
package/CLAUDE.md
CHANGED
|
@@ -104,7 +104,7 @@ Two callout families coexist. Authors import what they need.
|
|
|
104
104
|
|
|
105
105
|
**Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
|
|
106
106
|
|
|
107
|
-
**Study-guide (v4.17.0+, #112; opt-in).** A schema-validated `questions` content collection drives an exam-prep "question bank". Author questions under `src/content/questions/**.{md,mdx}` — frontmatter `id` (unique, required) / `type` (`mcq`|`free`|`cloze`) / `chapter` / `domain` (+ optional `part`/`bloom_level`/`objective_id`/`difficulty`), MDX body = the stem. MCQ carries `options: [{ id, text, correct }]` (exactly one `correct: true`); free-response carries an `answer` (model answer). An MCQ must **not** set `answer` — its answer is the option marked `correct`, and explanations for any type go in a `<Rationale>` body block. Declare the per-book domain taxonomy in `defineBookConfig({ examDomains: ['…'] })` — a question whose `domain` isn't registered **throws at build** (fail-loud, like `<BookLink>`'s `siblingBooks`). Enable the static `/practice-exam` route with `defineBookConfig({ routes: { practiceExam: true } })` (renders the bank grouped by domain with answers behind a `<details>` reveal; `cloze` is reserved/render-deferred). `<ObjectiveMap />` renders the exam-domain → chapter coverage matrix auto-derived from the collection (no separate data file). `<Rationale>` is a collapsible answer/explanation marker for a question's MDX body. `<Diagnostic>` (v4.19.0, #110) renders a per-chapter pre-reading "Do I Know This Already?" self-check (pedagogy family above; static `<details>` answer reveal). `<PartReview part={N} />` (v4.19.0, #111) aggregates a Part's `<Exercise>` items for interleaved review — reusing the `build-exercises` index + the chapters' `part` field (run `book-scaffold build-exercises` first; presence-gated otherwise). A **searchable glossary** (v4.19.0, #115): author terms under `src/content/glossary/**` (frontmatter `term` + an MDX-body definition), enable the static `/glossary` route via `defineBookConfig({ routes: { glossary: true } })`, and link inline with `<Term id="…">…</Term>` (→ `/glossary#term-<id>`). (
|
|
107
|
+
**Study-guide (v4.17.0+, #112; opt-in).** A schema-validated `questions` content collection drives an exam-prep "question bank". Author questions under `src/content/questions/**.{md,mdx}` — frontmatter `id` (unique, required) / `type` (`mcq`|`free`|`cloze`) / `chapter` / `domain` (+ optional `part`/`bloom_level`/`objective_id`/`difficulty`), MDX body = the stem. MCQ carries `options: [{ id, text, correct }]` (exactly one `correct: true`); free-response carries an `answer` (model answer). An MCQ must **not** set `answer` — its answer is the option marked `correct`, and explanations for any type go in a `<Rationale>` body block. Declare the per-book domain taxonomy in `defineBookConfig({ examDomains: ['…'] })` — a question whose `domain` isn't registered **throws at build** (fail-loud, like `<BookLink>`'s `siblingBooks`). Enable the static `/practice-exam` route with `defineBookConfig({ routes: { practiceExam: true } })` (renders the bank grouped by domain with answers behind a `<details>` reveal; `cloze` is reserved/render-deferred). `<ObjectiveMap />` renders the exam-domain → chapter coverage matrix auto-derived from the collection (no separate data file). `<Rationale>` is a collapsible answer/explanation marker for a question's MDX body. `<Diagnostic>` (v4.19.0, #110) renders a per-chapter pre-reading "Do I Know This Already?" self-check (pedagogy family above; static `<details>` answer reveal). `<PartReview part={N} />` (v4.19.0, #111) aggregates a Part's `<Exercise>` items for interleaved review — reusing the `build-exercises` index + the chapters' `part` field (run `book-scaffold build-exercises` first; presence-gated otherwise). A **searchable glossary** (v4.19.0, #115): author terms under `src/content/glossary/**` (frontmatter `term` + an MDX-body definition), enable the static `/glossary` route via `defineBookConfig({ routes: { glossary: true } })`, and link inline with `<Term id="…">…</Term>` (→ `/glossary#term-<id>`). **Interactive layer (v4.21.0, #112-UI/#113/#114):** `/practice-exam` mounts the **ExamRunner island** (`client:idle`) — Start samples a form client-side (`sampleExam`), hides the rest of the bank + the answer reveals, scores checked radios on submit (`scoreExam`), and reads out per-domain results with weak-domain anchors (no JS → the static bank is the fallback). `<AssessmentTest />` is the whole-book front-matter diagnostic: a cross-domain sampled form whose weak-domain readout routes to the chapters carrying those domains' questions. The `/answers` route (`routes: { answers: true }`) is the Sybex back-appendix — every question grouped by chapter with the correct answer + rationale pre-expanded; `<Rationale appendix for="<id>">` renders inline as a link into it (and throws at build without `for=` or with the route disabled). (Flashcards are the remaining increment — see `docs/plans/active/study-guide-epic_*.md`.)
|
|
108
108
|
|
|
109
109
|
Full reference in `recipes/04-component-library.md`.
|
|
110
110
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <AssessmentTest> — whole-book front-matter assessment test (v4.21.0, #113).
|
|
4
|
+
*
|
|
5
|
+
* The Sybex-style "where do I stand?" diagnostic: a cross-domain sampled form
|
|
6
|
+
* drawn from the WHOLE question bank, scored client-side with a weak-domain
|
|
7
|
+
* readout that routes the reader to the chapters carrying those domains'
|
|
8
|
+
* questions. Drop into a front-matter / intro page (like <ObjectiveMap>);
|
|
9
|
+
* any profile.
|
|
10
|
+
*
|
|
11
|
+
* Server side (this file): presence-gate the questions collection, fail loud on
|
|
12
|
+
* unregistered domains (assertKnownDomain), render every scoreable MCQ as a
|
|
13
|
+
* QuestionCard (stems are MDX — they can't cross into island props), and mount
|
|
14
|
+
* the ExamRunner island in assessment mode with the pure manifest + the
|
|
15
|
+
* domain→chapters routing map (deriveDomainRouting: string chapters link to
|
|
16
|
+
* /chapters/<slug>/, numeric chapters render as labels — no fabricated URLs).
|
|
17
|
+
* Cross-domain spread is the island's blueprint (spreadBlueprint).
|
|
18
|
+
*
|
|
19
|
+
* free/cloze questions are omitted here entirely (not just hidden): the
|
|
20
|
+
* assessment is the scored diagnostic, while /practice-exam remains the
|
|
21
|
+
* complete bank listing.
|
|
22
|
+
*
|
|
23
|
+
* No-JS fallback: the cards render with radios as a manual self-check and
|
|
24
|
+
* <details> answer reveals.
|
|
25
|
+
*/
|
|
26
|
+
import bookConfig from 'virtual:book-scaffold/book-config';
|
|
27
|
+
import { render } from 'astro:content';
|
|
28
|
+
import QuestionCard from './QuestionCard.astro';
|
|
29
|
+
import ExamRunner from '@brandon_m_behring/book-scaffold-astro/components/ExamRunner';
|
|
30
|
+
import { getAllQuestions } from '../src/lib/questions';
|
|
31
|
+
import { assertKnownDomain } from '../src/lib/exam-domains';
|
|
32
|
+
import { buildExamManifest, deriveDomainRouting } from '../src/lib/exam-manifest';
|
|
33
|
+
import '../styles/exam-runner.css';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
/** Heading above the test. Defaults to "Assessment test". */
|
|
37
|
+
title?: string;
|
|
38
|
+
/** Default form size (clamped to the scoreable pool). Defaults to 12. */
|
|
39
|
+
count?: number;
|
|
40
|
+
/** Weak-domain threshold (0–1) passed to scoreExam. Defaults to 0.7. */
|
|
41
|
+
passMark?: number;
|
|
42
|
+
}
|
|
43
|
+
const { title = 'Assessment test', count, passMark } = Astro.props;
|
|
44
|
+
|
|
45
|
+
// Fail loud on prop misuse (v4.15.0 closed-union-props line): passMark={70}
|
|
46
|
+
// (percent-vs-fraction confusion) would silently flag EVERY domain weak on
|
|
47
|
+
// every run — confidently-wrong output, the exact class this repo forbids.
|
|
48
|
+
if (passMark !== undefined && !(passMark > 0 && passMark <= 1)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`<AssessmentTest passMark={${passMark}}> — passMark is a fraction in (0, 1] ` +
|
|
51
|
+
`(e.g. 0.7 for 70%), not a percent.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (count !== undefined && (!Number.isInteger(count) || count < 1)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`<AssessmentTest count={${count}}> — count must be a positive integer.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Presence-gate: only touch the collection when the directory holds files
|
|
61
|
+
// (same twin-gate as <ObjectiveMap> / practice-exam.astro).
|
|
62
|
+
const questionModules = import.meta.glob('/src/content/questions/**/*.{md,mdx}', {
|
|
63
|
+
query: '?raw',
|
|
64
|
+
import: 'default',
|
|
65
|
+
eager: true,
|
|
66
|
+
});
|
|
67
|
+
const hasQuestions = Object.keys(questionModules).length > 0;
|
|
68
|
+
|
|
69
|
+
const entries = hasQuestions ? await getAllQuestions() : [];
|
|
70
|
+
|
|
71
|
+
// Fail loud: an unregistered/typo'd domain must throw at build, not silently
|
|
72
|
+
// skew the assessment's domain spread.
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
assertKnownDomain(bookConfig.examDomains, e.data.domain, { id: e.data.id });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// entries are already draft-filtered (getAllQuestions filters !draft), so this
|
|
78
|
+
// is type-shape filtering only — it matches buildExamManifest's pool exactly,
|
|
79
|
+
// which is what the island's drift guard assumes.
|
|
80
|
+
const scoreable = entries.filter(
|
|
81
|
+
(e) => e.data.type === 'mcq' && (e.data.options?.length ?? 0) > 0,
|
|
82
|
+
);
|
|
83
|
+
const manifest = buildExamManifest(entries);
|
|
84
|
+
const domainRouting = deriveDomainRouting(entries);
|
|
85
|
+
const practiceExamHref = (bookConfig.enabledRoutes ?? []).includes('practiceExam')
|
|
86
|
+
? '/practice-exam'
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
const rendered = await Promise.all(
|
|
90
|
+
scoreable.map(async (e) => ({ data: e.data, Content: (await render(e)).Content })),
|
|
91
|
+
);
|
|
92
|
+
---
|
|
93
|
+
{!hasQuestions ? (
|
|
94
|
+
<p class="assessment-empty">
|
|
95
|
+
No questions found under <code>src/content/questions/</code> — the assessment
|
|
96
|
+
test draws from the question bank. Add question MDX files and declare your
|
|
97
|
+
<code>examDomains</code> in <code>defineBookConfig</code>.
|
|
98
|
+
</p>
|
|
99
|
+
) : (
|
|
100
|
+
<section class="assessment-test" data-exam-root aria-label={title}>
|
|
101
|
+
<h2 class="assessment-title">{title}</h2>
|
|
102
|
+
{/* Without JS the island's Start button would render dead — hide the
|
|
103
|
+
controls; the cards below remain a manual self-check. */}
|
|
104
|
+
<noscript><style is:inline>.exam-runner { display: none; }</style></noscript>
|
|
105
|
+
<ExamRunner
|
|
106
|
+
client:idle
|
|
107
|
+
mode="assessment"
|
|
108
|
+
manifest={manifest}
|
|
109
|
+
count={count}
|
|
110
|
+
passMark={passMark}
|
|
111
|
+
domainRouting={domainRouting}
|
|
112
|
+
practiceExamHref={practiceExamHref}
|
|
113
|
+
/>
|
|
114
|
+
{rendered.map((q) => (
|
|
115
|
+
<QuestionCard data={q.data} Content={q.Content} reserved={false} />
|
|
116
|
+
))}
|
|
117
|
+
</section>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.assessment-test { margin-block: 1.5rem; }
|
|
122
|
+
.assessment-title { margin-block-end: 0.5rem; }
|
|
123
|
+
.assessment-empty { color: var(--color-text-muted, #6b7280); }
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExamRunner — Preact island driving the interactive practice exam (#112-UI)
|
|
3
|
+
* and the front-matter assessment test (#113).
|
|
4
|
+
*
|
|
5
|
+
* Architecture: a CONTROLLER over server-rendered question cards, not a
|
|
6
|
+
* client-side renderer. MDX stems can't serialize into island props, so the
|
|
7
|
+
* .astro side renders every card statically (QuestionCard.astro: stem, options
|
|
8
|
+
* as radio inputs named `exam-<qid>`, answer behind <details>) and this island
|
|
9
|
+
* receives only the pure manifest — the exact `ExamQuestion` shape
|
|
10
|
+
* sampleExam/scoreExam consume. The island samples a form client-side, hides
|
|
11
|
+
* the cards that aren't in it, collects the checked radios on submit, scores
|
|
12
|
+
* with the SAME engine the node:test suite verifies, and renders the
|
|
13
|
+
* score/per-domain/weak-domain readout. No JS → the static bank with
|
|
14
|
+
* <details> reveals is untouched.
|
|
15
|
+
*
|
|
16
|
+
* DOM contract with the .astro side (QuestionCard inside a [data-exam-root]):
|
|
17
|
+
* [data-exam-root] wrapper section; gains
|
|
18
|
+
* data-exam-phase="active|review"
|
|
19
|
+
* [data-question-id="<id>"] one card per question; toggled via
|
|
20
|
+
* the `hidden` attribute; gains
|
|
21
|
+
* data-exam-result="correct|incorrect"
|
|
22
|
+
* input[name="exam-<id>"]:checked the reader's chosen option id
|
|
23
|
+
* details.question-reveal force-opened on review
|
|
24
|
+
*
|
|
25
|
+
* Hydrated with `client:idle`. Theme via CSS tokens only (no canvas — no
|
|
26
|
+
* book:theme:change listener needed).
|
|
27
|
+
*/
|
|
28
|
+
import { useRef, useState } from 'preact/hooks';
|
|
29
|
+
import {
|
|
30
|
+
sampleExam,
|
|
31
|
+
scoreExam,
|
|
32
|
+
type ExamQuestion,
|
|
33
|
+
type ExamResult,
|
|
34
|
+
} from '../src/lib/exam-engine';
|
|
35
|
+
import { spreadBlueprint, type RoutingChapter } from '../src/lib/exam-manifest';
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
/** Scoreable MCQ pool (buildExamManifest output) — ids/domains/options only. */
|
|
39
|
+
manifest: ExamQuestion[];
|
|
40
|
+
/** practice: domain-agnostic sampling, weak domains anchor to #domain-<d> on
|
|
41
|
+
* the same page. assessment: cross-domain spread blueprint, weak domains
|
|
42
|
+
* route to chapters via `domainRouting`. */
|
|
43
|
+
mode: 'practice' | 'assessment';
|
|
44
|
+
/** Default form size (clamped to the pool; reader can adjust before start). */
|
|
45
|
+
count?: number;
|
|
46
|
+
/** Weak-domain threshold passed to scoreExam (default 0.7). */
|
|
47
|
+
passMark?: number;
|
|
48
|
+
/** Assessment mode: domain → chapters carrying its questions (deriveDomainRouting). */
|
|
49
|
+
domainRouting?: Record<string, RoutingChapter[]>;
|
|
50
|
+
/** Assessment mode: href of the practice bank when that route is enabled, else null. */
|
|
51
|
+
practiceExamHref?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Phase = 'idle' | 'active' | 'review';
|
|
55
|
+
|
|
56
|
+
export default function ExamRunner({
|
|
57
|
+
manifest,
|
|
58
|
+
mode,
|
|
59
|
+
count,
|
|
60
|
+
passMark = 0.7,
|
|
61
|
+
domainRouting = {},
|
|
62
|
+
practiceExamHref = null,
|
|
63
|
+
}: Props) {
|
|
64
|
+
const poolSize = manifest.length;
|
|
65
|
+
const domainCount = new Set(manifest.map((q) => q.domain)).size;
|
|
66
|
+
// Assessment floors at one question per domain (see start()); the default
|
|
67
|
+
// and the input's min respect that so the UI can't request a starved form.
|
|
68
|
+
const minCount = mode === 'assessment' ? Math.max(1, domainCount) : 1;
|
|
69
|
+
const defaultCount = Math.min(
|
|
70
|
+
Math.max(count ?? (mode === 'assessment' ? 12 : 10), minCount),
|
|
71
|
+
poolSize,
|
|
72
|
+
);
|
|
73
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
74
|
+
const [requested, setRequested] = useState(defaultCount);
|
|
75
|
+
const [form, setForm] = useState<ExamQuestion[]>([]);
|
|
76
|
+
const [result, setResult] = useState<ExamResult | null>(null);
|
|
77
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
78
|
+
|
|
79
|
+
function requireRoot(): HTMLElement {
|
|
80
|
+
// Fail loud (house invariant): a silently dead Start button is the worst
|
|
81
|
+
// failure mode. The throw surfaces as an uncaught console error.
|
|
82
|
+
const r = ref.current?.closest<HTMLElement>('[data-exam-root]');
|
|
83
|
+
if (!r) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'ExamRunner: no [data-exam-root] ancestor — mount the island inside the ' +
|
|
86
|
+
'wrapper that contains its QuestionCards (see the DOM contract in ExamRunner.tsx).',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return r;
|
|
90
|
+
}
|
|
91
|
+
function cards(r: HTMLElement): HTMLElement[] {
|
|
92
|
+
// Array.from, not spread — the dts tsconfig lib lacks DOM.Iterable.
|
|
93
|
+
return Array.from(r.querySelectorAll<HTMLElement>('[data-question-id]'));
|
|
94
|
+
}
|
|
95
|
+
function radios(r: HTMLElement): HTMLInputElement[] {
|
|
96
|
+
return Array.from(r.querySelectorAll<HTMLInputElement>('input[type="radio"]'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function start(): void {
|
|
100
|
+
const r = requireRoot();
|
|
101
|
+
// Assessment mode floors at one question per domain — a "cross-domain"
|
|
102
|
+
// form that silently drops late-book domains would betray its own point
|
|
103
|
+
// (spreadBlueprint's quota order starves the tail otherwise).
|
|
104
|
+
const n = Math.max(minCount, Math.min(requested, poolSize));
|
|
105
|
+
const sampled =
|
|
106
|
+
mode === 'assessment'
|
|
107
|
+
? sampleExam(manifest, spreadBlueprint(manifest, n))
|
|
108
|
+
: sampleExam(manifest, { count: n });
|
|
109
|
+
const inForm = new Set(sampled.map((q) => q.id));
|
|
110
|
+
const allCards = cards(r);
|
|
111
|
+
// Fail loud on manifest/DOM drift: a sampled question with no rendered
|
|
112
|
+
// card would be invisible yet scored incorrect — silently wrong results.
|
|
113
|
+
const cardIds = new Set(allCards.map((c) => c.dataset.questionId));
|
|
114
|
+
const missing = sampled.filter((q) => !cardIds.has(q.id));
|
|
115
|
+
if (missing.length > 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`ExamRunner: manifest/DOM drift — no rendered card for question(s): ` +
|
|
118
|
+
`${missing.map((q) => q.id).join(', ')}.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
for (const card of allCards) {
|
|
122
|
+
card.hidden = !inForm.has(card.dataset.questionId ?? '');
|
|
123
|
+
card.removeAttribute('data-exam-result');
|
|
124
|
+
const reveal = card.querySelector<HTMLDetailsElement>('details.question-reveal');
|
|
125
|
+
if (reveal) reveal.open = false;
|
|
126
|
+
}
|
|
127
|
+
for (const input of radios(r)) {
|
|
128
|
+
input.checked = false;
|
|
129
|
+
}
|
|
130
|
+
r.setAttribute('data-exam-phase', 'active');
|
|
131
|
+
setForm(sampled);
|
|
132
|
+
setResult(null);
|
|
133
|
+
setPhase('active');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function submit(): void {
|
|
137
|
+
const r = requireRoot();
|
|
138
|
+
const answers: Record<string, string> = {};
|
|
139
|
+
for (const q of form) {
|
|
140
|
+
// CSS.escape: a question id containing a quote would otherwise break
|
|
141
|
+
// the selector and throw a DOMException mid-submit (frozen exam).
|
|
142
|
+
const checked = r.querySelector<HTMLInputElement>(
|
|
143
|
+
`input[name="exam-${CSS.escape(q.id)}"]:checked`,
|
|
144
|
+
);
|
|
145
|
+
if (checked) answers[q.id] = checked.value;
|
|
146
|
+
}
|
|
147
|
+
for (const q of form) {
|
|
148
|
+
const card = r.querySelector<HTMLElement>(
|
|
149
|
+
`[data-question-id="${CSS.escape(q.id)}"]`,
|
|
150
|
+
);
|
|
151
|
+
if (!card) {
|
|
152
|
+
// start() already guards drift; defense in depth, same loud failure.
|
|
153
|
+
throw new Error(`ExamRunner: no rendered card for question "${q.id}".`);
|
|
154
|
+
}
|
|
155
|
+
const right = q.options.some((o) => o.correct === true && o.id === answers[q.id]);
|
|
156
|
+
card.setAttribute('data-exam-result', right ? 'correct' : 'incorrect');
|
|
157
|
+
const reveal = card.querySelector<HTMLDetailsElement>('details.question-reveal');
|
|
158
|
+
if (reveal) reveal.open = true;
|
|
159
|
+
}
|
|
160
|
+
r.setAttribute('data-exam-phase', 'review');
|
|
161
|
+
setResult(scoreExam(form, answers, passMark));
|
|
162
|
+
setPhase('review');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function reset(): void {
|
|
166
|
+
const r = requireRoot();
|
|
167
|
+
for (const card of cards(r)) {
|
|
168
|
+
card.hidden = false;
|
|
169
|
+
card.removeAttribute('data-exam-result');
|
|
170
|
+
}
|
|
171
|
+
for (const input of radios(r)) {
|
|
172
|
+
input.checked = false;
|
|
173
|
+
}
|
|
174
|
+
r.removeAttribute('data-exam-phase');
|
|
175
|
+
setForm([]);
|
|
176
|
+
setResult(null);
|
|
177
|
+
setPhase('idle');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (poolSize === 0) {
|
|
181
|
+
return (
|
|
182
|
+
<p class="exam-runner-empty">
|
|
183
|
+
No auto-scoreable (multiple-choice) questions available — free-response and
|
|
184
|
+
cloze items can't be machine-scored.
|
|
185
|
+
</p>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div class="exam-runner" ref={ref}>
|
|
191
|
+
{phase === 'idle' && (
|
|
192
|
+
<div class="exam-runner-controls">
|
|
193
|
+
<p class="exam-runner-intro">
|
|
194
|
+
{mode === 'assessment'
|
|
195
|
+
? 'Take a cross-domain assessment: a sampled form spread over every exam domain, scored with a weak-domain readout routing you to the chapters to (re)read.'
|
|
196
|
+
: 'Take a scored practice exam: a random form sampled from the bank below, with a per-domain score readout.'}
|
|
197
|
+
</p>
|
|
198
|
+
<label class="exam-runner-count-label">
|
|
199
|
+
Questions:{' '}
|
|
200
|
+
<input
|
|
201
|
+
type="number"
|
|
202
|
+
class="exam-runner-count"
|
|
203
|
+
min={minCount}
|
|
204
|
+
max={poolSize}
|
|
205
|
+
value={requested}
|
|
206
|
+
onInput={(e) => {
|
|
207
|
+
const v = Number.parseInt((e.target as HTMLInputElement).value, 10);
|
|
208
|
+
if (Number.isFinite(v)) setRequested(Math.max(minCount, Math.min(v, poolSize)));
|
|
209
|
+
}}
|
|
210
|
+
/>{' '}
|
|
211
|
+
of {poolSize}
|
|
212
|
+
</label>
|
|
213
|
+
<button type="button" class="exam-runner-button exam-runner-start" onClick={start}>
|
|
214
|
+
Start {mode === 'assessment' ? 'assessment' : 'practice exam'}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{phase === 'active' && (
|
|
220
|
+
<div class="exam-runner-controls">
|
|
221
|
+
<p class="exam-runner-status" role="status">
|
|
222
|
+
{form.length} question{form.length === 1 ? '' : 's'} below — answers stay
|
|
223
|
+
hidden until you submit.
|
|
224
|
+
</p>
|
|
225
|
+
<button type="button" class="exam-runner-button exam-runner-submit" onClick={submit}>
|
|
226
|
+
Submit answers
|
|
227
|
+
</button>
|
|
228
|
+
<button type="button" class="exam-runner-button exam-runner-cancel" onClick={reset}>
|
|
229
|
+
Cancel
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{phase === 'review' && result && (
|
|
235
|
+
<div class="exam-runner-scoreboard" aria-live="polite">
|
|
236
|
+
<p class="exam-runner-score">
|
|
237
|
+
<strong>{result.pct}%</strong> — {result.correct} of {result.total} correct
|
|
238
|
+
</p>
|
|
239
|
+
<table class="exam-runner-domains">
|
|
240
|
+
<thead>
|
|
241
|
+
<tr>
|
|
242
|
+
<th scope="col">Domain</th>
|
|
243
|
+
<th scope="col">Score</th>
|
|
244
|
+
</tr>
|
|
245
|
+
</thead>
|
|
246
|
+
<tbody>
|
|
247
|
+
{result.byDomain.map((d) => (
|
|
248
|
+
<tr class={result.weakDomains.includes(d.domain) ? 'exam-runner-weak' : undefined}>
|
|
249
|
+
<th scope="row">{d.domain}</th>
|
|
250
|
+
<td>
|
|
251
|
+
{d.correct}/{d.total}
|
|
252
|
+
{result.weakDomains.includes(d.domain) && (
|
|
253
|
+
<span class="exam-runner-weak-mark"> — review</span>
|
|
254
|
+
)}
|
|
255
|
+
</td>
|
|
256
|
+
</tr>
|
|
257
|
+
))}
|
|
258
|
+
</tbody>
|
|
259
|
+
</table>
|
|
260
|
+
{result.weakDomains.length > 0 && (
|
|
261
|
+
<div class="exam-runner-routing">
|
|
262
|
+
<p class="exam-runner-routing-lead">
|
|
263
|
+
{mode === 'assessment'
|
|
264
|
+
? 'Weak domains — start with these chapters:'
|
|
265
|
+
: 'Weak domains — review these sections of the bank:'}
|
|
266
|
+
</p>
|
|
267
|
+
<ul class="exam-runner-routing-list">
|
|
268
|
+
{result.weakDomains.map((domain) => (
|
|
269
|
+
<li>
|
|
270
|
+
<strong class="exam-runner-routing-domain">{domain}</strong>
|
|
271
|
+
{mode === 'practice' && (
|
|
272
|
+
<>
|
|
273
|
+
{' '}
|
|
274
|
+
— <a href={`#domain-${domain}`}>jump to {domain} questions</a>
|
|
275
|
+
</>
|
|
276
|
+
)}
|
|
277
|
+
{mode === 'assessment' && (domainRouting[domain]?.length ?? 0) > 0 && (
|
|
278
|
+
<>
|
|
279
|
+
{' — '}
|
|
280
|
+
{domainRouting[domain]!.map((ch, i) => (
|
|
281
|
+
<>
|
|
282
|
+
{i > 0 && ', '}
|
|
283
|
+
{ch.href ? (
|
|
284
|
+
<a href={ch.href}>chapter {ch.label}</a>
|
|
285
|
+
) : (
|
|
286
|
+
<span>chapter {ch.label}</span>
|
|
287
|
+
)}
|
|
288
|
+
</>
|
|
289
|
+
))}
|
|
290
|
+
</>
|
|
291
|
+
)}
|
|
292
|
+
{mode === 'assessment' && practiceExamHref && (
|
|
293
|
+
<>
|
|
294
|
+
{' '}
|
|
295
|
+
(<a href={`${practiceExamHref}#domain-${domain}`}>practice more</a>)
|
|
296
|
+
</>
|
|
297
|
+
)}
|
|
298
|
+
</li>
|
|
299
|
+
))}
|
|
300
|
+
</ul>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
<button type="button" class="exam-runner-button exam-runner-retake" onClick={start}>
|
|
304
|
+
Retake
|
|
305
|
+
</button>
|
|
306
|
+
<button type="button" class="exam-runner-button exam-runner-reset" onClick={reset}>
|
|
307
|
+
{mode === 'assessment' ? 'Show all questions' : 'Show full bank'}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* QuestionCard — one server-rendered question card (v4.21.0, #112-UI/#113).
|
|
4
|
+
*
|
|
5
|
+
* The shared partial between /practice-exam and <AssessmentTest>: renders the
|
|
6
|
+
* MDX stem (passed as a rendered Content component — stems can't cross into
|
|
7
|
+
* island props), MCQ options as radio inputs the ExamRunner island reads on
|
|
8
|
+
* submit, and the answer behind the static <details> reveal (the no-JS
|
|
9
|
+
* fallback — radios still work as a manual self-check without the island).
|
|
10
|
+
*
|
|
11
|
+
* DOM contract with ExamRunner.tsx (see its module doc): data-question-id /
|
|
12
|
+
* data-exam-scoreable on the article; input[name="exam-<id>"] per option;
|
|
13
|
+
* details.question-reveal. The exam-phase CSS lives here (with :global() for
|
|
14
|
+
* the [data-exam-phase] ancestor the island sets on the wrapper section):
|
|
15
|
+
* answers hide during an active exam, cards get a result border on review.
|
|
16
|
+
*/
|
|
17
|
+
import type { Question } from '../src/schemas.js';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** The question's frontmatter (CollectionEntry data). */
|
|
21
|
+
data: Question;
|
|
22
|
+
/** The rendered MDX stem, or null for reserved (cloze) questions. */
|
|
23
|
+
Content: any | null;
|
|
24
|
+
/** True for cloze — schema-accepted, render-deferred. */
|
|
25
|
+
reserved: boolean;
|
|
26
|
+
}
|
|
27
|
+
const { data, Content, reserved } = Astro.props;
|
|
28
|
+
|
|
29
|
+
const scoreable = data.type === 'mcq' && (data.options?.length ?? 0) > 0;
|
|
30
|
+
const correct = data.type === 'mcq' && data.options
|
|
31
|
+
? data.options.find((o) => o.correct)
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
/** Filled/hollow diamonds for a 1–4 difficulty, mirroring Practice.astro. */
|
|
35
|
+
function difficultyMarks(d: string | undefined): string {
|
|
36
|
+
if (!d) return '';
|
|
37
|
+
const n = Number.parseInt(d, 10);
|
|
38
|
+
return '◆'.repeat(n) + '◇'.repeat(4 - n);
|
|
39
|
+
}
|
|
40
|
+
---
|
|
41
|
+
<article
|
|
42
|
+
class="question"
|
|
43
|
+
id={`question-${data.id}`}
|
|
44
|
+
data-question-id={data.id}
|
|
45
|
+
data-question-domain={data.domain}
|
|
46
|
+
data-exam-scoreable={scoreable ? 'true' : 'false'}
|
|
47
|
+
>
|
|
48
|
+
<header class="question-header">
|
|
49
|
+
<span class="question-meta-id">{data.id}</span>
|
|
50
|
+
<span class="question-meta-type">{data.type}</span>
|
|
51
|
+
{data.bloom_level && <span class="question-meta-bloom">{data.bloom_level}</span>}
|
|
52
|
+
{data.difficulty && (
|
|
53
|
+
<span class="question-meta-difficulty" aria-label={`Difficulty ${data.difficulty} of 4`}>
|
|
54
|
+
{difficultyMarks(data.difficulty)}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</header>
|
|
58
|
+
|
|
59
|
+
{reserved ? (
|
|
60
|
+
<p class="question-reserved">
|
|
61
|
+
Cloze question — interactive rendering deferred to a later release.
|
|
62
|
+
</p>
|
|
63
|
+
) : (
|
|
64
|
+
Content && (
|
|
65
|
+
<div class="question-stem"><Content /></div>
|
|
66
|
+
)
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{data.type === 'mcq' && data.options && (
|
|
70
|
+
<fieldset class="question-options">
|
|
71
|
+
<legend class="question-options-legend">Options</legend>
|
|
72
|
+
{data.options.map((o) => (
|
|
73
|
+
<label class="question-option">
|
|
74
|
+
<input type="radio" name={`exam-${data.id}`} value={o.id} />
|
|
75
|
+
<span class="question-option-key">{o.id}.</span>
|
|
76
|
+
<span class="question-option-text">{o.text ?? o.id}</span>
|
|
77
|
+
</label>
|
|
78
|
+
))}
|
|
79
|
+
</fieldset>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{!reserved && (
|
|
83
|
+
<details class="question-reveal">
|
|
84
|
+
<summary>Show answer</summary>
|
|
85
|
+
{data.type === 'mcq' && correct && (
|
|
86
|
+
<p class="question-answer-line">
|
|
87
|
+
Correct: <strong>{correct.text ?? correct.id}</strong>
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
{data.type === 'free' && data.answer && (
|
|
91
|
+
<p class="question-answer-line">{data.answer}</p>
|
|
92
|
+
)}
|
|
93
|
+
</details>
|
|
94
|
+
)}
|
|
95
|
+
</article>
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
.question {
|
|
99
|
+
margin-block: 1.25rem;
|
|
100
|
+
padding: 1rem 1.1rem;
|
|
101
|
+
border: 1px solid var(--color-border, #e5e7eb);
|
|
102
|
+
border-radius: 0.5rem;
|
|
103
|
+
background: var(--color-surface, transparent);
|
|
104
|
+
}
|
|
105
|
+
.question-header {
|
|
106
|
+
display: flex;
|
|
107
|
+
flex-wrap: wrap;
|
|
108
|
+
gap: 0.5rem;
|
|
109
|
+
align-items: center;
|
|
110
|
+
font-size: 0.78rem;
|
|
111
|
+
color: var(--color-text-muted, #6b7280);
|
|
112
|
+
margin-block-end: 0.5rem;
|
|
113
|
+
}
|
|
114
|
+
.question-meta-id { font-family: var(--font-mono, ui-monospace, monospace); }
|
|
115
|
+
.question-meta-type,
|
|
116
|
+
.question-meta-bloom {
|
|
117
|
+
text-transform: uppercase;
|
|
118
|
+
letter-spacing: 0.04em;
|
|
119
|
+
padding: 0.05rem 0.4rem;
|
|
120
|
+
border-radius: 0.25rem;
|
|
121
|
+
background: var(--color-code-bg, rgba(127, 127, 127, 0.12));
|
|
122
|
+
}
|
|
123
|
+
.question-meta-difficulty { margin-inline-start: auto; letter-spacing: 0.1em; }
|
|
124
|
+
.question-options {
|
|
125
|
+
margin-block: 0.6rem;
|
|
126
|
+
border: none;
|
|
127
|
+
padding: 0;
|
|
128
|
+
}
|
|
129
|
+
.question-options-legend {
|
|
130
|
+
position: absolute;
|
|
131
|
+
width: 1px;
|
|
132
|
+
height: 1px;
|
|
133
|
+
overflow: hidden;
|
|
134
|
+
clip-path: inset(50%);
|
|
135
|
+
}
|
|
136
|
+
.question-option {
|
|
137
|
+
display: flex;
|
|
138
|
+
gap: 0.5rem;
|
|
139
|
+
align-items: baseline;
|
|
140
|
+
margin-block: 0.25rem;
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
}
|
|
143
|
+
.question-option-key { color: var(--color-text-muted, #6b7280); }
|
|
144
|
+
.question-reveal { margin-block-start: 0.6rem; }
|
|
145
|
+
.question-reveal > summary {
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
color: var(--color-accent, #4f46e5);
|
|
149
|
+
}
|
|
150
|
+
.question-answer-line { margin-block: 0.5rem 0.25rem; }
|
|
151
|
+
.question-reserved { color: var(--color-text-muted, #6b7280); font-style: italic; }
|
|
152
|
+
|
|
153
|
+
/* Exam phases (ExamRunner island sets data-exam-phase on the wrapper
|
|
154
|
+
section): no peeking at answers during an active exam; result borders +
|
|
155
|
+
re-opened reveals on review. :global() crosses the wrapper's scope — the
|
|
156
|
+
element side stays scoped to this card. */
|
|
157
|
+
:global([data-exam-phase='active']) .question-reveal { display: none; }
|
|
158
|
+
.question[data-exam-result='correct'] {
|
|
159
|
+
border-color: var(--color-success, #15803d);
|
|
160
|
+
box-shadow: inset 3px 0 0 var(--color-success, #15803d);
|
|
161
|
+
}
|
|
162
|
+
.question[data-exam-result='incorrect'] {
|
|
163
|
+
border-color: var(--color-danger, #b91c1c);
|
|
164
|
+
box-shadow: inset 3px 0 0 var(--color-danger, #b91c1c);
|
|
165
|
+
}
|
|
166
|
+
</style>
|