diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd96e18..1dd5c0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: if: ${{ hashFiles('src/content/questions/**/*.yaml') != '' }} run: npm run validate:questions + - name: Validate challenge tracks + if: ${{ hashFiles('src/content/challenges/**/*.yaml') != '' }} + run: npm run validate:challenges + + - name: Validate lab simulation cases + if: ${{ hashFiles('src/content/cases/**/*.yaml') != '' }} + run: npm run validate:cases + - name: Type check run: npx astro check diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 85bac9d..79a8938 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,14 @@ jobs: if: ${{ hashFiles('src/content/questions/**/*.yaml') != '' }} run: npm run validate:questions + - name: Validate challenge tracks + if: ${{ hashFiles('src/content/challenges/**/*.yaml') != '' }} + run: npm run validate:challenges + + - name: Validate lab simulation cases + if: ${{ hashFiles('src/content/cases/**/*.yaml') != '' }} + run: npm run validate:cases + - name: Build run: npm run build diff --git a/astro.config.mjs b/astro.config.mjs index 3aff787..574dbc3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -27,7 +27,9 @@ export default defineConfig({ !page.includes('/og/') && !page.includes('/og-image/') && !page.includes('/study/review') && - !page.includes('/study/settings'), + !page.includes('/study/settings') && + !page.includes('/study/challenges') && + !page.includes('/study/cases'), lastmod: new Date(), }), ], diff --git a/docs/authoring-cases.md b/docs/authoring-cases.md new file mode 100644 index 0000000..a431bdf --- /dev/null +++ b/docs/authoring-cases.md @@ -0,0 +1,246 @@ +# Authoring Lab Simulation Cases — Curator's Guide + +> **Diataxis type:** How-to — goal-oriented guide for contributors authoring and reviewing Lab +> Simulation cases. +> +> **Audience:** FGA-trained content reviewers and internal contributors writing case files for the +> `src/content/cases/` sidecar bank. This guide assumes familiarity with gemmological terminology +> and basic YAML editing; it does not assume programming experience. It mirrors +> [`authoring-questions.md`](authoring-questions.md) — read that guide first if you have not +> authored curated content in this repository before. + +--- + +## Who This Guide Is For + +This guide is for the **curator** role writing and reviewing Lab Simulation case files: scenario-driven +identification exercises where a learner chooses tests, interprets readings, narrows candidates, calls +a treatment, and reaches a final identification — scored by decision quality at every step, not just +the final answer. You do not need to know TypeScript or Astro internals. You do need: + +- FGA Foundation or equivalent qualification (or close supervision by someone who has one) +- A text editor with YAML support +- Git access to the repository (a GitHub account with contributor access) + +The engineering infrastructure — the Zod schema (`src/content/config.ts`), the validator +(`scripts/validate-cases.mjs`), the CI gate — is maintained by the implementer track. If the validator +rejects a file you believe is correct, raise the issue rather than working around it. + +--- + +## What Is a "Case"? + +A case is a single scenario, not a single question. It is a short sequence of **steps** (minimum 3, +one of which must be a `final-identification` step), each presenting the learner with a decision and +2–6 options. Options are tiered: + +| Weight | Score | Meaning | +|--------|-------|---------| +| `optimal` | 10 | The expert move — the most efficient, most diagnostic choice available at this point | +| `acceptable` | 5 | A reasonable choice that still makes progress, but is slower, less complete, or less rigorous than optimal | +| `poor` | 0 | A choice that wastes time, ignores available evidence, or reaches a wrong conclusion | + +Steps can have a `pointsMultiplier` (1–3) for higher-stakes decisions — a treatment call or final +identification should typically carry `pointsMultiplier: 2`. + +Step types: + +| Type | Purpose | +|------|---------| +| `choose-next-test` | Learner picks which instrument/test to reach for next | +| `reading-interpretation` | Learner interprets a reading just revealed (what does it rule in/out?) | +| `candidate-narrowing` | An explicit narrowing-the-field decision, usually after a second measurement | +| `treatment-call` | A treatment/enhancement determination based on inclusion or optical evidence | +| `final-identification` | The concluding identification + disclosure decision (exactly one per case) | + +Each step may reveal **evidence items** (`evidenceRevealed`) — instrument readings, inclusion +descriptions, filter reactions — that accumulate in the learner's on-screen "evidence notebook" as +the case progresses. An option can also reveal evidence conditionally via `revealsEvidenceIds`, +referencing an `id` defined in some step's `evidenceRevealed` array. + +--- + +## The Five-Step Authoring Workflow + +### Step 1 — Generate a stub file + +```bash +npm run case:new -- --category=identification --slug=dealers-ruby --difficulty=intermediate +``` + +This creates `src/content/cases/identification/dealers-ruby.yaml` pre-populated with a heavily +commented template: three example steps (`choose-next-test`, `reading-interpretation`, +`final-identification`), a `groundTruth` block, and a `debrief` block. The `--category` argument is +free-form (kebab-case) and becomes the containing directory; `--slug` becomes the file name and the +case's `id`. `--difficulty` is optional (default `intermediate`; one of `foundation`, `intermediate`, +`diploma`). + +### Step 2 — Write the scenario + +Replace every `TODO:` field. Write `backstory` and `specimenSummary` as a real bench situation — what +a client says, what the stone looks like before any testing — not a bare property dump. Instrument +readings and other diagnostic evidence belong in each step's `evidenceRevealed`, not in +`specimenSummary`. + +For every step, write: + +- a `prompt` framed as a real decision ("What's the most efficient first test on this stone?", not + "What is the RI of corundum?") +- 2–6 `options`, each with a `weight`, matching `score` (10/5/0 — see the table above), and a + `rationale` that teaches the diagnostic principle, not just restates the answer +- for `reading-interpretation` and `candidate-narrowing` steps, a `candidatesAfter` list per option + showing what the working candidate field looks like after this decision + +### Step 3 — Verify every property value + +**This is the most important review step and the one most often skipped.** Every RI, SG, +birefringence, spectroscope band, fluorescence colour, or treatment-diagnostic claim in a case must +match the reference data actually used elsewhere on the site, with a citation: + +- Species properties: `mineral-database` (`mineral-db info `) — this is canonical. +- Spectroscope bands: `src/lib/spectroscope/reference-bands.ts` (`SPECTROSCOPE_REFERENCE`). +- Treatment clues: `src/lib/treatments/wizard.ts`'s clue list and weights. +- Fallback/common-gem tables: `src/lib/calculator/conversions.ts`'s `GemReference` data, used as a + cross-check — values should sit inside its ranges, not merely near them. + +Add a full citation to the case's `references` block for every factual claim (mirror the +`[ref:id]` inline-marker convention from `authoring-questions.md` if a rationale needs an inline +anchor). Do not set `unvetted: false` until this check is complete and a named reviewer has +signed off. + +### Step 4 — Write the debrief + +The `debrief.summary` restates the correct identification and why. `debrief.expertPath` is an ordered +list (minimum 1 entry, but aim for 3–5) of short imperative sentences describing the expert's actual +reasoning sequence through the case — this is what most differentiates a case from a quiz question: +it teaches a *procedure*, not just an answer. `debrief.furtherReading` is optional, freeform citation +strings for background reading beyond the formal `references` block. + +### Step 5 — Validate and open a pull request + +```bash +npm run validate:cases +``` + +The validator runs the Zod schema against every file in `src/content/cases/`, then applies lints the +schema alone cannot express (see "Validator Lints" below). It exits 0 if all files pass; it prints the +failing file path and specific violation otherwise. + +Push your branch and open a PR. CI runs `validate:cases` automatically whenever any file under +`src/content/cases/**/*.yaml` changed, followed by the full build (confirming the new case renders at +`/study/cases//`). At least one other FGA-qualified reviewer should approve the scenario content +and property values before merge. + +--- + +## Validator Lints + +Beyond schema shape, `scripts/validate-cases.mjs` checks: + +1. **Option ids unique per step.** +2. **At least one `optimal`-weight option per step** — every decision must have a demonstrably best + answer. +3. **Exactly one `final-identification` step per case.** +4. **`candidatesAfter` length is non-increasing** across the sequence of steps that declare it (in + step order across the whole case, on any step type — not only `candidate-narrowing` steps, since a + `reading-interpretation` step can narrow the field too). Candidate narrowing should never widen the + field a later step already narrowed. +5. **weight/score consistency** — `optimal` must score 10, `acceptable` must score 5, `poor` must + score 0. If you have a deliberate, reviewed reason to deviate (e.g. a step weighted differently for + pedagogical reasons), set `allowScoreOverride: true` on that option — this is a validator-only + escape hatch, not a schema field consumed at runtime, so use it sparingly and document why in a + YAML comment next to the option. +6. **Every `revealsEvidenceIds` entry resolves** to an evidence item `id` defined somewhere in the + case's `evidenceRevealed` arrays. A dangling reference is always a content bug. + +--- + +## Quality Bar for Curated Cases + +A case passes review when it satisfies all of the following: + +1. **Realistic scenario** — a specific stone, a specific situation (dealer, client, unknown parcel), + not an abstract property list. +2. **Every step is a genuine decision** with a clear expert answer and at least one plausible + near-miss distractor drawn from the real gemmological confusion matrix (see + `authoring-questions.md`'s confusion-matrix list). +3. **Evidence reveal matches instrument reality** — a reading is revealed only after the learner + would actually have taken that measurement; don't reveal SG before the learner has chosen to weigh + the stone. +4. **Property values verified** against `mineral-database`, `SPECTROSCOPE_REFERENCE`, or + `wizard.ts`'s clue table, with a citation (Step 3 above). +5. **Treatment/final-identification steps carry `pointsMultiplier: 2`** (or higher, if justified) to + reflect their higher stakes relative to earlier exploratory steps. +6. **Debrief teaches the procedure**, not just the answer — `expertPath` should read as a short "how + an expert actually works this stone" sequence. + +--- + +## Common Review Failures + +### Failure 1 — Score doesn't match weight + +```yaml +# Bad: acceptable weight but optimal-tier score, no allowScoreOverride +- id: reach-spectroscope + weight: acceptable + score: 10 +``` + +The validator rejects this. Either fix the score to `5`, or add `allowScoreOverride: true` with a +comment explaining why this option deliberately deviates. + +### Failure 2 — Evidence revealed before the corresponding test is chosen + +```yaml +# Bad: SG appears in specimenSummary before any option lets the learner weigh the stone +specimenSummary: "A 2.10ct red stone, SG 4.00, ..." +``` + +Instrument readings belong in a step's `evidenceRevealed`, gated behind the option that represents +choosing that test. + +### Failure 3 — candidatesAfter widens the field + +```yaml +# Bad: step 2 narrowed to 1 candidate, step 3 lists 3 — the field cannot widen +# (step 2) candidatesAfter: [{familyId: corundum, ...}] +# (step 3) candidatesAfter: [{familyId: corundum}, {familyId: spinel}, {familyId: garnet}] +``` + +Once a candidate is excluded by valid evidence, it should not reappear in a later step's +`candidatesAfter` list. + +### Failure 4 — Rationale restates the answer + +```yaml +# Bad: does not explain the diagnostic reasoning +- text: "Red spinel" + weight: poor + score: 0 + rationale: "This is not correct." +``` + +Every option's rationale must identify which property or observation makes that option optimal, +acceptable, or poor — mirror `authoring-questions.md`'s "Failure 2" guidance. + +### Failure 5 — Property value uncited or unverified + +Any RI/SG/spectroscope/treatment claim without a matching `references` entry, or one that doesn't +match `mineral-db info ` / `SPECTROSCOPE_REFERENCE` / `wizard.ts`, should not ship with +`unvetted: false`. + +--- + +## Cross-references + +- Case schema (Zod): `src/content/config.ts` (`casesCollection`); `src/content/cases/_example/` for + the template +- Runtime types and mapper: `src/lib/cases/case-types.ts`, `src/lib/cases/mapper.ts` +- Scoring model: `src/lib/cases/scoring.ts` (efficiency bonus, grade computation) +- Case runner hook: `src/hooks/useCaseRunner.ts` +- Components: `src/components/cases/` (`CaseIntro`, `CaseStepPanel`, `CaseOptionList`, + `EvidenceNotebook`, `CaseProgressStrip`, `CaseDebrief`, `CaseRunner`) +- Routes: `src/pages/study/cases/[id].astro`; unified hub section at `src/pages/study/challenges/index.astro` + ("Lab Cases") +- Sister guide for the question bank: [authoring-questions.md](authoring-questions.md) diff --git a/package.json b/package.json index 762fd80..fdae88c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "question:new": "node scripts/new-question.mjs", + "case:new": "node scripts/new-case.mjs", "validate:questions": "node scripts/validate-questions.mjs", + "validate:challenges": "node scripts/validate-challenges.mjs", + "validate:cases": "node scripts/validate-cases.mjs", "questions:coverage": "node scripts/questions-coverage.mjs", "schema:questions": "node scripts/generate-question-schema.mjs", "validate:citations": "node scripts/validate-citations.mjs" diff --git a/scripts/_cases-schema.mjs b/scripts/_cases-schema.mjs new file mode 100644 index 0000000..575fa09 --- /dev/null +++ b/scripts/_cases-schema.mjs @@ -0,0 +1,139 @@ +/** + * Standalone Zod schema for the cases content collection. + * + * This module intentionally mirrors the schema declared in + * src/content/config.ts (casesCollection / caseStepSchema / caseOptionSchema + * / evidenceItemSchema / caseGroundTruthSchema / caseDebriefSchema) — + * WITHOUT importing from Astro, so CI and Node scripts can validate YAML + * files without booting the Astro runtime. Mirrors the existing + * _questions-schema.mjs / _challenges-schema.mjs drift-prevention + * convention. + * + * IMPORTANT: When src/content/config.ts's case schema is updated, this file + * MUST be updated in lockstep. + * + * Dependency: zod (transitive, available via node_modules from astro). + */ + +import { z } from 'zod'; + +export const EVIDENCE_KINDS = /** @type {const} */ ([ + 'visual', + 'ri', + 'sg', + 'birefringence', + 'optic-character', + 'pleochroism', + 'spectroscope', + 'uv-fluorescence', + 'chelsea-filter', + 'inclusion', + 'hardness', + 'other', +]); + +export const CASE_OPTION_WEIGHTS = /** @type {const} */ (['optimal', 'acceptable', 'poor']); + +export const CASE_STEP_TYPES = /** @type {const} */ ([ + 'choose-next-test', + 'reading-interpretation', + 'candidate-narrowing', + 'final-identification', + 'treatment-call', +]); + +export const CASE_DIFFICULTIES = /** @type {const} */ (['foundation', 'intermediate', 'diploma']); + +// ── Evidence ───────────────────────────────────────────────────────────────── + +export const evidenceItemSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + kind: z.enum(EVIDENCE_KINDS), + label: z.string(), + value: z.string(), + detail: z.string().optional(), + toolHref: z.string().optional(), +}); + +// ── Option / step ──────────────────────────────────────────────────────────── + +export const caseOptionSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + text: z.string().min(1), + weight: z.enum(CASE_OPTION_WEIGHTS), + score: z.number().int().min(0).max(10), + rationale: z.string(), + revealsEvidenceIds: z.array(z.string()).optional(), + candidatesAfter: z + .array(z.object({ familyId: z.string(), name: z.string() })) + .optional(), + timeCost: z.number().int().min(0).max(5).optional(), + // Escape hatch for the weight/score-consistency lint in validate-cases.mjs + // (optimal->10, acceptable->5, poor->0 is the default expectation). Not a + // content-schema field consumed at runtime — purely a validator directive. + allowScoreOverride: z.boolean().optional(), +}); + +export const caseStepSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + type: z.enum(CASE_STEP_TYPES), + prompt: z.string().min(1), + evidenceRevealed: z.array(evidenceItemSchema).optional(), + options: z.array(caseOptionSchema).min(2).max(6), + pointsMultiplier: z.number().int().min(1).max(3).default(1), + learnLinks: z.array(z.string()).optional(), + toolLinks: z.array(z.object({ href: z.string(), label: z.string() })).optional(), +}); + +// ── Ground truth / debrief ─────────────────────────────────────────────────── + +export const caseGroundTruthSchema = z.object({ + speciesFamilyId: z.string(), + variety: z.string().optional(), + treatment: z.string().optional(), + originNote: z.string().optional(), +}); + +export const caseDebriefSchema = z.object({ + summary: z.string(), + expertPath: z.array(z.string()).min(1), + furtherReading: z.array(z.string()).optional(), +}); + +// ── Main case schema ───────────────────────────────────────────────────────── + +export const caseSchema = z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + difficulty: z.enum(CASE_DIFFICULTIES), + estimatedMinutes: z.number().int(), + backstory: z.string(), + specimenSummary: z.string(), + groundTruth: caseGroundTruthSchema, + steps: z.array(caseStepSchema).min(3), + debrief: caseDebriefSchema, + conceptTags: z.array(z.string()).optional(), + references: z + .array( + z.object({ + id: z.string(), + citation: z.string(), + url: z.string().url().optional(), + }), + ) + .optional(), + unvetted: z.boolean().default(false), + }) + .refine((c) => c.steps.some((s) => s.type === 'final-identification'), { + message: 'Case must contain at least one final-identification step', + }); + +/** + * Ordered list of top-level field names declared in the schema. + * Used by an optional future drift-detection test (mirrors + * scripts/validate-questions.test.mjs's pattern for the questions schema). + */ +export const SCHEMA_FIELD_NAMES = Object.keys( + /** @type {z.ZodObject} */ (caseSchema._def.schema).shape, +); diff --git a/scripts/_challenges-schema.mjs b/scripts/_challenges-schema.mjs new file mode 100644 index 0000000..51274fd --- /dev/null +++ b/scripts/_challenges-schema.mjs @@ -0,0 +1,76 @@ +/** + * Standalone Zod schema for the challenges content collection. + * + * This module intentionally mirrors the schema declared in + * src/content/config.ts (challengeTrackSchema / challengeStageSchema) — + * WITHOUT importing from Astro, so CI and Node scripts can validate YAML + * files without booting the Astro runtime. + * + * IMPORTANT: When src/content/config.ts's challenge schema is updated, this + * file MUST be updated in lockstep (mirrors the existing + * _questions-schema.mjs / config.ts drift-prevention convention). + * + * Dependency: zod (transitive, available via node_modules from astro). + */ + +import { z } from 'zod'; + +// ── Categories (must match src/content/config.ts exactly) ────────────────── + +export const CATEGORIES = /** @type {const} */ ([ + 'fundamentals', + 'equipment', + 'species', + 'identification', + 'phenomena', + 'origin', + 'market', + 'care', +]); + +// ── Stage sub-schema ───────────────────────────────────────────────────────── + +export const challengeStageSchema = z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + description: z.string().optional(), + // Explicit-ids path: an ordered set of curated question ids. + questionIds: z.array(z.string()).min(1).optional(), + // Tag-fallback path: pull `count` questions matching conceptTags. + conceptTags: z.array(z.string()).min(1).optional(), + tagMatch: z.enum(['all', 'any']).default('all'), + count: z.number().int().min(1).optional(), + passThreshold: z.number().min(0).max(1).default(0.7), + }) + .refine( + (stage) => Boolean(stage.questionIds) || Boolean(stage.conceptTags && stage.count), + { + message: + 'Stage must define either questionIds, or both conceptTags and count (tag-fallback selection)', + }, + ); + +// ── Main track schema ──────────────────────────────────────────────────────── + +export const challengeTrackSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + description: z.string(), + icon: z.string().optional(), + categoryAffinity: z.enum(CATEGORIES).optional(), + conceptTags: z.array(z.string()).optional(), + estimatedMinutes: z.number().int().optional(), + stages: z.array(challengeStageSchema).min(1), +}); + +/** + * Ordered list of top-level field names declared in the schema. + * Used by an optional future drift-detection test (mirrors + * scripts/validate-questions.test.mjs's pattern for the questions schema). + */ +export const SCHEMA_FIELD_NAMES = Object.keys(challengeTrackSchema.shape); + +export const STAGE_SCHEMA_FIELD_NAMES = Object.keys( + /** @type {z.ZodObject} */ (challengeStageSchema._def.schema).shape, +); diff --git a/scripts/new-case.mjs b/scripts/new-case.mjs new file mode 100644 index 0000000..f808da8 --- /dev/null +++ b/scripts/new-case.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * new-case.mjs — scaffold a new Lab Simulation case YAML stub. + * + * Usage: + * node scripts/new-case.mjs --category= --slug= [--difficulty=] + * + * Options: + * --category Required. Content subdirectory under src/content/cases/ + * (e.g. identification, treatments, origin). Free-form — + * unlike questions, cases are not tied to the 8 quiz + * categories; use whatever grouping makes sense for cases. + * --slug Required. Kebab-case identifier. Becomes the case `id` + * field. Must match /^[a-z0-9][a-z0-9-]*$/. + * --difficulty Optional. Default: intermediate. + * One of: foundation intermediate diploma + * + * Behaviour: + * - Writes src/content/cases//.yaml + * - Refuses to overwrite an existing file (idempotent) + * - Exits 0 on success, 1 on any error + */ + +import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CASE_DIFFICULTIES } from './_cases-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +// ── Argument parsing ───────────────────────────────────────────────────────── + +function parseArgs(argv) { + const args = {}; + for (const arg of argv.slice(2)) { + const m = arg.match(/^--([a-z-]+)=(.+)$/); + if (m) { + args[m[1]] = m[2]; + } + } + return args; +} + +const args = parseArgs(process.argv); + +const category = args['category']; +const slug = args['slug']; +const difficulty = args['difficulty'] ?? 'intermediate'; + +// ── Validation ──────────────────────────────────────────────────────────────── + +const errors = []; + +if (!category) { + errors.push('--category is required.'); +} else if (!/^[a-z0-9][a-z0-9-]*$/.test(category)) { + errors.push( + `--category="${category}" is invalid. Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase, digits, hyphens only).`, + ); +} + +if (!slug) { + errors.push('--slug is required.'); +} else if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { + errors.push( + `--slug="${slug}" is invalid. Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase, digits, hyphens only, no leading hyphen).`, + ); +} + +if (!CASE_DIFFICULTIES.includes(difficulty)) { + errors.push(`--difficulty="${difficulty}" is invalid. Valid values: ${CASE_DIFFICULTIES.join(', ')}`); +} + +if (errors.length > 0) { + for (const e of errors) { + console.error(`Error: ${e}`); + } + process.exit(1); +} + +// ── Output path ─────────────────────────────────────────────────────────────── + +const outDir = resolve(ROOT, 'src', 'content', 'cases', category); +const outPath = resolve(outDir, `${slug}.yaml`); + +if (existsSync(outPath)) { + console.error(`Error: File already exists — ${outPath}`); + console.error('Refusing to overwrite. Rename or delete it first.'); + process.exit(1); +} + +// ── Stub generation ─────────────────────────────────────────────────────────── + +const stub = `# Case stub — fill in every placeholder marked with TODO. +# Run npm run validate:cases to check this file before committing. +# See src/content/cases/_example/example-case.yaml for a complete, +# heavily-commented example and docs/authoring-cases.md for the full guide. + +id: ${slug} +title: "TODO: Case title (e.g. \\"The Dealer's Ruby\\")" +difficulty: ${difficulty} +estimatedMinutes: 10 + +backstory: | + TODO: Set the scene — who brought the stone in, and why. One or two + sentences, written as a real consulting-room / lab-bench situation. + +specimenSummary: | + TODO: Describe what the learner can see before any testing: shape, colour, + transparency, approximate weight. No instrument readings here yet — those + belong in evidenceRevealed on the relevant steps. + +groundTruth: + speciesFamilyId: TODO-mineral-database-family-id + # variety: TODO + # treatment: TODO + # originNote: TODO + +# Every property value cited anywhere in this case (RI, SG, spectroscope +# bands, treatment clues, etc.) MUST match mineral_families data or +# SPECTROSCOPE_REFERENCE, with a citation in the \`references\` block below. +# See docs/authoring-cases.md's verification checklist before setting +# unvetted: false. + +steps: + # At least 3 steps required; exactly one must be type: final-identification. + - id: step-1 + type: choose-next-test + prompt: "TODO: What does the learner decide to check first?" + options: + - id: opt-a + text: "TODO: the diagnostically fastest choice" + weight: optimal + score: 10 + rationale: "TODO: why this is the best next test" + # revealsEvidenceIds: [ev-1] + # timeCost: 1 + - id: opt-b + text: "TODO: a slower but still useful choice" + weight: acceptable + score: 5 + rationale: "TODO: why this works but isn't optimal" + # timeCost: 2 + - id: opt-c + text: "TODO: a poor choice" + weight: poor + score: 0 + rationale: "TODO: why this doesn't help" + # timeCost: 3 + pointsMultiplier: 1 + # evidenceRevealed: + # - id: ev-1 + # kind: ri + # label: "TODO" + # value: "TODO" + # detail: "TODO" + # toolHref: /tools/measurement + # learnLinks: [TODO-learn-slug] + # toolLinks: + # - href: /tools/measurement + # label: "TODO tool label" + + - id: step-2 + type: reading-interpretation + prompt: "TODO" + options: + - id: opt-a + text: "TODO" + weight: optimal + score: 10 + rationale: "TODO" + - id: opt-b + text: "TODO" + weight: poor + score: 0 + rationale: "TODO" + pointsMultiplier: 1 + + - id: step-3 + type: final-identification + prompt: "TODO: What is the final identification?" + options: + - id: opt-a + text: "TODO: correct identification" + weight: optimal + score: 10 + rationale: "TODO" + - id: opt-b + text: "TODO: plausible near-miss" + weight: poor + score: 0 + rationale: "TODO" + pointsMultiplier: 2 + +debrief: + summary: | + TODO: State the final identification plainly and summarize the + diagnostic chain that led there. + expertPath: + - "TODO: step 1 of what an expert would actually do" + - "TODO: step 2" + - "TODO: step 3" + # furtherReading: + # - "TODO: e.g. Hughes, R.W. Ruby & Sapphire: A Gemologist's Guide." + +# conceptTags: +# - TODO-tag-1 + +# references: +# - id: TODO-citation-id +# citation: "TODO: full citation" +# url: https://example.com/optional-url + +unvetted: true # Set to false only after FGA-qualified review and property verification. +`; + +// ── Write ───────────────────────────────────────────────────────────────────── + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outPath, stub, 'utf8'); + +console.log(`Created: ${outPath}`); +console.log(`Next steps:`); +console.log(` 1. Open the file and fill in every TODO placeholder.`); +console.log(` 2. Verify every property value against mineral_families / SPECTROSCOPE_REFERENCE with a citation.`); +console.log(` 3. Run: npm run validate:cases`); +console.log(` 4. Set unvetted: false after FGA-qualified review.`); diff --git a/scripts/validate-cases.mjs b/scripts/validate-cases.mjs new file mode 100644 index 0000000..40a2c7b --- /dev/null +++ b/scripts/validate-cases.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * validate-cases.mjs — validate every YAML under src/content/cases/** + * against the published Zod schema, then apply case-specific lints that the + * schema alone cannot express: + * + * - Option ids must be unique within a step. + * - Every step must have at least one `optimal`-weight option. + * - The case must contain exactly one `final-identification` step. + * - `candidatesAfter` list length must be non-increasing across the + * sequence of steps that declare it (candidate narrowing should never + * widen the field). + * - weight/score consistency: optimal -> 10, acceptable -> 5, poor -> 0, + * unless the option sets `allowScoreOverride: true`. + * - Every `revealsEvidenceIds` entry must resolve to an evidence item + * defined somewhere in the case (any step's `evidenceRevealed`). + * + * Exit codes: + * 0 — all files valid (including the case of zero files found) + * 1 — one or more files failed validation or lint, or a parse/read error + * occurred + * + * Usage: + * node scripts/validate-cases.mjs + * npm run validate:cases + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve, relative, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import yaml from 'js-yaml'; +import { caseSchema } from './_cases-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const CASES_DIR = resolve(ROOT, 'src', 'content', 'cases'); + +const EXPECTED_SCORE_BY_WEIGHT = { + optimal: 10, + acceptable: 5, + poor: 0, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Recursively collect all .yaml files under a directory, excluding paths + * whose components start with '_' (Astro glob-loader convention). + */ +function collectYamlFiles(dir) { + let results = []; + let entries; + try { + entries = readdirSync(dir); + } catch { + return results; // directory doesn't exist — zero files, valid state + } + + for (const entry of entries) { + if (entry.startsWith('_')) continue; // skip _example, _schema, etc. + const full = resolve(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + results = results.concat(collectYamlFiles(full)); + } else if (stat.isFile() && extname(entry) === '.yaml') { + results.push(full); + } + } + return results; +} + +function formatZodError(zodError) { + return zodError.errors + .map((e) => { + const path = e.path.length > 0 ? e.path.join('.') : '(root)'; + return ` [${path}] ${e.message}`; + }) + .join('\n'); +} + +/** Applies the case-specific lints described in the module docblock. */ +function lintCase(caseData) { + const errors = []; + + // ── Exactly one final-identification step ────────────────────────────── + const finalSteps = caseData.steps.filter((s) => s.type === 'final-identification'); + if (finalSteps.length !== 1) { + errors.push( + ` [steps] expected exactly 1 final-identification step, found ${finalSteps.length}`, + ); + } + + // ── Evidence catalogue: every evidence item defined anywhere ──────────── + const evidenceIds = new Set(); + for (const step of caseData.steps) { + for (const item of step.evidenceRevealed ?? []) { + evidenceIds.add(item.id); + } + } + + let previousCandidateCount = null; + + for (const step of caseData.steps) { + // ── Option ids unique per step ───────────────────────────────────── + const seenOptionIds = new Set(); + for (const option of step.options) { + if (seenOptionIds.has(option.id)) { + errors.push(` [steps.${step.id}] duplicate option id "${option.id}"`); + } + seenOptionIds.add(option.id); + } + + // ── At least one optimal option per step ─────────────────────────── + if (!step.options.some((o) => o.weight === 'optimal')) { + errors.push(` [steps.${step.id}] step has no "optimal"-weight option`); + } + + // ── weight/score consistency ──────────────────────────────────────── + for (const option of step.options) { + const expected = EXPECTED_SCORE_BY_WEIGHT[option.weight]; + if (option.score !== expected && !option.allowScoreOverride) { + errors.push( + ` [steps.${step.id}.options.${option.id}] weight "${option.weight}" expects ` + + `score ${expected}, got ${option.score} (set allowScoreOverride: true to permit this deliberately)`, + ); + } + } + + // ── revealsEvidenceIds resolve to a defined evidence item ─────────── + for (const option of step.options) { + for (const id of option.revealsEvidenceIds ?? []) { + if (!evidenceIds.has(id)) { + errors.push( + ` [steps.${step.id}.options.${option.id}] revealsEvidenceIds references undefined evidence id "${id}"`, + ); + } + } + } + + // ── candidatesAfter non-increasing across narrowing steps ────────── + // Checked on any step whose options declare candidatesAfter (not just + // steps typed candidate-narrowing — a reading-interpretation step can + // narrow the field too), in step order across the whole case. + const counts = step.options + .map((o) => o.candidatesAfter?.length) + .filter((n) => typeof n === 'number'); + if (counts.length > 0) { + const maxCount = Math.max(...counts); + if (previousCandidateCount !== null && maxCount > previousCandidateCount) { + errors.push( + ` [steps.${step.id}] candidatesAfter widens the candidate field (previous narrowing step ` + + `allowed ${previousCandidateCount}, this step allows up to ${maxCount})`, + ); + } + previousCandidateCount = maxCount; + } + } + + return errors; +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +const files = collectYamlFiles(CASES_DIR); + +if (files.length === 0) { + console.log('No case files found. Validation passed (nothing to validate).'); + process.exit(0); +} + +let hasErrors = false; +let validCount = 0; + +for (const filePath of files) { + const rel = relative(ROOT, filePath); + let raw; + + try { + const src = readFileSync(filePath, 'utf8'); + raw = yaml.load(src); + } catch (err) { + console.error(`FAIL ${rel}`); + console.error(` YAML parse error: ${err.message}`); + hasErrors = true; + continue; + } + + const result = caseSchema.safeParse(raw); + if (!result.success) { + console.error(`FAIL ${rel}`); + console.error(formatZodError(result.error)); + hasErrors = true; + continue; + } + + const lintErrors = lintCase(result.data); + if (lintErrors.length > 0) { + console.error(`FAIL ${rel}`); + console.error(lintErrors.join('\n')); + hasErrors = true; + continue; + } + + console.log(`ok ${rel}`); + validCount += 1; +} + +console.log(''); +console.log(`Validated ${validCount}/${files.length} files successfully.`); + +process.exit(hasErrors ? 1 : 0); diff --git a/scripts/validate-challenges.mjs b/scripts/validate-challenges.mjs new file mode 100644 index 0000000..5b2c448 --- /dev/null +++ b/scripts/validate-challenges.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * validate-challenges.mjs — validate every YAML under src/content/challenges/** + * against the published Zod schema, then cross-reference each stage against + * the curated question bank (src/content/questions/**): + * + * - explicit-ids stages: every id in `questionIds` must exist as a + * non-deprecated question. + * - tag-fallback stages: the number of non-deprecated questions matching + * `conceptTags` (per `tagMatch`) must be >= `count`. + * + * Exit codes: + * 0 — all files valid (including the case of zero files found) + * 1 — one or more files failed validation or cross-reference, or a + * parse/read error occurred + * + * Usage: + * node scripts/validate-challenges.mjs + * npm run validate:challenges + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve, relative, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import yaml from 'js-yaml'; +import { challengeTrackSchema } from './_challenges-schema.mjs'; +import { questionSchema } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const CHALLENGES_DIR = resolve(ROOT, 'src', 'content', 'challenges'); +const QUESTIONS_DIR = resolve(ROOT, 'src', 'content', 'questions'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Recursively collect all .yaml files under a directory, excluding paths + * whose components start with '_' (Astro glob-loader convention). + */ +function collectYamlFiles(dir) { + let results = []; + let entries; + try { + entries = readdirSync(dir); + } catch { + return results; // directory doesn't exist — zero files, valid state + } + + for (const entry of entries) { + if (entry.startsWith('_')) continue; // skip _example, _schema, etc. + const full = resolve(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + results = results.concat(collectYamlFiles(full)); + } else if (stat.isFile() && extname(entry) === '.yaml') { + results.push(full); + } + } + return results; +} + +function formatZodError(zodError) { + return zodError.errors + .map((e) => { + const path = e.path.length > 0 ? e.path.join('.') : '(root)'; + return ` [${path}] ${e.message}`; + }) + .join('\n'); +} + +// ── Load the question bank (for cross-referencing) ───────────────────────── + +/** + * Returns { byId: Map, all: question[] } of every + * non-deprecated, schema-valid curated question. Malformed question files + * are silently skipped here — validate-questions.mjs is the source of truth + * for question-bank shape errors; this script only needs a best-effort pool + * to cross-reference against. + */ +function loadQuestionPool() { + const files = collectYamlFiles(QUESTIONS_DIR); + const byId = new Map(); + const all = []; + + for (const filePath of files) { + let raw; + try { + raw = yaml.load(readFileSync(filePath, 'utf8')); + } catch { + continue; + } + const result = questionSchema.safeParse(raw); + if (!result.success) continue; + const q = result.data; + if (q.deprecated) continue; + byId.set(q.id, q); + all.push(q); + } + + return { byId, all }; +} + +function questionMatchesTags(question, tags, tagMatch) { + const qTags = question.conceptTags ?? []; + return tagMatch === 'any' + ? tags.some((t) => qTags.includes(t)) + : tags.every((t) => qTags.includes(t)); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +const files = collectYamlFiles(CHALLENGES_DIR); + +if (files.length === 0) { + console.log('No challenge track files found. Validation passed (nothing to validate).'); + process.exit(0); +} + +const { byId: questionsById, all: allQuestions } = loadQuestionPool(); + +let hasErrors = false; +let validCount = 0; + +for (const filePath of files) { + const rel = relative(ROOT, filePath); + let raw; + + try { + const src = readFileSync(filePath, 'utf8'); + raw = yaml.load(src); + } catch (err) { + console.error(`FAIL ${rel}`); + console.error(` YAML parse error: ${err.message}`); + hasErrors = true; + continue; + } + + const result = challengeTrackSchema.safeParse(raw); + if (!result.success) { + console.error(`FAIL ${rel}`); + console.error(formatZodError(result.error)); + hasErrors = true; + continue; + } + + const track = result.data; + const crossRefErrors = []; + + for (const stage of track.stages) { + if (stage.questionIds && stage.questionIds.length > 0) { + for (const id of stage.questionIds) { + if (!questionsById.has(id)) { + crossRefErrors.push( + ` [stages] stage "${stage.id}": questionId "${id}" not found in the non-deprecated question bank`, + ); + } + } + } else if (stage.conceptTags && stage.count) { + const matched = allQuestions.filter((q) => + questionMatchesTags(q, stage.conceptTags, stage.tagMatch), + ); + if (matched.length < stage.count) { + crossRefErrors.push( + ` [stages] stage "${stage.id}": tag-fallback needs ${stage.count} matching question(s) ` + + `(tags: ${stage.conceptTags.join(', ')}; tagMatch: ${stage.tagMatch}) but only ${matched.length} found`, + ); + } + } + } + + if (crossRefErrors.length > 0) { + console.error(`FAIL ${rel}`); + console.error(crossRefErrors.join('\n')); + hasErrors = true; + continue; + } + + console.log(`ok ${rel}`); + validCount += 1; +} + +console.log(''); +console.log(`Validated ${validCount}/${files.length} files successfully.`); + +process.exit(hasErrors ? 1 : 0); diff --git a/src/components/cases/CaseCard.tsx b/src/components/cases/CaseCard.tsx new file mode 100644 index 0000000..32f71b7 --- /dev/null +++ b/src/components/cases/CaseCard.tsx @@ -0,0 +1,106 @@ +/** + * CaseCard — hub card for a single Lab Simulation case. + * Modeled on src/components/quiz/challenges/ChallengesHub.tsx's track card. + * + * Reads its own completed/score state from LocalCaseStore on mount via + * useEffect (SSR-safe, avoids a hydration mismatch) so each card is an + * independent island rather than requiring the whole hub to hydrate one + * shared component. + */ + +import { useEffect, useState } from 'react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '../ui/Card'; +import { IconBox } from '../ui/IconBox'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; +import { DifficultyBadge } from '../ui/DifficultyBadge'; +import { getCaseStore } from '../../lib/cases/store'; +import type { CaseStoredEntry } from '../../lib/cases/store'; + +/** Maps a case's 3-level difficulty onto DifficultyBadge's beginner/intermediate/advanced scale. */ +const DIFFICULTY_LEVEL_MAP = { + foundation: 'beginner', + intermediate: 'intermediate', + diploma: 'advanced', +} as const; + +/** Plain-data summary of one case, built at build time by the Astro page. */ +export interface CaseSummary { + id: string; + title: string; + difficulty: 'foundation' | 'intermediate' | 'diploma'; + estimatedMinutes: number; + specimenSummary: string; +} + +interface CaseCardProps { + caseSummary: CaseSummary; +} + +export function CaseCard({ caseSummary }: CaseCardProps) { + const [entry, setEntry] = useState(null); + + useEffect(() => { + try { + setEntry(getCaseStore().getCaseState(caseSummary.id)); + } catch (err) { + console.warn('[CaseCard] failed to load case state:', err); + } + }, [caseSummary.id]); + + const isComplete = entry?.state.status === 'complete'; + const hasProgress = entry !== null && !isComplete; + + return ( + + +
+ + {isComplete && entry?.result && ( + Solved · {entry.result.grade} + )} +
+ {caseSummary.title} + {caseSummary.specimenSummary} +
+ + +
+ + + ~{caseSummary.estimatedMinutes} min +
+ + {isComplete && entry?.result && ( +

+ Best result: {entry.result.percentage}% (+{entry.result.efficiencyBonus} efficiency) +

+ )} + {hasProgress && ( +

In progress — resume where you left off.

+ )} +
+ + + + +
+ ); +} diff --git a/src/components/cases/CaseDebrief.test.tsx b/src/components/cases/CaseDebrief.test.tsx new file mode 100644 index 0000000..4adf546 --- /dev/null +++ b/src/components/cases/CaseDebrief.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CaseDebrief } from './CaseDebrief'; +import type { CaseDefinition, CaseResult } from '../../lib/cases/case-types'; + +const CASE_DATA: CaseDefinition = { + id: 'test-case', + title: "Dealer's Ruby", + difficulty: 'intermediate', + estimatedMinutes: 10, + backstory: 'A dealer offers you a red stone.', + specimenSummary: 'A 2ct red faceted stone.', + groundTruth: { speciesFamilyId: 'corundum', treatment: 'heated' }, + steps: [ + { + id: 'step-1', + type: 'choose-next-test', + prompt: 'What do you check first?', + options: [ + { id: 'opt-a', text: 'Refractometer', weight: 'optimal', score: 10, rationale: 'Diagnostic RI window.' }, + { id: 'opt-b', text: 'Guess', weight: 'poor', score: 0, rationale: 'No evidence.' }, + ], + pointsMultiplier: 1, + }, + { + id: 'step-2', + type: 'final-identification', + prompt: 'What is it?', + options: [ + { id: 'opt-c', text: 'Natural ruby, heat-treated', weight: 'optimal', score: 10, rationale: 'Matches all evidence.' }, + { id: 'opt-d', text: 'Red spinel', weight: 'poor', score: 0, rationale: 'Spinel is isotropic.' }, + ], + pointsMultiplier: 2, + }, + ], + debrief: { + summary: 'The stone is a heat-treated natural ruby.', + expertPath: ['Check RI first.', 'Confirm with SG.', 'Look for silk inclusions.', 'Conclude: heated ruby.'], + furtherReading: ['Hughes, Ruby & Sapphire, ch. 4'], + }, + conceptTags: ['corundum', 'treatments'], + references: [{ id: 'hughes-ruby-sapphire', citation: "Hughes, R.W. Ruby & Sapphire: A Gemologist's Guide." }], + unvetted: false, +}; + +const RESULT: CaseResult = { + caseId: 'test-case', + rawScore: 30, + maxScore: 30, + percentage: 100, + efficiencyBonus: 2, + grade: 'A', + decisions: [ + { + stepId: 'step-1', + optionId: 'opt-a', + weight: 'optimal', + scoreAwarded: 10, + timeCostIncurred: 1, + timeMs: 500, + timestamp: Date.now(), + }, + { + stepId: 'step-2', + optionId: 'opt-c', + weight: 'optimal', + scoreAwarded: 20, + timeCostIncurred: 1, + timeMs: 800, + timestamp: Date.now(), + }, + ], + completedAt: Date.now(), +}; + +describe('CaseDebrief', () => { + it('shows the grade and score', () => { + render(); + expect(screen.getByText(/Grade A/)).toBeInTheDocument(); + expect(screen.getByText(/30\/30/)).toBeInTheDocument(); + expect(screen.getByText(/100%/)).toBeInTheDocument(); + }); + + it('shows the efficiency bonus badge when positive', () => { + render(); + expect(screen.getByText(/\+2 efficiency bonus/)).toBeInTheDocument(); + }); + + it('omits the efficiency bonus badge when zero', () => { + render( + , + ); + expect(screen.queryByText(/efficiency bonus/)).not.toBeInTheDocument(); + }); + + it('renders the debrief summary', () => { + render(); + expect(screen.getByText('The stone is a heat-treated natural ruby.')).toBeInTheDocument(); + }); + + it('renders the expert path as an ordered list', () => { + render(); + expect(screen.getByText('Check RI first.')).toBeInTheDocument(); + expect(screen.getByText('Conclude: heated ruby.')).toBeInTheDocument(); + }); + + it('renders further reading links/items', () => { + render(); + expect(screen.getByText('Hughes, Ruby & Sapphire, ch. 4')).toBeInTheDocument(); + }); + + it('renders reference citations', () => { + render(); + expect(screen.getByText(/Hughes, R\.W\. Ruby & Sapphire/)).toBeInTheDocument(); + }); + + it('renders a decision-by-decision review with each chosen option and rationale', () => { + render(); + expect(screen.getByText(/Refractometer/)).toBeInTheDocument(); + expect(screen.getByText('Diagnostic RI window.')).toBeInTheDocument(); + expect(screen.getByText(/Natural ruby, heat-treated/)).toBeInTheDocument(); + expect(screen.getByText('Matches all evidence.')).toBeInTheDocument(); + }); + + it('calls onRestart when the restart button is clicked', async () => { + const user = userEvent.setup(); + const onRestart = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: /try again/i })); + expect(onRestart).toHaveBeenCalled(); + }); +}); diff --git a/src/components/cases/CaseDebrief.tsx b/src/components/cases/CaseDebrief.tsx new file mode 100644 index 0000000..57dbfe9 --- /dev/null +++ b/src/components/cases/CaseDebrief.tsx @@ -0,0 +1,104 @@ +/** + * CaseDebrief — completion screen: grade + score + efficiency badge, + * decision-by-decision review with rationale, expert path, and references. + */ + +import { Card, CardHeader, CardTitle, CardContent } from '../ui/Card'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; +import type { CaseDefinition, CaseResult, CaseOptionWeight } from '../../lib/cases/case-types'; + +interface CaseDebriefProps { + caseData: CaseDefinition; + result: CaseResult; + onRestart: () => void; +} + +function tierBadgeVariant(weight: CaseOptionWeight): 'emerald' | 'topaz' | 'ruby' { + if (weight === 'optimal') return 'emerald'; + if (weight === 'acceptable') return 'topaz'; + return 'ruby'; +} + +export function CaseDebrief({ caseData, result, onRestart }: CaseDebriefProps) { + return ( +
+ + +
+ Case complete + Grade {result.grade} +
+
+ +
+ + Score: {result.rawScore}/{result.maxScore} ({result.percentage}%) + + {result.efficiencyBonus > 0 && ( + +{result.efficiencyBonus} efficiency bonus + )} +
+

{caseData.debrief.summary}

+ +

Expert path

+
    + {caseData.debrief.expertPath.map((step, i) => ( +
  1. {step}
  2. + ))} +
+ + {caseData.debrief.furtherReading && caseData.debrief.furtherReading.length > 0 && ( + <> +

Further reading

+
    + {caseData.debrief.furtherReading.map((item, i) => ( +
  • {item}
  • + ))} +
+ + )} + + {caseData.references && caseData.references.length > 0 && ( +

+ {caseData.references.map((ref) => ref.citation).join(' ')} +

+ )} +
+
+ + + + Decision review + + +
    + {result.decisions.map((decision, i) => { + const step = caseData.steps.find((s) => s.id === decision.stepId); + const option = step?.options.find((o) => o.id === decision.optionId); + if (!step || !option) return null; + return ( +
  1. +

    + {i + 1}. {step.prompt} +

    +

    + Chosen: {option.text}{' '} + + +{decision.scoreAwarded} pts + +

    +

    {option.rationale}

    +
  2. + ); + })} +
+
+
+ + +
+ ); +} diff --git a/src/components/cases/CaseIntro.tsx b/src/components/cases/CaseIntro.tsx new file mode 100644 index 0000000..e62e7ef --- /dev/null +++ b/src/components/cases/CaseIntro.tsx @@ -0,0 +1,56 @@ +/** + * CaseIntro — the backstory/specimen screen shown before a case's first + * step. Offers "Resume case" instead of "Start case" when a saved + * in-progress attempt already exists. + */ + +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'; +import { Badge } from '../ui/Badge'; +import { DifficultyBadge } from '../ui/DifficultyBadge'; +import { Button } from '../ui/Button'; +import type { CaseDefinition } from '../../lib/cases/case-types'; + +const DIFFICULTY_LEVEL_MAP = { + foundation: 'beginner', + intermediate: 'intermediate', + diploma: 'advanced', +} as const; + +interface CaseIntroProps { + caseData: CaseDefinition; + /** Whether a saved in-progress attempt exists for this case. */ + hasSavedProgress: boolean; + onStart: () => void; +} + +export function CaseIntro({ caseData, hasSavedProgress, onStart }: CaseIntroProps) { + return ( + + +
+ + {caseData.unvetted && Unvetted} +
+ {caseData.title} + ~{caseData.estimatedMinutes} min · {caseData.steps.length} steps +
+ +

{caseData.backstory}

+

Specimen

+

{caseData.specimenSummary}

+ {caseData.conceptTags && caseData.conceptTags.length > 0 && ( +
+ {caseData.conceptTags.map((tag) => ( + {tag} + ))} +
+ )} +
+ + + +
+ ); +} diff --git a/src/components/cases/CaseOptionList.test.tsx b/src/components/cases/CaseOptionList.test.tsx new file mode 100644 index 0000000..7353e7b --- /dev/null +++ b/src/components/cases/CaseOptionList.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CaseOptionList } from './CaseOptionList'; +import type { CaseOption } from '../../lib/cases/case-types'; + +const OPTIONS: CaseOption[] = [ + { id: 'opt-a', text: 'Reach for the refractometer', weight: 'optimal', score: 10, rationale: 'Fast and diagnostic.' }, + { id: 'opt-b', text: 'Reach for the spectroscope', weight: 'acceptable', score: 5, rationale: 'Works, but slower here.' }, + { id: 'opt-c', text: 'Guess by eye', weight: 'poor', score: 0, rationale: 'No evidence gathered.' }, +]; + +describe('CaseOptionList', () => { + it('renders all options with A/B/C labels', () => { + render( + , + ); + expect(screen.getByText('Reach for the refractometer')).toBeInTheDocument(); + expect(screen.getByText('Reach for the spectroscope')).toBeInTheDocument(); + expect(screen.getByText('Guess by eye')).toBeInTheDocument(); + }); + + it('calls onSelect with the option id when clicked, before submit', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render( + , + ); + await user.click(screen.getByText('Reach for the refractometer')); + expect(onSelect).toHaveBeenCalledWith('opt-a'); + }); + + it('marks the selected option as aria-pressed before submit', () => { + render( + , + ); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[1]).toHaveAttribute('aria-pressed', 'false'); + }); + + it('disables all options once submitted', () => { + render( + , + ); + for (const button of screen.getAllByRole('button')) { + expect(button).toBeDisabled(); + } + }); + + it('does not call onSelect when clicked after submission', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render( + , + ); + await user.click(screen.getByText('Reach for the spectroscope')); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('reveals tier badges for every option once submitted, not just the chosen one', () => { + render( + , + ); + expect(screen.getByText(/Optimal.*\+10 pts/)).toBeInTheDocument(); + expect(screen.getByText(/Acceptable.*\+5 pts/)).toBeInTheDocument(); + expect(screen.getByText(/Poor.*\+0 pts/)).toBeInTheDocument(); + }); + + it('marks only the chosen option as "(your choice)"', () => { + render( + , + ); + expect(screen.getByText('(your choice)')).toBeInTheDocument(); + // Only one option should carry the "your choice" annotation. + expect(screen.getAllByText('(your choice)')).toHaveLength(1); + }); +}); diff --git a/src/components/cases/CaseOptionList.tsx b/src/components/cases/CaseOptionList.tsx new file mode 100644 index 0000000..bca5c9f --- /dev/null +++ b/src/components/cases/CaseOptionList.tsx @@ -0,0 +1,123 @@ +/** + * CaseOptionList — the list of decision options for a single case step. + * + * Markup and a11y are modeled on src/components/quiz/AnswerOption.tsx + * (aria-pressed, disabled after submit, per-option aria-label), extended to a + * 3-tier post-submit reveal instead of a binary correct/incorrect one: + * optimal = emerald, acceptable = amber, poor = red. The option the user + * actually chose gets an additional highlight ring so it stands out among + * same-tier siblings. + * + * Light-only: no `dark:` classes (site convention for new Study components). + */ + +import { cn } from '../ui/cn'; +import { Badge } from '../ui/Badge'; +import type { CaseOption, CaseOptionWeight } from '../../lib/cases/case-types'; + +export const CASE_OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'] as const; + +interface CaseOptionListProps { + options: CaseOption[]; + /** Currently selected (pre-submit) option id. */ + selectedOptionId: string | null; + /** Whether a decision has already been submitted for this step. */ + isSubmitted: boolean; + onSelect: (optionId: string) => void; +} + +function tierBadgeVariant(weight: CaseOptionWeight): 'emerald' | 'topaz' | 'ruby' { + if (weight === 'optimal') return 'emerald'; + if (weight === 'acceptable') return 'topaz'; + return 'ruby'; +} + +function tierLabel(weight: CaseOptionWeight): string { + if (weight === 'optimal') return 'Optimal'; + if (weight === 'acceptable') return 'Acceptable'; + return 'Poor'; +} + +export function CaseOptionList({ + options, + selectedOptionId, + isSubmitted, + onSelect, +}: CaseOptionListProps) { + return ( +
+ {options.map((option, index) => { + const label = CASE_OPTION_LABELS[index] ?? String(index + 1); + const isSelected = selectedOptionId === option.id; + const isChosen = isSubmitted && isSelected; + + const ariaLabelParts = [`Option ${label}: ${option.text}`]; + if (isSelected) ariaLabelParts.push('selected'); + if (isSubmitted) ariaLabelParts.push(`${tierLabel(option.weight)} choice`); + if (isChosen) ariaLabelParts.push('your choice'); + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/cases/CaseProgressStrip.tsx b/src/components/cases/CaseProgressStrip.tsx new file mode 100644 index 0000000..a4e01d3 --- /dev/null +++ b/src/components/cases/CaseProgressStrip.tsx @@ -0,0 +1,39 @@ +/** + * CaseProgressStrip — "Step X of N" progress bar + running score. + * Modeled on src/components/quiz/QuizProgress.tsx. + */ + +import { cn } from '../ui/cn'; + +interface CaseProgressStripProps { + /** Current step index (0-based). */ + current: number; + /** Total number of steps. */ + total: number; + /** Sum of scoreAwarded across decisions recorded so far. */ + runningScore: number; + className?: string; +} + +export function CaseProgressStrip({ current, total, runningScore, className }: CaseProgressStripProps) { + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; + + return ( +
+
+
+
+
+ + Step {current + 1} of {total} + + + Score: {runningScore} + +
+
+ ); +} diff --git a/src/components/cases/CaseRunner.tsx b/src/components/cases/CaseRunner.tsx new file mode 100644 index 0000000..4055cb9 --- /dev/null +++ b/src/components/cases/CaseRunner.tsx @@ -0,0 +1,134 @@ +/** + * CaseRunner — island root for a single Lab Simulation case. + * Wires useCaseRunner through intro -> step loop -> debrief, with a + * ConfirmDialog-gated restart control available throughout. + */ + +import { useState } from 'react'; +import { useCaseRunner } from '../../hooks/useCaseRunner'; +import { ConfirmDialog } from '../quiz/ConfirmDialog'; +import { Button } from '../ui/Button'; +import { CaseIntro } from './CaseIntro'; +import { CaseProgressStrip } from './CaseProgressStrip'; +import { EvidenceNotebook } from './EvidenceNotebook'; +import { CaseStepPanel } from './CaseStepPanel'; +import { CaseDebrief } from './CaseDebrief'; +import type { CaseDefinition } from '../../lib/cases/case-types'; + +interface CaseRunnerProps { + caseData: CaseDefinition; +} + +export function CaseRunner({ caseData }: CaseRunnerProps) { + const { + state, + currentStep, + selectedOptionId, + selectOption, + isSubmitted, + submitDecision, + currentDecision, + nextStep, + resetCase, + isComplete, + result, + revealedEvidence, + } = useCaseRunner({ caseData }); + + // Intro is shown until the learner explicitly starts/resumes, unless the + // case is already complete (reopen straight into the debrief) or already + // mid-run with at least one recorded decision (resuming past the intro). + const [showIntro, setShowIntro] = useState( + !isComplete && state.decisions.length === 0, + ); + const [showRestartConfirm, setShowRestartConfirm] = useState(false); + + const hasSavedProgress = state.decisions.length > 0 || isComplete; + + const handleRestart = () => { + resetCase(); + setShowRestartConfirm(false); + setShowIntro(true); + }; + + if (showIntro) { + return ( + setShowIntro(false)} + /> + ); + } + + if (isComplete && result) { + return ( +
+ setShowRestartConfirm(true)} /> + setShowRestartConfirm(false)} + > +

This clears your saved progress and score for this case. This cannot be undone.

+
+
+ ); + } + + if (!currentStep) return null; + + const isLastStep = state.currentStepIndex >= caseData.steps.length - 1; + const runningScore = state.decisions.reduce((sum, d) => sum + d.scoreAwarded, 0); + + return ( +
+
+ + +
+ +
+
+ +
+ + + +
+ +
+
+ + setShowRestartConfirm(false)} + > +

This clears your saved progress and score for this case. This cannot be undone.

+
+
+ ); +} diff --git a/src/components/cases/CaseStepFeedback.test.tsx b/src/components/cases/CaseStepFeedback.test.tsx new file mode 100644 index 0000000..591a5f2 --- /dev/null +++ b/src/components/cases/CaseStepFeedback.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { CaseStepFeedback } from './CaseStepFeedback'; +import type { CaseStep, DecisionRecord } from '../../lib/cases/case-types'; + +const STEP: CaseStep = { + id: 'step-1', + type: 'choose-next-test', + prompt: 'What do you check first?', + options: [ + { id: 'opt-a', text: 'Refractometer', weight: 'optimal', score: 10, rationale: 'Diagnostic RI window.' }, + { id: 'opt-b', text: 'Spectroscope', weight: 'acceptable', score: 5, rationale: 'Useful, but slower.' }, + { id: 'opt-c', text: 'Guess', weight: 'poor', score: 0, rationale: 'No evidence.' }, + ], + pointsMultiplier: 1, +}; + +function makeDecision(overrides: Partial = {}): DecisionRecord { + return { + stepId: 'step-1', + optionId: 'opt-b', + weight: 'acceptable', + scoreAwarded: 5, + timeCostIncurred: 1, + timeMs: 1000, + timestamp: Date.now(), + ...overrides, + }; +} + +describe('CaseStepFeedback', () => { + it('renders nothing when show=false', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('shows the tier + points badge for an optimal decision', () => { + render( + , + ); + expect(screen.getByText(/\+10 points/)).toBeInTheDocument(); + expect(screen.getByText(/optimal/)).toBeInTheDocument(); + }); + + it('preserves the "acceptable" tier distinction rather than collapsing to correct/incorrect', () => { + render(); + // The tier badge line must say "acceptable" explicitly... + expect(screen.getByText(/\+5 points/)).toBeInTheDocument(); + expect(screen.getByText(/acceptable/)).toBeInTheDocument(); + // ...and RationalePanel's binary heading resolves to "Not quite" since + // acceptable isn't the optimal choice, but the badge above makes clear + // partial credit was actually awarded. + expect(screen.getByText(/not quite: here is why/i)).toBeInTheDocument(); + }); + + it('shows a poor-tier decision with 0 points and the correct badge', () => { + render( + , + ); + expect(screen.getByText(/\+0 points/)).toBeInTheDocument(); + expect(screen.getByText(/poor/)).toBeInTheDocument(); + }); + + it("renders the optimal option's rationale as the main explanation, even when a different option was chosen", () => { + render(); + // Appears twice: once as the main rationale header, once in the + // per-option rationale list — both sourced from the optimal option. + expect(screen.getAllByText('Diagnostic RI window.').length).toBeGreaterThan(0); + }); + + it('renders per-option rationales for all options', () => { + render(); + expect(screen.getAllByText('Diagnostic RI window.').length).toBeGreaterThan(0); + expect(screen.getByText('Useful, but slower.')).toBeInTheDocument(); + expect(screen.getByText('No evidence.')).toBeInTheDocument(); + }); +}); diff --git a/src/components/cases/CaseStepFeedback.tsx b/src/components/cases/CaseStepFeedback.tsx new file mode 100644 index 0000000..0dc2ea4 --- /dev/null +++ b/src/components/cases/CaseStepFeedback.tsx @@ -0,0 +1,63 @@ +/** + * CaseStepFeedback — wraps the existing quiz RationalePanel to show + * post-submit feedback for a case step's decision. + * + * RationalePanel only has a binary `correct` prop, which would render an + * `acceptable`-weight choice as "Not quite" even though partial credit was + * awarded. To keep the tri-state tier legible, this wrapper adds its own + * light-only tier badge line ("+N points · optimal/acceptable/poor") above + * the panel rather than modifying RationalePanel (which contains `dark:` + * classes that must not be touched or imitated here). + */ + +import { RationalePanel } from '../quiz/study/RationalePanel'; +import { Badge } from '../ui/Badge'; +import type { CaseStep, DecisionRecord, CaseOptionWeight } from '../../lib/cases/case-types'; + +interface CaseStepFeedbackProps { + step: CaseStep; + decision: DecisionRecord; + show: boolean; +} + +function tierBadgeVariant(weight: CaseOptionWeight): 'emerald' | 'topaz' | 'ruby' { + if (weight === 'optimal') return 'emerald'; + if (weight === 'acceptable') return 'topaz'; + return 'ruby'; +} + +function tierLabel(weight: CaseOptionWeight): string { + if (weight === 'optimal') return 'optimal'; + if (weight === 'acceptable') return 'acceptable'; + return 'poor'; +} + +export function CaseStepFeedback({ step, decision, show }: CaseStepFeedbackProps) { + if (!show) return null; + + const chosenOption = step.options.find((o) => o.id === decision.optionId); + const optimalOption = step.options.find((o) => o.weight === 'optimal'); + const userPickedIndex = step.options.findIndex((o) => o.id === decision.optionId); + const optionRationales = step.options.map((o) => ({ + text: o.text, + isCorrect: o.weight === 'optimal', + rationale: o.rationale, + })); + + return ( +
+
+ + +{decision.scoreAwarded} points · {tierLabel(decision.weight)} + +
+ = 0 ? userPickedIndex : undefined} + show={show} + /> +
+ ); +} diff --git a/src/components/cases/CaseStepPanel.tsx b/src/components/cases/CaseStepPanel.tsx new file mode 100644 index 0000000..2ef0de5 --- /dev/null +++ b/src/components/cases/CaseStepPanel.tsx @@ -0,0 +1,70 @@ +/** + * CaseStepPanel — a single case step: prompt, option list, tool/learn links, + * and (once submitted) tier feedback + a "Continue" control. + */ + +import { Card, CardContent } from '../ui/Card'; +import { Button } from '../ui/Button'; +import { CaseOptionList } from './CaseOptionList'; +import { CaseStepFeedback } from './CaseStepFeedback'; +import { ToolAndLearnLinks } from './ToolAndLearnLinks'; +import type { CaseStep, DecisionRecord } from '../../lib/cases/case-types'; + +interface CaseStepPanelProps { + step: CaseStep; + selectedOptionId: string | null; + isSubmitted: boolean; + decision: DecisionRecord | null; + isLastStep: boolean; + onSelectOption: (optionId: string) => void; + onSubmit: () => void; + onNext: () => void; +} + +export function CaseStepPanel({ + step, + selectedOptionId, + isSubmitted, + decision, + isLastStep, + onSelectOption, + onSubmit, + onNext, +}: CaseStepPanelProps) { + return ( + + +

{step.prompt}

+ +
+ +
+ + + + {isSubmitted && decision && ( +
+ +
+ )} + +
+ {!isSubmitted ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/cases/EvidenceNotebook.test.tsx b/src/components/cases/EvidenceNotebook.test.tsx new file mode 100644 index 0000000..54a2fdd --- /dev/null +++ b/src/components/cases/EvidenceNotebook.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EvidenceNotebook } from './EvidenceNotebook'; +import type { EvidenceItem } from '../../lib/cases/case-types'; + +const EVIDENCE: EvidenceItem[] = [ + { id: 'ev-1', kind: 'ri', label: 'Refractometer reading', value: '1.762-1.770', detail: 'Uniaxial negative.' }, + { id: 'ev-2', kind: 'sg', label: 'Specific gravity', value: '4.00', toolHref: '/tools/measurement' }, +]; + +describe('EvidenceNotebook', () => { + it('shows a placeholder when no evidence has been gathered yet', () => { + render(); + const placeholders = screen.getAllByText('No evidence gathered yet.'); + // Both the mobile and desktop render trees are present in the DOM + // simultaneously (visibility toggled purely via CSS), so both copies exist. + expect(placeholders.length).toBeGreaterThan(0); + }); + + it('renders evidence items in accumulation order (both mobile and desktop copies)', () => { + render(); + const readingLabels = screen.getAllByText('Refractometer reading'); + const sgLabels = screen.getAllByText('Specific gravity'); + expect(readingLabels.length).toBeGreaterThan(0); + expect(sgLabels.length).toBeGreaterThan(0); + }); + + it('shows the evidence count in the disclosure summary and rail heading', () => { + render(); + const headings = screen.getAllByText('Evidence (2)'); + expect(headings.length).toBeGreaterThan(0); + }); + + it('renders item detail text when present', () => { + render(); + expect(screen.getAllByText('Uniaxial negative.').length).toBeGreaterThan(0); + }); + + it('renders a tool link when toolHref is present', () => { + render(); + const links = screen.getAllByRole('link', { name: /open tool/i }); + expect(links.length).toBeGreaterThan(0); + expect(links[0]).toHaveAttribute('href', '/tools/measurement'); + }); + + it('the mobile disclosure starts collapsed and can be toggled open', async () => { + const user = userEvent.setup(); + render(); + const summaries = screen.getAllByText(/Evidence \(2\)/); + const summaryEl = summaries.find((el) => el.tagName === 'SUMMARY'); + expect(summaryEl).toBeDefined(); + const details = summaryEl!.closest('details'); + expect(details).not.toHaveAttribute('open'); + await user.click(summaryEl!); + expect(details).toHaveAttribute('open'); + }); +}); diff --git a/src/components/cases/EvidenceNotebook.tsx b/src/components/cases/EvidenceNotebook.tsx new file mode 100644 index 0000000..f711040 --- /dev/null +++ b/src/components/cases/EvidenceNotebook.tsx @@ -0,0 +1,74 @@ +/** + * EvidenceNotebook — the running list of evidence revealed so far in a case. + * + * Responsive layout without a media-query hook (SSR/hydration safe): + * - `lg:` and up: an always-open sticky right rail. + * - below `lg:`: a collapsible `
` disclosure ("Evidence (n)") shown + * above the step panel. + * + * Both variants render the same list (in accumulation/reveal order — the + * notebook never reorders evidence); only one is visible at a given + * viewport width via Tailwind responsive display classes. + */ + +import { Badge } from '../ui/Badge'; +import type { EvidenceItem } from '../../lib/cases/case-types'; + +interface EvidenceNotebookProps { + evidence: EvidenceItem[]; +} + +function EvidenceList({ evidence }: { evidence: EvidenceItem[] }) { + if (evidence.length === 0) { + return

No evidence gathered yet.

; + } + + return ( +
    + {evidence.map((item) => ( +
  • +
    + {item.label} + {item.kind} +
    +

    {item.value}

    + {item.detail &&

    {item.detail}

    } + {item.toolHref && ( + + Open tool → + + )} +
  • + ))} +
+ ); +} + +export function EvidenceNotebook({ evidence }: EvidenceNotebookProps) { + return ( + <> + {/* Below lg: collapsible disclosure above the step panel. */} +
+ + Evidence ({evidence.length}) + +
+ +
+
+ + {/* lg and up: always-open sticky right rail. */} + + + ); +} diff --git a/src/components/cases/ToolAndLearnLinks.tsx b/src/components/cases/ToolAndLearnLinks.tsx new file mode 100644 index 0000000..33981ab --- /dev/null +++ b/src/components/cases/ToolAndLearnLinks.tsx @@ -0,0 +1,30 @@ +/** + * ToolAndLearnLinks — renders a step's optional "Learn more" and "Try the + * tool" links (learnLinks -> /learn/[slug], toolLinks -> arbitrary href). + */ + +import { Link } from '../ui/Link'; + +interface ToolAndLearnLinksProps { + learnLinks?: string[]; + toolLinks?: Array<{ href: string; label: string }>; +} + +export function ToolAndLearnLinks({ learnLinks = [], toolLinks = [] }: ToolAndLearnLinksProps) { + if (learnLinks.length === 0 && toolLinks.length === 0) return null; + + return ( +
+ {learnLinks.map((slug) => ( + + Learn more: {slug.replace(/-/g, ' ')} + + ))} + {toolLinks.map((tool) => ( + + {tool.label} + + ))} +
+ ); +} diff --git a/src/components/quiz/Quiz.test.tsx b/src/components/quiz/Quiz.test.tsx index 435c273..55f61c2 100644 --- a/src/components/quiz/Quiz.test.tsx +++ b/src/components/quiz/Quiz.test.tsx @@ -242,6 +242,42 @@ describe('Quiz — unrenderable questions and auto-offer banner (A6)', () => { }); }); +describe('Quiz — onComplete (Study Challenge Tracks touchpoint)', () => { + it('fires onComplete exactly once with the final QuizResult when the quiz completes', async () => { + mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); + const user = userEvent.setup(); + const onComplete = vi.fn(); + const question = makeQuestion(); + render(); + + await waitFor(() => expect(mockStore.getSettings).toHaveBeenCalled()); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + await user.click(screen.getByRole('button', { name: 'Check Answer' })); + await user.click(screen.getByRole('button', { name: 'Finish Quiz' })); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalledTimes(1); + }); + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ score: 1, totalQuestions: 1 }), + ); + }); + + it('does not fire onComplete while the quiz is still in progress', async () => { + mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); + const user = userEvent.setup(); + const onComplete = vi.fn(); + const question = makeQuestion(); + render(); + + await waitFor(() => expect(mockStore.getSettings).toHaveBeenCalled()); + await user.click(screen.getByRole('button', { name: /Cubic/ })); + + expect(onComplete).not.toHaveBeenCalled(); + }); +}); + describe('Quiz — keyboard essentials (A8)', () => { it('pressing "1" selects the first option', async () => { mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); diff --git a/src/components/quiz/Quiz.tsx b/src/components/quiz/Quiz.tsx index b0475ce..d7ec8e4 100644 --- a/src/components/quiz/Quiz.tsx +++ b/src/components/quiz/Quiz.tsx @@ -27,7 +27,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import type { Question, QuizConfig } from '../../lib/quiz'; +import type { Question, QuizConfig, QuizResult } from '../../lib/quiz'; import { isRenderable } from '../../lib/quiz'; import { useQuiz } from '../../hooks/useQuiz'; import { useQuizKeyboard } from '../../hooks/useQuizKeyboard'; @@ -49,8 +49,8 @@ interface QuizProps { config: QuizConfig; /** Questions to display */ questions: Question[]; - /** Callback when quiz is completed */ - onComplete?: () => void; + /** Callback fired exactly once, with the final results, when the quiz completes */ + onComplete?: (results: QuizResult) => void; /** Callback to return to setup */ onBack?: () => void; } @@ -81,6 +81,22 @@ export function Quiz({ // Restart confirmation dialog (A6). const [showRestartConfirm, setShowRestartConfirm] = useState(false); + // Fire onComplete exactly once per completion. Guarded with a ref (rather + // than relying solely on effect deps) so re-renders while isComplete stays + // true never invoke it twice; reset when the quiz is no longer complete + // (e.g. after Restart) so a subsequent completion fires again. + const onCompleteFiredRef = useRef(false); + useEffect(() => { + if (isComplete && results) { + if (!onCompleteFiredRef.current) { + onCompleteFiredRef.current = true; + onComplete?.(results); + } + } else { + onCompleteFiredRef.current = false; + } + }, [isComplete, results, onComplete]); + // Study store singleton — stable reference. const store = useRef(getStudyStore()).current; diff --git a/src/components/quiz/QuizPage.tsx b/src/components/quiz/QuizPage.tsx index d76bee7..9c04e50 100644 --- a/src/components/quiz/QuizPage.tsx +++ b/src/components/quiz/QuizPage.tsx @@ -19,6 +19,8 @@ import { DEFAULT_STUDY_SETTINGS, } from '../../lib/quiz'; import { STORAGE_KEYS } from '../../hooks/useLocalStorage'; +import { Card, CardContent } from '../ui/Card'; +import { Button } from '../ui/Button'; interface QuizPageProps { /** Learn content entries for generating questions */ @@ -117,11 +119,29 @@ export function QuizPage({ learnEntries, curatedQuestions = [] }: QuizPageProps) /> ) ) : ( - + <> + + +

Challenge Tracks

+

+ Themed challenge tracks — structured study with staged mastery. +

+
+ +
+ + )}
diff --git a/src/components/quiz/challenges/ChallengeTrackScreen.test.tsx b/src/components/quiz/challenges/ChallengeTrackScreen.test.tsx new file mode 100644 index 0000000..8df823e --- /dev/null +++ b/src/components/quiz/challenges/ChallengeTrackScreen.test.tsx @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ChallengeProgress } from '../../../lib/quiz/challenge-store'; +import type { Question, QuizConfig, QuizResult } from '../../../lib/quiz'; + +const mockGetProgress = vi.fn<(trackId: string) => ChallengeProgress>(); +const mockRecordStageResult = vi.fn(); + +vi.mock('../../../lib/quiz/challenge-store', () => ({ + getChallengeStore: () => ({ + getProgress: mockGetProgress, + recordStageResult: mockRecordStageResult, + }), +})); + +// The real pulls in the full study-store machinery; for this +// component's own tests (stage gating / start / complete / back), Quiz is +// treated as an opaque child so we can assert on the props it was given and +// trigger onComplete/onBack directly. +let lastQuizProps: { questions: Question[]; config: QuizConfig; onComplete?: (r: QuizResult) => void; onBack?: () => void } | null = null; + +vi.mock('../Quiz', () => ({ + Quiz: (props: { questions: Question[]; config: QuizConfig; onComplete?: (r: QuizResult) => void; onBack?: () => void }) => { + lastQuizProps = props; + return ( +
+ Quiz for {props.questions.length} question(s) + + +
+ ); + }, +})); + +const { ChallengeTrackScreen } = await import('./ChallengeTrackScreen'); + +function makeQuestion(overrides: Partial = {}): Question { + return { + id: 'q1', + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: 'Which system has three equal axes at 90 degrees?', + options: ['Cubic', 'Trigonal', 'Monoclinic'], + correctAnswer: 'Cubic', + ...overrides, + }; +} + +const track = { + id: 'gem-chemistry', + title: 'Gem Chemistry', + description: 'Chemistry of gemstones.', +}; + +const stages = [ + { id: 'stage-1', title: 'Stage One', passThreshold: 0.7, questions: [makeQuestion({ id: 'q1' })] }, + { id: 'stage-2', title: 'Stage Two', passThreshold: 0.8, questions: [makeQuestion({ id: 'q2' })] }, +]; + +beforeEach(() => { + mockGetProgress.mockReset(); + mockRecordStageResult.mockReset(); + mockGetProgress.mockReturnValue({ trackId: 'gem-chemistry', stages: {} }); + lastQuizProps = null; +}); + +describe('ChallengeTrackScreen — stage gating', () => { + it('shows stage 1 unlocked and stage 2 locked when no progress is recorded', async () => { + render(); + + expect(await screen.findByText('Stage 1: Stage One')).toBeInTheDocument(); + expect(screen.getByText('Stage 2: Stage Two')).toBeInTheDocument(); + + const startButtons = screen.getAllByRole('button', { name: 'Start stage' }); + // Stage 1's start button enabled, stage 2's disabled (locked). + expect(startButtons[0]).not.toBeDisabled(); + expect(startButtons[1]).toBeDisabled(); + expect(screen.getByText(/Complete stage 1 to unlock this stage\./)).toBeInTheDocument(); + }); + + it('unlocks stage 2 once stage 1 is passed', async () => { + mockGetProgress.mockReturnValue({ + trackId: 'gem-chemistry', + stages: { + 'stage-1': { stageId: 'stage-1', attempts: 1, bestScore: 0.9, passed: true, lastAttemptAt: Date.now() }, + }, + }); + + render(); + + await waitFor(() => { + const startButtons = screen.getAllByRole('button', { name: 'Start stage' }); + expect(startButtons[0]).toBeInTheDocument(); + }); + + expect(await screen.findByText('Passed')).toBeInTheDocument(); + const retryOrStart = screen.getAllByRole('button', { name: /Retry stage|Start stage/ }); + expect(retryOrStart[1]).not.toBeDisabled(); + }); +}); + +describe('ChallengeTrackScreen — starting and completing a stage', () => { + it('passes the stage questions in fixed order to Quiz (no shuffleQuestions)', async () => { + const user = userEvent.setup(); + render(); + + await user.click((await screen.findAllByRole('button', { name: 'Start stage' }))[0]); + + expect(await screen.findByTestId('mock-quiz')).toBeInTheDocument(); + expect(lastQuizProps!.questions.map(q => q.id)).toEqual(['q1']); + expect(lastQuizProps!.config.shuffleQuestions).toBe(false); + expect(lastQuizProps!.config.questionCount).toBe(1); + }); + + it('records the result via recordStageResult and returns to the stage list on completion', async () => { + const user = userEvent.setup(); + render(); + + await user.click((await screen.findAllByRole('button', { name: 'Start stage' }))[0]); + await screen.findByTestId('mock-quiz'); + + await user.click(screen.getByRole('button', { name: 'Mock Complete' })); + + expect(mockRecordStageResult).toHaveBeenCalledWith( + 'gem-chemistry', + 'stage-1', + 1, + 1, + 0.7, + ['stage-1', 'stage-2'], + ); + + await waitFor(() => { + expect(screen.queryByTestId('mock-quiz')).not.toBeInTheDocument(); + }); + expect(await screen.findByText('Stage 1: Stage One')).toBeInTheDocument(); + }); + + it('returns to the stage list when Back is triggered without completing', async () => { + const user = userEvent.setup(); + render(); + + await user.click((await screen.findAllByRole('button', { name: 'Start stage' }))[0]); + await screen.findByTestId('mock-quiz'); + + await user.click(screen.getByRole('button', { name: 'Mock Back' })); + + expect(mockRecordStageResult).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByTestId('mock-quiz')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/quiz/challenges/ChallengeTrackScreen.tsx b/src/components/quiz/challenges/ChallengeTrackScreen.tsx new file mode 100644 index 0000000..faa9cf0 --- /dev/null +++ b/src/components/quiz/challenges/ChallengeTrackScreen.tsx @@ -0,0 +1,190 @@ +/** + * ChallengeTrackScreen — per-track staged-mastery screen for Study Challenge + * Tracks (Phase 1). + * + * Used by src/pages/study/challenges/[track].astro, which resolves the full + * ordered Question[] for every stage at build time and passes plain data in + * (no astro:content on the client). + * + * Two views: + * - "stages": a list of the track's stages with locked/unlocked/passed + * state. Stage N+1 is locked until stage N is passed; a locked stage + * shows a short rationale instead of a Start button. + * - "quiz": an active session for one stage. On completion, the + * result is recorded via the ChallengeStore and the view returns to the + * stage list (with the newly-updated lock/passed state). + * + * Progress is read from the localStorage-backed ChallengeStore on mount via + * useEffect — SSR-safe, since no progress/lock state is rendered until the + * effect runs client-side. + */ + +import { useEffect, useState } from 'react'; +import { Quiz } from '../Quiz'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../../ui/Card'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { Link } from '../../ui/Link'; +import { getChallengeStore } from '../../../lib/quiz/challenge-store'; +import type { ChallengeProgress } from '../../../lib/quiz/challenge-store'; +import { STORAGE_KEYS } from '../../../hooks/useLocalStorage'; +import type { Category, Question, QuizResult } from '../../../lib/quiz'; + +/** One fully-resolved stage, as built at page-generation time by [track].astro. */ +export interface ChallengeTrackScreenStage { + id: string; + title: string; + description?: string; + passThreshold: number; + questions: Question[]; +} + +export interface ChallengeTrackScreenTrack { + id: string; + title: string; + description: string; + icon?: string; + estimatedMinutes?: number; + categoryAffinity?: Category; +} + +interface ChallengeTrackScreenProps { + track: ChallengeTrackScreenTrack; + stages: ChallengeTrackScreenStage[]; +} + +function isStagePassed(progress: ChallengeProgress | null, stageId: string): boolean { + return progress?.stages[stageId]?.passed ?? false; +} + +export function ChallengeTrackScreen({ track, stages }: ChallengeTrackScreenProps) { + const [progress, setProgress] = useState(null); + const [activeStageIndex, setActiveStageIndex] = useState(null); + + useEffect(() => { + try { + setProgress(getChallengeStore().getProgress(track.id)); + } catch (err) { + console.warn('[ChallengeTrackScreen] failed to load progress:', err); + } + }, [track.id]); + + const handleStartStage = (index: number) => { + if (typeof window !== 'undefined') { + // Same pattern as QuizPage.handleStart: an explicit Start is always a + // fresh session, so any leftover in-progress quiz state must be + // discarded before mounts and (potentially) restores it. + window.localStorage.removeItem(STORAGE_KEYS.QUIZ_STATE); + } + setActiveStageIndex(index); + }; + + const backToStages = () => setActiveStageIndex(null); + + const handleStageComplete = (stageIndex: number, results: QuizResult) => { + const stage = stages[stageIndex]; + const allStageIds = stages.map(s => s.id); + try { + const updated = getChallengeStore().recordStageResult( + track.id, + stage.id, + results.score, + results.totalQuestions, + stage.passThreshold, + allStageIds, + ); + setProgress(updated); + } catch (err) { + console.warn('[ChallengeTrackScreen] failed to record stage result:', err); + } + setActiveStageIndex(null); + }; + + if (activeStageIndex !== null) { + const stage = stages[activeStageIndex]; + const categories = Array.from(new Set(stage.questions.map(q => q.category))); + + return ( +
+ + 0 ? categories : ['fundamentals'], + questionCount: stage.questions.length, + shuffleQuestions: false, + shuffleOptions: true, + mode: 'practice', + }} + onComplete={(results) => handleStageComplete(activeStageIndex, results)} + onBack={backToStages} + /> +
+ ); + } + + return ( +
+
+ + ← All challenge tracks + +

{track.title}

+

{track.description}

+
+ +
+ {stages.map((stage, index) => { + const passed = isStagePassed(progress, stage.id); + const previousPassed = index === 0 || isStagePassed(progress, stages[index - 1].id); + const locked = !previousPassed; + const result = progress?.stages[stage.id]; + + return ( + + +
+ + Stage {index + 1}: {stage.title} + + {passed && Passed} + {!passed && locked && Locked} +
+ {stage.description && {stage.description}} +
+ +

+ {stage.questions.length} question{stage.questions.length === 1 ? '' : 's'} ·{' '} + {Math.round(stage.passThreshold * 100)}% to pass + {result && ( + <> + {' '} + · best score {Math.round(result.bestScore * 100)}% + {result.attempts > 1 ? ` (${result.attempts} attempts)` : ''} + + )} +

+ {locked && ( +

+ Complete stage {index} to unlock this stage. +

+ )} +
+ + + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/quiz/challenges/ChallengesHub.test.tsx b/src/components/quiz/challenges/ChallengesHub.test.tsx new file mode 100644 index 0000000..aba8dcc --- /dev/null +++ b/src/components/quiz/challenges/ChallengesHub.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ChallengeProgress } from '../../../lib/quiz/challenge-store'; + +const mockGetAllProgress = vi.fn<() => Record>(); + +vi.mock('../../../lib/quiz/challenge-store', () => ({ + getChallengeStore: () => ({ + getAllProgress: mockGetAllProgress, + }), +})); + +const { ChallengesHub } = await import('./ChallengesHub'); +import type { ChallengeTrackSummary } from './ChallengesHub'; + +function makeSummary(overrides: Partial = {}): ChallengeTrackSummary { + return { + id: 'gem-chemistry', + title: 'Gem Chemistry', + description: 'A track about chemistry.', + stageCount: 3, + totalQuestions: 15, + ...overrides, + }; +} + +beforeEach(() => { + mockGetAllProgress.mockReset(); + mockGetAllProgress.mockReturnValue({}); +}); + +describe('ChallengesHub — empty state', () => { + it('shows the "Tracks are coming" empty state when there are zero tracks', () => { + render(); + expect(screen.getByText('Tracks are coming')).toBeInTheDocument(); + }); + + it('does not render any track cards in the empty state', () => { + render(); + expect(screen.queryByText('Gem Chemistry')).not.toBeInTheDocument(); + }); +}); + +describe('ChallengesHub — track cards', () => { + it('renders a card per track with title, description, and counts', () => { + render(); + expect(screen.getByText('Gem Chemistry')).toBeInTheDocument(); + expect(screen.getByText('A track about chemistry.')).toBeInTheDocument(); + expect(screen.getByText('3 stages')).toBeInTheDocument(); + expect(screen.getByText('15 questions')).toBeInTheDocument(); + }); + + it('shows "Start track" and no Completed badge when there is no recorded progress', async () => { + render(); + expect(await screen.findByRole('button', { name: 'Start track' })).toBeInTheDocument(); + expect(screen.queryByText('Completed')).not.toBeInTheDocument(); + }); + + it('shows "Continue track" and stage-passed count once progress is loaded', async () => { + mockGetAllProgress.mockReturnValue({ + 'gem-chemistry': { + trackId: 'gem-chemistry', + stages: { + 'stage-1': { stageId: 'stage-1', attempts: 1, bestScore: 0.9, passed: true, lastAttemptAt: Date.now() }, + }, + }, + }); + + render(); + + expect(await screen.findByRole('button', { name: 'Continue track' })).toBeInTheDocument(); + expect(await screen.findByText('1 / 3 stages passed')).toBeInTheDocument(); + }); + + it('shows a Completed badge once completedAt is set', async () => { + mockGetAllProgress.mockReturnValue({ + 'gem-chemistry': { + trackId: 'gem-chemistry', + stages: {}, + completedAt: Date.now(), + }, + }); + + render(); + expect(await screen.findByText('Completed')).toBeInTheDocument(); + }); + + it('does not throw when getChallengeStore/getAllProgress throws', () => { + mockGetAllProgress.mockImplementation(() => { + throw new Error('boom'); + }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/src/components/quiz/challenges/ChallengesHub.tsx b/src/components/quiz/challenges/ChallengesHub.tsx new file mode 100644 index 0000000..b7dca03 --- /dev/null +++ b/src/components/quiz/challenges/ChallengesHub.tsx @@ -0,0 +1,151 @@ +/** + * ChallengesHub — card grid landing page for Study Challenge Tracks. + * + * Used by src/pages/study/challenges/index.astro. Progress (stages passed / + * total, "Completed" badge) is read from the localStorage-backed + * LocalChallengeStore on mount via useEffect — SSR-safe, since no progress + * is rendered until the effect runs client-side (avoids a hydration + * mismatch between server and client markup). + * + * Renders a "Tracks are coming" empty state when `trackSummaries` is empty + * (Phase 1 ships with zero real tracks — see src/content/challenges/_example). + */ + +import { useEffect, useState } from 'react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '../../ui/Card'; +import { IconBox } from '../../ui/IconBox'; +import { Badge } from '../../ui/Badge'; +import { Button } from '../../ui/Button'; +import { getChallengeStore } from '../../../lib/quiz/challenge-store'; +import type { ChallengeProgress } from '../../../lib/quiz/challenge-store'; +import type { Category } from '../../../lib/quiz'; +import { CATEGORY_LABELS } from '../../../lib/quiz'; + +/** Plain-data summary of one track, built at build time by the Astro page. */ +export interface ChallengeTrackSummary { + id: string; + title: string; + description: string; + icon?: string; + estimatedMinutes?: number; + categoryAffinity?: Category; + stageCount: number; + totalQuestions: number; +} + +interface ChallengesHubProps { + trackSummaries: ChallengeTrackSummary[]; +} + +export function ChallengesHub({ trackSummaries }: ChallengesHubProps) { + const [progressByTrack, setProgressByTrack] = useState>({}); + + useEffect(() => { + try { + setProgressByTrack(getChallengeStore().getAllProgress()); + } catch (err) { + console.warn('[ChallengesHub] failed to load progress:', err); + } + }, []); + + if (trackSummaries.length === 0) { + return ; + } + + return ( +
+ {trackSummaries.map(track => { + const progress = progressByTrack[track.id]; + const stagesPassed = progress + ? Object.values(progress.stages).filter(s => s.passed).length + : 0; + const isComplete = Boolean(progress?.completedAt); + + return ( + + +
+ + {isComplete && Completed} +
+ {track.title} + {track.description} +
+ + +
+ + {track.stageCount} stage{track.stageCount === 1 ? '' : 's'} + + + + {track.totalQuestions} question{track.totalQuestions === 1 ? '' : 's'} + + {track.estimatedMinutes !== undefined && ( + <> + + ~{track.estimatedMinutes} min + + )} +
+ + {track.categoryAffinity && ( + + {CATEGORY_LABELS[track.categoryAffinity]} + + )} + +

+ {stagesPassed} / {track.stageCount} stages passed +

+
+ + + + +
+ ); + })} +
+ ); +} + +function EmptyTracksState() { + return ( +
+

Tracks are coming

+

+ Themed challenge tracks are in development — structured, staged-mastery study + paths built around specific gemmology topics. Check back soon, or head to + practice and exam mode in the meantime. +

+
+ +
+
+ ); +} diff --git a/src/content/cases/_example/example-case.yaml b/src/content/cases/_example/example-case.yaml new file mode 100644 index 0000000..bde9999 --- /dev/null +++ b/src/content/cases/_example/example-case.yaml @@ -0,0 +1,259 @@ +# ────────────────────────────────────────────────────────────────────────────── +# EXAMPLE CASE: for curator reference and scaffolder template only. +# +# This file lives in _example/ so Astro's glob-loader ignores it (the loader +# pattern excludes paths starting with _). It is NOT loaded into the cases +# collection. Run `npx astro sync` to confirm no schema errors. +# +# To create a real case, run: +# npm run case:new -- --category= --slug= +# +# See docs/authoring-cases.md for the full curator guide. +# ────────────────────────────────────────────────────────────────────────────── + +# id: must match the filename slug, lowercase, digits and hyphens only, +# no leading hyphen. Regex: /^[a-z0-9][a-z0-9-]*$/ +id: example-case-001 + +# title: shown on the case card and the case run screen. +title: "The Dealer's Ruby" + +# difficulty: one of foundation | intermediate | diploma +difficulty: intermediate + +# estimatedMinutes: shown on the hub card; a rough time budget. +estimatedMinutes: 10 + +# backstory: sets the scene. One or two sentences, written as a real +# consulting-room / lab-bench situation, not a bare property list. +backstory: | + A dealer brings in a 2.10ct transparent red faceted stone, claiming it is + an untreated natural ruby from Mogok. You have full lab access. + +# specimenSummary: what the learner can see before any testing — shape, +# colour, transparency, approximate weight. No instrument readings here; +# those belong in each step's evidenceRevealed. +specimenSummary: | + A 2.10ct oval mixed-cut transparent red stone, eye-clean under 10x loupe + with faint silky reflections visible at certain angles. + +# groundTruth: the actual answer, used for internal bookkeeping/QA (not +# shown to the learner directly — the debrief communicates it in prose). +# speciesFamilyId must be a valid id in the mineral-database (see +# `mineral-db list` / `mineral-db info `). +groundTruth: + speciesFamilyId: corundum + variety: ruby + treatment: heated + originNote: "Consistent with Mogok-type corundum; origin is not fully determinable from this evidence alone." + +# ── Steps ───────────────────────────────────────────────────────────────────── +# Minimum 3 steps. Exactly one step must have type: final-identification. +# Every property value cited in prompts/rationales/evidence MUST match +# mineral_families data or SPECTROSCOPE_REFERENCE, with a citation in the +# `references` block below. See docs/authoring-cases.md's verification +# checklist before setting unvetted: false. + +steps: + # Step 1 — choose-next-test: the learner picks which instrument to reach + # for first. Weight/score convention: optimal=10, acceptable=5, poor=0 + # (validate-cases.mjs enforces this unless allowScoreOverride: true). + - id: choose-first-test + type: choose-next-test + prompt: "What's the most efficient first test on this stone?" + options: + - id: reach-refractometer + text: "Refractometer" + weight: optimal + score: 10 + rationale: | + RI is fast, non-destructive, and immediately separates corundum + from most red-stone look-alikes (spinel, garnet, glass). + revealsEvidenceIds: [ri-reading] + timeCost: 1 + - id: reach-spectroscope + text: "Spectroscope" + weight: acceptable + score: 5 + rationale: | + Useful for confirming chromium absorption later, but without an RI + or SG anchor first it doesn't narrow the field on its own. + timeCost: 2 + - id: reach-hardness-pick + text: "Hardness pick" + weight: poor + score: 0 + rationale: | + Destructive and rarely necessary on a faceted stone when + non-destructive optical tests are available. + timeCost: 3 + pointsMultiplier: 1 + evidenceRevealed: + - id: ri-reading + kind: ri + label: "Refractometer reading" + value: "1.762–1.770" + detail: "Uniaxial negative; birefringence 0.008." + toolHref: /tools/measurement + toolLinks: + - href: /tools/measurement + label: "Try the RI calculator" + + # Step 2 — reading-interpretation: the learner interprets the RI reading + # just revealed. candidatesAfter narrows the working candidate list. + - id: interpret-ri + type: reading-interpretation + prompt: "The RI reads 1.762–1.770, uniaxial negative. What does this rule in or out?" + options: + - id: corundum-only + text: "Consistent with corundum; rules out garnet and spinel (both isotropic)" + weight: optimal + score: 10 + rationale: | + Garnet and spinel are singly refractive (isotropic) and cannot + produce a uniaxial reading — this RI range and optic character + are diagnostic of corundum among common red stones. + candidatesAfter: + - familyId: corundum + name: Corundum + - id: still-three-way + text: "Still consistent with corundum, garnet, or spinel" + weight: poor + score: 0 + rationale: | + Garnet (~1.73–1.76) and spinel (~1.718) are both isotropic and + would not show a uniaxial reading at all — this option ignores + the optic character half of the evidence. + candidatesAfter: + - familyId: corundum + name: Corundum + - familyId: garnet + name: Garnet + - familyId: spinel + name: Spinel + pointsMultiplier: 1 + + # Step 3 — candidate-narrowing: SG confirms/narrows further. Note the + # candidatesAfter count here (1) must not exceed the previous narrowing + # step's count (also 1) — validate-cases.mjs checks this. + - id: narrow-by-sg + type: candidate-narrowing + prompt: "Hydrostatic weighing gives SG 4.00. How does this affect the candidate list?" + options: + - id: confirms-corundum + text: "Confirms corundum (SG 3.99–4.00); no other candidate remains" + weight: optimal + score: 10 + rationale: "SG 4.00 sits exactly at corundum's published range and is not reached by any remaining candidate." + candidatesAfter: + - familyId: corundum + name: Corundum + - id: irrelevant + text: "SG doesn't add anything beyond the RI reading" + weight: poor + score: 0 + rationale: "SG is an independent, corroborating measurement — always worth taking even after a strong RI read." + candidatesAfter: + - familyId: corundum + name: Corundum + pointsMultiplier: 1 + evidenceRevealed: + - id: sg-reading + kind: sg + label: "Specific gravity" + value: "4.00" + toolHref: /tools/measurement + + # Step 4 — treatment-call: a higher-stakes decision, weighted 2x. + - id: assess-treatment + type: treatment-call + prompt: "Under 10x you see fine rutile silk with discoid stress halos around a mineral inclusion, and the stone reads red through the Chelsea filter. What's your treatment call?" + options: + - id: heated + text: "Heat-treated" + weight: optimal + score: 10 + rationale: | + Discoid stress halos around included crystals form specifically + under heat-induced expansion; intact, undisturbed silk without + halos would instead argue against heating. + revealsEvidenceIds: [inclusion-evidence, chelsea-evidence] + - id: unheated + text: "Unheated / natural colour" + weight: poor + score: 0 + rationale: | + Intact silk alone can be consistent with no heat, but discoid + halos specifically are a heat signature and were explicitly + described here. + pointsMultiplier: 2 + evidenceRevealed: + - id: inclusion-evidence + kind: inclusion + label: "Microscopic inclusions" + value: "Fine rutile silk with discoid stress halos" + - id: chelsea-evidence + kind: chelsea-filter + label: "Chelsea filter reaction" + value: "Red" + learnLinks: [treatments/heat-treatment] + toolLinks: + - href: /tools/advanced + label: "Try the Treatment Wizard" + + # Step 5 — final-identification: exactly one such step is required per case. + - id: final-call + type: final-identification + prompt: "What is your final identification and disclosure?" + options: + - id: natural-ruby-heated + text: "Natural ruby (corundum), heat-treated" + weight: optimal + score: 10 + rationale: "Matches every property gathered: RI, SG, optic character, and the treatment evidence all agree." + - id: natural-ruby-undisclosed + text: "Natural ruby (corundum); treatment not stated" + weight: acceptable + score: 5 + rationale: "Correct species, but omitting the treatment call after clear heat evidence falls short of full disclosure." + - id: red-spinel + text: "Red spinel" + weight: poor + score: 0 + rationale: "Spinel is isotropic and would not give a uniaxial RI reading; already excluded in step 2." + pointsMultiplier: 2 + +# ── Debrief ─────────────────────────────────────────────────────────────────── + +debrief: + summary: | + The stone is a heat-treated natural ruby (corundum). The RI, SG, optic + character, and inclusion evidence are all internally consistent, and the + discoid stress halos around silk inclusions are a specific heat + signature that must be disclosed. + expertPath: + - "Check RI first — fast, non-destructive, and immediately separates corundum from isotropic look-alikes." + - "Confirm with SG — an independent measurement that should agree with the RI-based candidate." + - "Examine inclusions under magnification for treatment evidence before concluding." + - "State both species and treatment in the final identification — disclosure is part of the answer, not an afterthought." + furtherReading: + - "Hughes, R.W. Ruby & Sapphire: A Gemologist's Guide, ch. 4 (heat treatment) and ch. 6 (inclusions)." + +# conceptTags: optional, used for future cross-linking / search. +conceptTags: + - corundum + - refractive-index + - specific-gravity + - treatments + - inclusions + +# references: full citations for any property value or diagnostic rule +# asserted in this case. +references: + - id: hughes-ruby-sapphire + citation: "Hughes, R.W. (1997). Ruby & Sapphire: A Gemologist's Guide. RWH Publishing." + +# unvetted: true = auto-generated or not yet FGA-reviewed; false = expert-vetted. +# New stubs should default to true. Only set false after review AND after +# verifying every property value against mineral_families / SPECTROSCOPE_REFERENCE. +unvetted: true diff --git a/src/content/cases/identification/dealers-ruby.yaml b/src/content/cases/identification/dealers-ruby.yaml new file mode 100644 index 0000000..1ef5a26 --- /dev/null +++ b/src/content/cases/identification/dealers-ruby.yaml @@ -0,0 +1,232 @@ +id: dealers-ruby +title: "The Dealer's Ruby" +difficulty: intermediate +estimatedMinutes: 10 + +backstory: | + A dealer brings a loose 2.10ct transparent red stone to your bench, claiming it is an + untreated natural ruby from Mogok and asking for a written identification before he lists it. + You have full access to the standard gemmological lab. + +specimenSummary: | + A 2.10ct oval mixed-cut transparent red stone, eye-clean at 10x with faint silky reflections + visible from certain angles when tilted under a fibre-optic light. + +groundTruth: + speciesFamilyId: corundum + variety: ruby + treatment: heated + originNote: > + Property evidence confirms corundum with heat-treatment indicators; the evidence gathered in + this case does not by itself establish a specific geographic origin. + +steps: + - id: choose-first-test + type: choose-next-test + prompt: "What's the most efficient first test to run on this stone?" + options: + - id: reach-refractometer + text: "Refractometer" + weight: optimal + score: 10 + rationale: > + RI is fast, non-destructive, and reads directly from the polished facet. On a red + stone it immediately separates corundum from the two most common isotropic + look-alikes, garnet and spinel, because only corundum gives a birefringent (doubly + refractive) reading. + revealsEvidenceIds: [ri-reading] + timeCost: 1 + - id: reach-spectroscope + text: "Spectroscope" + weight: acceptable + score: 5 + rationale: > + The spectroscope will eventually confirm chromium absorption, but without an RI + reading first there is no anchor to narrow the candidate field — it is corroborating + evidence, not a starting point. + timeCost: 2 + - id: reach-hardness-pick + text: "Hardness pick" + weight: poor + score: 0 + rationale: > + A hardness test risks damaging a faceted, saleable stone and is rarely justified when + non-destructive optical tests (RI, SG, spectroscope) are available and more diagnostic. + timeCost: 3 + pointsMultiplier: 1 + evidenceRevealed: + - id: ri-reading + kind: ri + label: "Refractometer reading" + value: "1.762–1.770" + detail: "Uniaxial negative; birefringence 0.008." + toolHref: /tools/measurement + toolLinks: + - href: /tools/measurement + label: "Try the RI calculator" + + - id: interpret-ri + type: reading-interpretation + prompt: > + The refractometer reads 1.762–1.770, uniaxial negative. What does this reading rule in or + out among the common red-stone candidates? + options: + - id: corundum-only + text: "Consistent with corundum only; rules out garnet and spinel" + weight: optimal + score: 10 + rationale: > + Garnet and spinel are both singly refractive (isotropic) and cannot produce a uniaxial + reading at all — this RI range together with the uniaxial negative optic character is + diagnostic of corundum among the common red-stone confusion set. + candidatesAfter: + - familyId: corundum + name: Corundum + - id: still-three-way + text: "Still consistent with corundum, garnet, or spinel" + weight: poor + score: 0 + rationale: > + Garnet (~1.73–1.76) and spinel (~1.718) are isotropic and would show a single + stationary shadow edge on the refractometer, not a uniaxial split — this option + ignores half of the evidence just read. + candidatesAfter: + - familyId: corundum + name: Corundum + - familyId: garnet + name: Garnet + - familyId: spinel + name: Spinel + pointsMultiplier: 1 + + - id: narrow-by-sg + type: candidate-narrowing + prompt: "Hydrostatic weighing gives SG 4.00. How does this affect the candidate list?" + options: + - id: confirms-corundum + text: "Confirms corundum; no other candidate remains" + weight: optimal + score: 10 + rationale: > + SG 4.00 sits at the top of corundum's published range (3.99–4.00) and is not reached by + garnet or spinel, both already excluded by optic character in the previous step — this + measurement independently corroborates the RI-based conclusion. + candidatesAfter: + - familyId: corundum + name: Corundum + - id: sg-irrelevant + text: "SG doesn't add anything beyond the RI reading" + weight: acceptable + score: 5 + rationale: > + The RI reading was already sufficient to reach corundum, so this option isn't wrong + about the conclusion — but treating SG as pointless undervalues it as an independent, + corroborating measurement that is always worth taking to protect against a + misread or contaminated refractometer contact. + candidatesAfter: + - familyId: corundum + name: Corundum + pointsMultiplier: 1 + evidenceRevealed: + - id: sg-reading + kind: sg + label: "Specific gravity" + value: "4.00" + detail: "Hydrostatic weighing; corundum reference range 3.99–4.00." + toolHref: /tools/measurement + + - id: assess-treatment + type: treatment-call + prompt: > + Under 10x magnification you see fine rutile silk disrupted into discoid stress halos + around a small included crystal, and the stone reads red through the Chelsea colour + filter. What is your treatment call? + options: + - id: heated + text: "Heat-treated" + weight: optimal + score: 10 + rationale: > + Discoid ("snowball") stress halos around an included crystal form specifically when + heat-induced expansion disrupts the surrounding silk — this is a classic, widely + documented heat-treatment indicator in corundum, distinct from sharp, undamaged silk. + revealsEvidenceIds: [inclusion-evidence, chelsea-evidence] + - id: unheated + text: "Unheated / natural colour, no treatment" + weight: poor + score: 0 + rationale: > + Sharp, undisturbed rutile silk would argue against strong heating, but that is not + what was observed here — discoid halos around included crystals are themselves the + heat signature, not a neutral or ambiguous feature. + pointsMultiplier: 2 + evidenceRevealed: + - id: inclusion-evidence + kind: inclusion + label: "Microscopic inclusions" + value: "Fine rutile silk disrupted into discoid stress halos around an included crystal" + - id: chelsea-evidence + kind: chelsea-filter + label: "Chelsea filter reaction" + value: "Red" + learnLinks: [identification/treatments] + toolLinks: + - href: /tools/advanced + label: "Try the Treatment Wizard" + + - id: final-call + type: final-identification + prompt: "What is your final identification and disclosure to the dealer?" + options: + - id: natural-ruby-heated + text: "Natural ruby (corundum), heat-treated" + weight: optimal + score: 10 + rationale: > + Every property gathered agrees: RI 1.762–1.770 uniaxial negative, SG 4.00, and the + discoid stress-halo inclusions with a red Chelsea reaction are all textbook corundum + with heat-treatment evidence — species and treatment must both be disclosed. + - id: natural-ruby-undisclosed + text: "Natural ruby (corundum); treatment not stated" + weight: acceptable + score: 5 + rationale: > + The species call is correct and the RI/SG evidence supports it fully, but omitting the + treatment determination after clear discoid-halo evidence falls short of the disclosure + standard the dealer is asking for. + - id: red-spinel + text: "Red spinel, natural colour" + weight: poor + score: 0 + rationale: > + Spinel is isotropic and cannot give the uniaxial refractometer reading obtained in + step 2 — this candidate was already excluded well before this final step. + pointsMultiplier: 2 + +debrief: + summary: > + The stone is a heat-treated natural ruby (corundum). The refractive index, specific gravity, + and inclusion evidence are all mutually consistent, and the discoid stress halos around + silk-adjacent inclusions are a well-established heat-treatment signature that must be + disclosed alongside the species identification. + expertPath: + - "Reach for the refractometer first — it is fast, non-destructive, and its optic character alone eliminates the isotropic look-alikes (garnet, spinel)." + - "Confirm with specific gravity — an independent measurement that should agree with the RI-based candidate before you commit to it." + - "Examine the inclusions under magnification specifically for treatment evidence — disc-shaped stress halos around included crystals are diagnostic of heating." + - "State both species and treatment in the final identification — disclosure is part of a correct answer, not an optional addendum." + furtherReading: + - "Hughes, R.W. Ruby & Sapphire: A Gemologist's Guide, ch. 4 (heat treatment) and ch. 6 (inclusions)." + +conceptTags: + - corundum + - refractive-index + - specific-gravity + - treatments + - inclusions + - chelsea-filter + +references: + - id: hughes-ruby-sapphire + citation: "Hughes, R.W. (1997). Ruby & Sapphire: A Gemologist's Guide. RWH Publishing." + +unvetted: true diff --git a/src/content/cases/identification/emerald-fissure-filling.yaml b/src/content/cases/identification/emerald-fissure-filling.yaml new file mode 100644 index 0000000..8f1aad6 --- /dev/null +++ b/src/content/cases/identification/emerald-fissure-filling.yaml @@ -0,0 +1,336 @@ +id: emerald-fissure-filling +title: "The Filled Emerald" +difficulty: intermediate +estimatedMinutes: 12 + +backstory: | + A regular client returns with a green stone she inherited; a previous lab report on file + already confirms it as natural emerald (beryl), so species is not in question today. She + wants to know exactly what treatment it has had, whether it's safe to send to an ultrasonic + cleaner before an upcoming event, and what you'll write on the disclosure line if she decides + to sell it. + +specimenSummary: | + A medium-toned green, transparent, oval mixed-cut stone of moderate size, previously + identified as natural emerald. Under the naked eye it shows a visible internal "garden" + (jardin) typical of the species, with several surface-reaching fissures across the pavilion. + No instrument readings taken yet in today's session. + +groundTruth: + speciesFamilyId: emerald + treatment: "resin-filled, moderate (LMHC F3)" + originNote: > + Species (natural emerald) was established in a prior lab report and is not re-derived in + this case; the evidence gathered here is scoped entirely to the treatment call and its + disclosure and care consequences. + +steps: + - id: choose-first-test + type: choose-next-test + prompt: > + The client's real question today is about treatment, not species. What's the most + efficient first step? + options: + - id: loupe-darkfield + text: "10x loupe and darkfield microscope examination of the fissures" + weight: optimal + score: 10 + rationale: > + Magnified examination of the surface-reaching fissures is the standard starting point + for an emerald treatment call: it lets you assess fissure lustre, look for trapped + bubbles or flow structures, and set up the darkfield flash-effect test, which is the + primary in-lab indicator for fracture filling. + revealsEvidenceIds: [fissure-evidence] + timeCost: 1 + - id: gentle-warming + text: "Gently warm the stone and check for oil sweating at the fissures" + weight: acceptable + score: 5 + rationale: > + A warmth "sweat test" can reveal liquid oil sweating from fissures, but it only + detects traditional oils, not the polymer resins (e.g. Opticon) that are at least as + common in commercial emerald today — starting with magnification is more broadly + diagnostic before reaching for a heat-based test on a stone with known fissures. + timeCost: 2 + - id: refractometer-recheck + text: "Re-run the refractometer to reconfirm species" + weight: poor + score: 0 + rationale: > + Species was already established on file; re-testing RI answers a question the client + didn't ask and doesn't move the treatment determination forward at all. + timeCost: 1 + pointsMultiplier: 1 + evidenceRevealed: + - id: fissure-evidence + kind: inclusion + label: "Fissure examination" + value: "Surface-reaching fissures forming a jardin pattern, concentrated in the pavilion" + detail: > + A subtle lustre difference is visible at several fracture mouths (slightly resinous + rather than vitreous), and small spherical bubble-like inclusions sit within the + largest fissures. + toolHref: /tools/lab + toolLinks: + - href: /tools/lab + label: "Open the Lab tools" + + - id: fissure-interpretation + type: reading-interpretation + prompt: > + The fissure mouths show a lustre difference from the surrounding facets, and small + spherical bubbles sit within the largest fissures — no natural fluid inclusions of that + rounded, isolated form were noted. What does this rule in or out? + options: + - id: favours-filled + text: "Favours a filled fissure network over untreated natural fissures" + weight: optimal + score: 10 + rationale: > + A lustre mismatch at the fracture mouth, together with spherical bubbles trapped + within a fissure, is specifically diagnostic of a filler (polymer or oil) rather than + an open, unfilled natural fissure — round trapped bubbles of this kind do not occur + in natural fluid inclusions. + candidatesAfter: + - familyId: emerald + name: "Emerald — filled (oil or resin)" + - id: normal-jardin + text: "This is just a normal jardin; no filling can be inferred yet" + weight: poor + score: 0 + rationale: > + Understates the evidence. A jardin alone would not include a lustre mismatch at the + fracture surface or spherical bubbles sitting inside a fissure — both features point + specifically to a filler, not to untreated natural inclusions. + candidatesAfter: + - familyId: emerald + name: "Emerald — untreated" + - familyId: emerald + name: "Emerald — filled (oil or resin)" + pointsMultiplier: 1 + + - id: flash-effect-interpretation + type: reading-interpretation + prompt: > + Tilting the stone under the darkfield microscope at 40x produces an orange-to-yellowish + orange iridescent flash where light crosses the fissure surfaces. How do you read this? + options: + - id: confirms-filler + text: "Confirms a filler is present at the fissures; oil vs resin still needs a further check" + weight: optimal + score: 10 + rationale: > + The orange/yellowish-orange flash effect at the filler-crystal interface is the + textbook, primary in-lab indicator of fracture filling in emerald. It confirms a + filler is present, but the flash effect itself doesn't reliably distinguish cedar oil + from a polymer resin — that distinction needs a further test such as UV fluorescence. + candidatesAfter: + - familyId: emerald + name: "Emerald — filled (oil or resin)" + - id: natural-color-play + text: "This is just natural colour play in the jardin, unrelated to any filler" + weight: poor + score: 0 + rationale: > + Misreads a well-established treatment indicator. The orange/yellowish-orange flash + seen specifically when tilting the stone at the fissure surface — not a general + bodycolour effect — is the classic flash-effect signature of a fracture filler. + candidatesAfter: + - familyId: emerald + name: "Emerald — untreated" + - familyId: emerald + name: "Emerald — filled (oil or resin)" + pointsMultiplier: 1 + evidenceRevealed: + - id: flash-evidence + kind: other + label: "Darkfield flash-effect observation" + value: "Orange-to-yellowish-orange iridescent flash at fissure surfaces when tilted at 40x" + + - id: uv-follow-up + type: reading-interpretation + prompt: > + Under long-wave UV, the stone's overall body fluorescence is inert, but the fissure + network itself shows a distinct chalky blue-to-yellowish-green glow. How does this narrow + the filler type? + options: + - id: favours-resin + text: "Favours a synthetic resin/polymer filler (e.g. Opticon-type) over cedar oil" + weight: optimal + score: 10 + rationale: > + Opticon and similar resins typically show a chalky-blue or yellowish-green glow under + UV, while cedar oil shows negligible fluorescence. The inert body colour is also + consistent with this specimen (many non-Colombian emeralds fluoresce weakly or not at + all), so the glow is coming specifically from the filler in the fissures, pointing to + resin rather than oil. + candidatesAfter: + - familyId: emerald + name: "Emerald — resin-filled" + - id: ignore-uv-generic-oil + text: "Call it generic oil filling; UV fluorescence isn't worth factoring in" + weight: poor + score: 0 + rationale: > + Ignores directly relevant evidence: cedar oil is specifically known for negligible UV + fluorescence, so a distinct chalky-blue/yellowish-green glow at the fissures is + inconsistent with a plain-oil call and should shift the working hypothesis toward a + resin filler. + candidatesAfter: + - familyId: emerald + name: "Emerald — oil-filled" + pointsMultiplier: 1 + evidenceRevealed: + - id: uv-evidence + kind: uv-fluorescence + label: "Long-wave UV fluorescence" + value: "Body inert; fissure network shows chalky blue-to-yellowish-green glow" + detail: "Cedar oil typically shows negligible fluorescence; Opticon-type resins typically show chalky-blue or yellowish-green fluorescence." + + - id: treatment-call + type: treatment-call + prompt: > + Tilting the stone across several angles, the filler's reflected-light glint is visible + across roughly a third of the pavilion facets — concentrated, not confined to a single + fissure, but not pervasive either. What is your treatment call? + options: + - id: resin-moderate + text: "Resin filling (Opticon-type polymer), moderate extent — LMHC F3" + weight: optimal + score: 10 + rationale: > + The flash effect confirms a filler; the chalky-blue/yellowish-green UV fluorescence + points specifically to a synthetic resin rather than cedar oil; and filler visible + across roughly a third of the pavilion — clearly present but not pervasive — matches + the LMHC scale's "moderate" (F3) band, where the filling is clearly present and + requires disclosure. + revealsEvidenceIds: [extent-evidence] + - id: resin-unspecified-extent + text: "Resin filling present, but hold off calling a level until FTIR confirms it" + weight: acceptable + score: 5 + rationale: > + Correctly identifies resin over oil from the UV evidence, and appropriately notes + FTIR would give a fully instrumented confirmation — but the visual extent evidence + already gathered (filler visible across roughly a third of the pavilion) is enough to + place this at a "moderate" level without waiting on further testing. + - id: cedar-oil-minor + text: "Cedar oil, minor level" + weight: poor + score: 0 + rationale: > + Contradicted by the UV evidence just gathered: cedar oil produces negligible + fluorescence, but a distinct chalky-blue/yellowish-green glow was observed at the + fissures — that glow is specifically associated with resin fillers, not oil. + - id: no-filling + text: "No filling; these are untreated natural fissures" + weight: poor + score: 0 + rationale: > + Contradicted by the fissure lustre mismatch, the trapped spherical bubbles, the + orange flash effect, and the fissure-specific UV fluorescence — all four + observations independently point to a filler being present. + pointsMultiplier: 2 + evidenceRevealed: + - id: extent-evidence + kind: other + label: "Filler extent assessment" + value: "Reflected-light glint visible across roughly a third of pavilion facets when tilted" + detail: "Concentrated but not pervasive — consistent with the LMHC 'moderate' (F3) fracture-fill band." + learnLinks: [identification/treatments] + toolLinks: + - href: /tools/advanced + label: "Try the Treatment Wizard" + + - id: final-call + type: final-identification + prompt: > + What is your final identification and disclosure statement to the client, including her + question about ultrasonic cleaning? + options: + - id: natural-emerald-resin-moderate + text: > + Natural emerald, moderate (F3) resin filling — disclose the filling on any sale + documentation, and advise against ultrasonic or steam cleaning and against + acetone/alcohol-based cleaners + weight: optimal + score: 10 + rationale: > + Names both species and treatment level, which is what full disclosure requires. + Durability studies of filled emeralds (Johnson, 2007) found liquid- and resin-filled + stones alike are vulnerable to ultrasonic and steam cleaning and to solvents such as + acetone and alcohol, so the care advice follows directly from the treatment call, not + as a separate afterthought. + - id: natural-emerald-treated-vague + text: "Natural emerald, some treatment present — advise general caution with cleaning" + weight: acceptable + score: 5 + rationale: > + Gets the species right and is appropriately cautious about cleaning, which is the + single most important safety message for the client — but falls short of full + disclosure practice by not naming the filler type or extent that the case evidence + actually supports. + - id: resin-ultrasonic-fine + text: "Natural emerald, resin filling — ultrasonic cleaning is fine since resin is more durable than oil" + weight: poor + score: 0 + rationale: > + Actively dangerous advice. Filled emeralds — resin-filled included — are consistently + documented as vulnerable to ultrasonic and steam cleaning; recommending ultrasonic + cleaning here risks damaging the stone and directly contradicts the durability + literature (Johnson, 2007). + - id: untreated-ultrasonic-fine + text: "Natural emerald, untreated — ultrasonic cleaning is fine" + weight: poor + score: 0 + rationale: > + Contradicted by every piece of evidence gathered in this case — the flash effect, the + fissure-specific UV fluorescence, and the reflected-light filler extent all confirm a + resin filling is present; calling the stone untreated ignores the whole examination. + pointsMultiplier: 2 + +debrief: + summary: > + The stone is a natural emerald with a moderate (LMHC F3) resin filling in its surface-reaching + fissures. The fissure lustre mismatch, trapped bubbles, orange flash effect, and + fissure-specific chalky-blue/yellowish-green UV fluorescence are all mutually consistent with + an Opticon-type polymer filler rather than cedar oil or an untreated stone. Both the species + and the treatment level must be disclosed, and the client should be advised against + ultrasonic, steam, and solvent-based cleaning. + expertPath: + - "Start with a loupe and darkfield exam of the fissures — this is the standard first step for an emerald treatment call, not a re-check of species." + - "Use the darkfield flash effect as the primary indicator that a filler is present at all, before trying to identify which filler." + - "Follow up with UV fluorescence to separate cedar oil (negligible fluorescence) from resin fillers (chalky-blue/yellowish-green glow)." + - "Assess the extent of the filler across the pavilion to place the case on the LMHC F1–F5 disclosure scale, rather than only calling 'filled' or 'not filled.'" + - "Let the treatment call drive the care advice directly — filled emeralds should never go through ultrasonic or steam cleaning, and disclosure must name both species and treatment level." + furtherReading: + - "Kammerling, R.C., Koivula, J.I., Kane, R.E., Maddison, P., Shigley, J.E., Fritsch, E. (1991). Fracture Filling of Emeralds: Opticon and Traditional \"Oils.\" Gems & Gemology 27(2), 70–85." + - "Johnson, M.L. (2007). Durability Testing of Filled Emeralds. Gems & Gemology 43(2), 120–137." + +conceptTags: + - emerald + - fracture-filling + - flash-effect + - uv-fluorescence + - treatments + - disclosure + - care-and-durability + +references: + - id: kammerling-1991-emerald + citation: > + Kammerling, R.C., Koivula, J.I., Kane, R.E., Maddison, P., Shigley, J.E., Fritsch, E. + (1991). Fracture Filling of Emeralds: Opticon and Traditional "Oils." Gems & Gemology + 27(2), 70–85. + url: "https://doi.org/10.5741/gems.27.2.70" + - id: johnson-2007-durability-filled-emeralds + citation: > + Johnson, M.L. (2007). Durability Testing of Filled Emeralds. Gems & Gemology 43(2), + 120–137. + url: "https://doi.org/10.5741/gems.43.2.120" + - id: mineral-db-emerald + citation: "Emerald reference data (RI 1.577–1.583, SG 2.72, uniaxial negative; treatments include oiling, resin filling, surface coating). gemmology-mineral-database, data/source/minerals/emerald.yaml." + - id: wizard-treatment-clues + citation: "Flash-effect, surface-bubbles, and sweat-test clue weights for emerald fracture treatments. src/lib/treatments/wizard.ts, CLUES table." + +unvetted: false diff --git a/src/content/cases/identification/garnet-or-glass.yaml b/src/content/cases/identification/garnet-or-glass.yaml new file mode 100644 index 0000000..e67a2c4 --- /dev/null +++ b/src/content/cases/identification/garnet-or-glass.yaml @@ -0,0 +1,266 @@ +id: garnet-or-glass +title: "Garnet or Glass?" +difficulty: foundation +estimatedMinutes: 8 + +backstory: | + A customer brings in an unlabelled 3ct red faceted stone found in an old jewellery box. No + papers, no provenance. She wants to know whether it's "a real garnet or just glass," and + whether it's worth adding to her home-contents insurance schedule. + +specimenSummary: | + A deep red, transparent, round brilliant-cut stone of about 3ct. Eye-clean at arm's length, + no visible colour zoning, no obvious surface wear. No instrument readings taken yet. + +groundTruth: + speciesFamilyId: glass-simulant + originNote: > + This case is deliberately built around a glass (paste) imitation rather than a genuine + almandine garnet: red glass is the classic look-alike for red garnet on a jeweller's bench, + and the twist here is the disclosure consequence — a customer who assumes "garnet" and asks + about insuring it as a gemstone needs to be told, plainly, that the stone is a simulant with + replacement-cost rather than gemstone value. + +steps: + - id: choose-first-test + type: choose-next-test + prompt: "What's the most efficient first test on this stone?" + options: + - id: reach-refractometer + text: "Refractometer" + weight: optimal + score: 10 + rationale: > + RI is fast and non-destructive. Even though almandine garnet and glass are both + singly refractive (isotropic) and will each give only a single stationary shadow + edge, the numeric position of that reading is still highly diagnostic: almandine + reads around 1.78–1.81, while typical glass reads much lower, around 1.50–1.70. A + single reading already does most of the separating work here. + revealsEvidenceIds: [ri-reading] + timeCost: 1 + - id: reach-polariscope + text: "Polariscope" + weight: acceptable + score: 5 + rationale: > + Useful for confirming the stone is singly refractive and for spotting anomalous + double refraction (ADR) from internal strain — but ADR can appear in both true + crystalline isotropic gems (some garnets) and in cast or moulded glass, so on its + own it does not separate this specific pair the way a refractometer reading does. + Better used as corroborating evidence after RI. + timeCost: 2 + - id: reach-hardness-pick + text: "Hardness pick" + weight: poor + score: 0 + rationale: > + A scratch test risks damaging a faceted, potentially saleable stone, and is rarely + justified when RI, inclusions, and SG are all available as non-destructive tests. + timeCost: 3 + pointsMultiplier: 1 + evidenceRevealed: + - id: ri-reading + kind: ri + label: "Refractometer reading" + value: "1.54" + detail: "Single stationary shadow edge (isotropic); no doubling observed." + toolHref: /tools/measurement + toolLinks: + - href: /tools/measurement + label: "Try the RI calculator" + + - id: interpret-ri + type: reading-interpretation + prompt: > + The refractometer gives a single stationary reading at 1.54 — isotropic, no + birefringence. Both almandine garnet (RI ≈1.78–1.81) and glass (RI ≈1.50–1.70) are + singly refractive, so optic character alone can't separate them here. What does the + numeric value itself show? + options: + - id: favours-glass + text: "1.54 sits inside glass's typical range, well below almandine — this favours glass" + weight: optimal + score: 10 + rationale: > + Even though neither candidate shows birefringence, the position of a single RI + reading is diagnostic in its own right. 1.54 sits comfortably inside glass's typical + 1.50–1.70 range and is far below almandine's 1.78–1.81 — this alone strongly favours + a glass identification. + candidatesAfter: + - familyId: glass-simulant + name: "Glass (Paste)" + - id: reading-inconclusive + text: "Since both candidates are isotropic, this reading is inconclusive" + weight: poor + score: 0 + rationale: > + This ignores that the numeric value of a single RI reading is itself the evidence, + independent of whether the stone shows doubling. 1.54 is far outside almandine's + 1.78–1.81 range, so the reading is anything but inconclusive. + candidatesAfter: + - familyId: almandine + name: "Almandine Garnet" + - familyId: glass-simulant + name: "Glass (Paste)" + pointsMultiplier: 1 + + - id: inclusion-observation + type: candidate-narrowing + prompt: > + Under 10x magnification you see small round gas bubbles (one small cluster, a few + elongated) and faint curved swirl marks; no mineral crystals or fingerprint inclusions + are visible anywhere in the stone. How does this affect the candidate list? + options: + - id: confirms-glass + text: "Confirms glass; rules out almandine garnet entirely" + weight: optimal + score: 10 + rationale: > + Round gas bubbles and swirl (flow) marks are specifically diagnostic of glass + manufacture and are never produced by garnet's natural crystal growth. Almandine's + typical inclusions — rutile silk, rounded mineral crystals, fingerprint inclusions — + are all absent here, so this evidence on its own is enough to exclude garnet. + candidatesAfter: + - familyId: glass-simulant + name: "Glass (Paste)" + - id: consistent-but-cautious + text: "Consistent with glass, but garnet can't be fully excluded without checking SG" + weight: acceptable + score: 5 + rationale: > + Correctly reads the inclusion evidence, but is overly cautious: gas bubbles and flow + lines simply do not occur in garnet, so this evidence alone already excludes it. SG + is still worth taking as an independent check — not because the inclusions left any + real ambiguity. + candidatesAfter: + - familyId: glass-simulant + name: "Glass (Paste)" + - id: misreads-crystals + text: "Rounded inclusions of any kind indicate a rounded mineral crystal, so this could be almandine" + weight: poor + score: 0 + rationale: > + Misreads the evidence. Genuine mineral crystal inclusions are angular or show their + own crystal faces; they don't take the perfectly spherical (sometimes elongated) form + of trapped gas, and garnet never shows the curved flow-line texture that is unique to + a once-molten, viscous material. + candidatesAfter: + - familyId: almandine + name: "Almandine Garnet" + pointsMultiplier: 1 + evidenceRevealed: + - id: inclusion-evidence + kind: inclusion + label: "Microscopic inclusions" + value: "Round gas bubbles (one small cluster, some elongated) and faint curved swirl/flow marks" + detail: "No crystals or fingerprint inclusions observed." + + - id: narrow-by-sg + type: candidate-narrowing + prompt: "Hydrostatic weighing gives SG 2.62. How does this affect the candidate list?" + options: + - id: corroborates-glass + text: "Corroborates glass; consistent with RI and inclusion evidence already gathered" + weight: optimal + score: 10 + rationale: > + Glass covers a very wide SG range (about 2.3–4.5 depending on lead content) — heavily + leaded glass can reach as high as almandine's own 4.05–4.30 range, so SG alone is not + always decisive for this particular pair. Here, though, 2.62 sits at the low end + typical of ordinary soda-lime glass and is clearly separate from any garnet, so it + corroborates rather than contradicts the RI and inclusion evidence. + candidatesAfter: + - familyId: glass-simulant + name: "Glass (Paste)" + - id: sg-redundant + text: "SG doesn't add anything beyond the RI and inclusion evidence" + weight: acceptable + score: 5 + rationale: > + The conclusion isn't wrong — RI and inclusions were already sufficient to reach glass + — but this undervalues SG as an independent, corroborating measurement. Given that + leaded glass can range up to SG 4.5 and overlap almandine's range, taking SG is + actually more important for this specific confusion pair than for most, not less. + candidatesAfter: + - familyId: glass-simulant + name: "Glass (Paste)" + pointsMultiplier: 1 + evidenceRevealed: + - id: sg-reading + kind: sg + label: "Specific gravity" + value: "2.62" + detail: "Hydrostatic weighing; glass reference range 2.3–4.5 (varies with lead content); almandine reference range 4.05–4.30." + toolHref: /tools/measurement + + - id: final-call + type: final-identification + prompt: > + What is your final identification and disclosure to the customer, including how it + should be treated for insurance purposes? + options: + - id: glass-disclosed + text: "Glass (paste) imitation of garnet — disclose as a simulant; insure at replacement cost, not gemstone value" + weight: optimal + score: 10 + rationale: > + The RI (1.54, far below almandine), the gas-bubble and swirl-mark inclusions, and the + corroborating SG (2.62) are all internally consistent with glass. Trade practice and + CIBJO-aligned disclosure standards require simulants to be named explicitly as such — + "glass" or "imitation" — never described using the mineral name they resemble, and + insurance value should reflect that. + - id: glass-vague + text: "It's an imitation — just tell her it isn't a real stone" + weight: acceptable + score: 5 + rationale: > + The identification itself is correct, but disclosure standards call for naming the + specific material ("glass"/"paste"), not a vague dismissal. Precise terminology + matters for the customer's insurance and any future resale decisions. + - id: natural-almandine + text: "Natural almandine garnet, suitable for a full gemstone insurance valuation" + weight: poor + score: 0 + rationale: > + Contradicted by every property gathered: the RI is far too low for almandine, the + inclusions are gas bubbles and flow marks rather than crystals or silk, and the SG is + well below almandine's 4.05–4.30 range. Identifying it as garnet risks over-insuring + a low-value imitation. + pointsMultiplier: 2 + +debrief: + summary: > + The stone is a glass (paste) imitation of almandine garnet, not a natural gemstone. The RI + (1.54), the gas-bubble and swirl-mark inclusions, and the SG (2.62) are all mutually + consistent with glass and clearly separate from almandine's published ranges. As a simulant, + it must be disclosed by name and insured at replacement rather than gemstone value. + expertPath: + - "Reach for the refractometer first — even though almandine garnet and glass are both isotropic, the numeric RI value itself separates them (about 1.78–1.81 vs about 1.50–1.70)." + - "Don't discount a single RI reading just because the stone shows no birefringence — the position of the reading is diagnostic in its own right." + - "Check inclusions for round gas bubbles and swirl/flow marks (glass) versus crystals, silk, and fingerprints (garnet)." + - "Take an SG reading as an independent corroborating check — remember leaded glass can range up to SG 4.5, overlapping garnet's range, so don't rely on SG alone." + - "Disclose simulants by name — 'glass' or 'paste', never as the natural mineral they imitate — and set insurance expectations accordingly." + furtherReading: + - "Read, P.G. (2012). Gemmology (3rd ed.). Routledge. DOI: 10.4324/9780080507224 — glass as a gem simulant; polariscope strain effects." + +conceptTags: + - garnet + - glass-simulant + - refractive-index + - specific-gravity + - inclusions + - polariscope + - disclosure + +references: + - id: mineral-db-almandine + citation: "Almandine garnet reference data (RI 1.78–1.81, SG 4.05–4.30, isotropic; inclusions: rutile silk, rounded crystals, fingerprints). gemmology-mineral-database, data/source/minerals/almandine.yaml." + - id: mineral-db-glass-simulant + citation: "Glass (Paste) simulant reference data (RI 1.50–1.70, SG 2.3–4.5, isotropic/amorphous; diagnostic gas bubbles, swirl marks). gemmology-mineral-database, data/source/simulants/glass-simulant.yaml." + - id: conversions-gemreference + citation: "Garnet (Almandine) cross-check range (RI 1.760–1.830, SG 3.95–4.30). src/lib/calculator/conversions.ts, GemReference table." + - id: read-2012-gemmology + citation: "Read, P.G. (2012). Gemmology (3rd ed.). Routledge." + url: "https://doi.org/10.4324/9780080507224" + +unvetted: false diff --git a/src/content/challenges/_example/example-track.yaml b/src/content/challenges/_example/example-track.yaml new file mode 100644 index 0000000..ebb2b59 --- /dev/null +++ b/src/content/challenges/_example/example-track.yaml @@ -0,0 +1,53 @@ +# ────────────────────────────────────────────────────────────────────────────── +# EXAMPLE CHALLENGE TRACK: schema reference only — not a real, authored track. +# +# This file lives in _example/ so Astro's glob-loader ignores it (same +# convention as src/content/questions/_example/example-mcq.yaml — the loader +# pattern excludes paths starting with _). It is NOT loaded into the +# challenges collection at build time and never appears on /study/challenges. +# +# Its only purpose is to exercise the challengeTrackSchema shape (both the +# explicit-questionIds stage type and the tag-fallback stage type) so the +# schema and validate-challenges.mjs tooling have something concrete to check +# against. It deliberately uses conceptTags ("example-only-tag") that will +# not match any real curated question, so if it were ever accidentally +# included, its tag-fallback stage would simply resolve to zero questions +# rather than silently pulling in unrelated real content. +# +# A real, researched track (e.g. gem chemistry) is authored separately — +# see CLAUDE.md's Study Challenge Tracks section. +# ────────────────────────────────────────────────────────────────────────────── + +# id: must match the filename slug, lowercase, digits and hyphens only. +id: example-track + +title: "Example Track (schema reference only)" + +description: > + Placeholder track used only to validate the challenge-track schema and + build pipeline. Not a real study track. + +icon: "🧪" + +categoryAffinity: fundamentals + +estimatedMinutes: 5 + +stages: + # Stage 1: explicit-ids selection path. + - id: stage-one + title: "Stage One (explicit question ids)" + description: "Demonstrates the explicit questionIds selection path." + questionIds: + - example-track-placeholder-question-001 + passThreshold: 0.7 + + # Stage 2: tag-fallback selection path. + - id: stage-two + title: "Stage Two (tag fallback)" + description: "Demonstrates the conceptTags + count tag-fallback selection path." + conceptTags: + - example-only-tag + tagMatch: all + count: 3 + passThreshold: 0.8 diff --git a/src/content/challenges/gem-chemistry.yaml b/src/content/challenges/gem-chemistry.yaml new file mode 100644 index 0000000..e6bf332 --- /dev/null +++ b/src/content/challenges/gem-chemistry.yaml @@ -0,0 +1,91 @@ +# Gem Chemistry challenge track — three staged sessions covering chemical +# formulas & classification, chromophore/colour-origin chemistry, and +# bench-relevant chemistry (behaviour, diamond types, treatments, simulants). +# +# Destined for src/content/challenges/gem-chemistry-track.yaml. +# All 30 questionIds referenced here correspond 1:1 to the 30 curated +# chem-*.yaml files delivered alongside this track (destined for +# src/content/questions/fundamentals/). + +id: gem-chemistry + +title: "Gem Chemistry" + +description: > + Three staged sessions on the chemistry behind gemstones: chemical formulas and + mineral classification, the trace-element chemistry that causes gem colour, and + the chemistry behind treatments, simulants, and diamond type — grounded in + peer-reviewed mineralogical and gemmological literature. + +icon: "🧪" + +categoryAffinity: fundamentals + +conceptTags: + - chemistry + - formulas + - chromophores + - colour-origin + - classification + - silicates + - diamond-types + - treatments-chemistry + +estimatedMinutes: 30 + +stages: + - id: formulas-and-families + title: "Formulas & families" + description: > + Chemical formulas of the major gem species and the silicate/oxide/phosphate + classification scheme that groups them. + questionIds: + - chem-diamond-purity + - chem-corundum-formula + - chem-beryl-formula + - chem-zircon-formula + - chem-spinel-formula + - chem-topaz-formula + - chem-turquoise-formula + - chem-species-formula-match + - chem-silicate-subclass-match + - chem-chrysoberyl-oxide-not-silicate + passThreshold: 0.7 + + - id: why-gems-have-colour + title: "Why gems have colour" + description: > + Chromophore chemistry: how trace ions like Cr3+, Fe3+, Fe2+/Ti4+, Cu2+ and Mn + produce (and sometimes share) gem colour, and the idiochromatic/allochromatic + distinction. + questionIds: + - chem-ruby-chromium + - chem-same-ion-different-host + - chem-blue-sapphire-charge-transfer + - chem-alexandrite-chromium-not-vanadium + - chem-morganite-manganese + - chem-paraiba-copper + - chem-amethyst-colour-centre + - chem-sapphire-iron-family + - chem-element-colour-match + - chem-idiochromatic-allochromatic-match + passThreshold: 0.7 + + - id: chemistry-at-the-bench + title: "Chemistry at the bench" + description: > + Applied chemistry for practising gemmologists: chain-silicate structure and + toughness, garnet-supergroup formulas, diamond type, treatment mechanisms, + and simulant composition. + questionIds: + - chem-jadeite-formula + - chem-jadeite-nephrite-toughness + - chem-garnet-general-formula + - chem-diamond-type-iib-boron + - chem-tsavorite-vanadium-caution + - chem-be-diffusion-mechanism + - chem-cz-stabilization + - chem-moissanite-natural-occurrence + - chem-organic-hydrous-classification-match + - chem-bench-chemistry-match + passThreshold: 0.7 diff --git a/src/content/config.ts b/src/content/config.ts index 98688cd..6789291 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -225,6 +225,174 @@ const questionsCollection = defineCollection({ ), }); +// ---------------------------------------------------------------------- +// Challenges collection (Study Challenge Tracks — Phase 1) +// ---------------------------------------------------------------------- +// A "track" is a themed, ordered sequence of quiz "stages" with staged +// mastery gating (stage N+1 unlocks only once stage N is passed). This +// collection is quiz-tracks only — Phase 3 scenario-style "cases" content +// is a separate `cases` collection built independently and is NOT modelled +// here (deliberate deviation from the original design sketch, which +// considered a shared `kind` discriminator on this collection). + +const challengeStageSchema = z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + description: z.string().optional(), + // Explicit-ids path: an ordered set of curated question ids. + questionIds: z.array(z.string()).min(1).optional(), + // Tag-fallback path: pull `count` questions matching conceptTags. + conceptTags: z.array(z.string()).min(1).optional(), + tagMatch: z.enum(['all', 'any']).default('all'), + count: z.number().int().min(1).optional(), + passThreshold: z.number().min(0).max(1).default(0.7), + }) + .refine( + (stage) => Boolean(stage.questionIds) || Boolean(stage.conceptTags && stage.count), + { + message: + 'Stage must define either questionIds, or both conceptTags and count (tag-fallback selection)', + }, + ); + +const challengeTrackSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + description: z.string(), + icon: z.string().optional(), + categoryAffinity: z + .enum([ + 'fundamentals', + 'equipment', + 'species', + 'identification', + 'phenomena', + 'origin', + 'market', + 'care', + ]) + .optional(), + conceptTags: z.array(z.string()).optional(), + estimatedMinutes: z.number().int().optional(), + stages: z.array(challengeStageSchema).min(1), +}); + +const challengeCollection = defineCollection({ + type: 'data', + schema: challengeTrackSchema, +}); + +// ---------------------------------------------------------------------- +// Cases collection (Lab Simulation case-based challenges — Phase 3) +// ---------------------------------------------------------------------- +// A "case" is a single scenario-driven identification/decision exercise +// (choose a test, interpret a reading, narrow candidates, call a treatment, +// reach a final identification). Deliberately separate from `challenges` +// (Phase 1's quiz-stage tracks) per the Phase 1 config.ts comment — cases +// are decision trees with evidence reveal and tiered (optimal/acceptable/ +// poor) scoring, not right/wrong MCQ stages. + +const evidenceKindSchema = z.enum([ + 'visual', + 'ri', + 'sg', + 'birefringence', + 'optic-character', + 'pleochroism', + 'spectroscope', + 'uv-fluorescence', + 'chelsea-filter', + 'inclusion', + 'hardness', + 'other', +]); + +const evidenceItemSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + kind: evidenceKindSchema, + label: z.string(), + value: z.string(), + detail: z.string().optional(), + toolHref: z.string().optional(), +}); + +const caseOptionSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + text: z.string().min(1), + weight: z.enum(['optimal', 'acceptable', 'poor']), + score: z.number().int().min(0).max(10), + rationale: z.string(), + revealsEvidenceIds: z.array(z.string()).optional(), + candidatesAfter: z + .array(z.object({ familyId: z.string(), name: z.string() })) + .optional(), + timeCost: z.number().int().min(0).max(5).optional(), +}); + +const caseStepSchema = z.object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + type: z.enum([ + 'choose-next-test', + 'reading-interpretation', + 'candidate-narrowing', + 'final-identification', + 'treatment-call', + ]), + prompt: z.string().min(1), + evidenceRevealed: z.array(evidenceItemSchema).optional(), + options: z.array(caseOptionSchema).min(2).max(6), + pointsMultiplier: z.number().int().min(1).max(3).default(1), + learnLinks: z.array(z.string()).optional(), + toolLinks: z + .array(z.object({ href: z.string(), label: z.string() })) + .optional(), +}); + +const caseGroundTruthSchema = z.object({ + speciesFamilyId: z.string(), + variety: z.string().optional(), + treatment: z.string().optional(), + originNote: z.string().optional(), +}); + +const caseDebriefSchema = z.object({ + summary: z.string(), + expertPath: z.array(z.string()).min(1), + furtherReading: z.array(z.string()).optional(), +}); + +const casesCollection = defineCollection({ + type: 'data', + schema: z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + title: z.string(), + difficulty: z.enum(['foundation', 'intermediate', 'diploma']), + estimatedMinutes: z.number().int(), + backstory: z.string(), + specimenSummary: z.string(), + groundTruth: caseGroundTruthSchema, + steps: z.array(caseStepSchema).min(3), + debrief: caseDebriefSchema, + conceptTags: z.array(z.string()).optional(), + references: z + .array( + z.object({ + id: z.string(), + citation: z.string(), + url: z.string().url().optional(), + }), + ) + .optional(), + unvetted: z.boolean().default(false), + }) + .refine( + (c) => c.steps.some((s) => s.type === 'final-identification'), + { message: 'Case must contain at least one final-identification step' }, + ), +}); + // Learn collection schema const learnCollection = defineCollection({ type: 'data', @@ -259,4 +427,6 @@ const learnCollection = defineCollection({ export const collections = { learn: learnCollection, questions: questionsCollection, + challenges: challengeCollection, + cases: casesCollection, }; diff --git a/src/content/questions/fundamentals/chem-alexandrite-chromium-not-vanadium.yaml b/src/content/questions/fundamentals/chem-alexandrite-chromium-not-vanadium.yaml new file mode 100644 index 0000000..c907a1f --- /dev/null +++ b/src/content/questions/fundamentals/chem-alexandrite-chromium-not-vanadium.yaml @@ -0,0 +1,67 @@ +id: chem-alexandrite-chromium-not-vanadium + +stem: | + A candidate confidently states that alexandrite's famous colour change is "caused by + vanadium, the same element behind some colour-change garnet material." Is this correct for + alexandrite specifically? + +type: mcq + +options: + - text: "No — alexandrite's colour change is caused by Cr3+ substituting for Al3+ in chrysoberyl (BeAl2O4), not vanadium." + isCorrect: true + rationale: | + Alexandrite is chromium-doped chrysoberyl (Cr:BeAl2O4). The chromium substitution for + aluminium is what produces the classic colour-change effect between daylight and + incandescent light, not vanadium. + - text: "Yes — alexandrite's colour change is caused by V3+, as in some tsavorite/emerald material." + isCorrect: false + rationale: | + Vanadium is a genuine trace element of gemmological interest in other species, but + alexandrite's colour change is documented as a chromium effect (Cr:BeAl2O4), not a + vanadium one. Assuming every colour-change gem shares one mechanism overlooks + species-specific chemistry. + - text: "Alexandrite's colour change is a physical (interference) phenomenon, not a chromophore effect." + isCorrect: false + rationale: | + Colour change in alexandrite is a genuine chromophore (absorption) effect from Cr3+, + not a physical interference or diffraction phenomenon; it should not be confused with + optical phenomena like adularescence or iridescence, which are structural rather than + chemical in origin. + +rationaleCorrect: | + Alexandrite's colour change is produced by Cr3+ substituting for Al3+ in chrysoberyl + (BeAl2O4) — the doped composition is explicitly written as Cr:BeAl2O4 in the crystal-field + literature on transition-metal-ion laser host materials. This is the same chromium ion + responsible for ruby and emerald colour, once again demonstrating the "same ion, different + host, different effect" principle, here producing colour change rather than a single fixed + hue. Vanadium is not the operative chromophore in alexandrite, even though it is a + recurring (and sometimes over-attributed) trace element in other species. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - chrysoberyl + - alexandrite + +sourceArticle: species/chrysoberyl + +examRelevance: FGA-diploma + +confusionPairs: + - chem-ruby-chromium + - chem-tsavorite-vanadium-caution + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: morrison-1992 + citation: "Morrison, C.A. (1992). BeAl2O4 (Chrysoberyl, Cr:BeAl2O4 = Alexandrite). In Crystal Fields for Transition-Metal Ions in Laser Host Materials (pp. 72–76). Springer." + url: https://doi.org/10.1007/978-3-642-95686-7_15 diff --git a/src/content/questions/fundamentals/chem-amethyst-colour-centre.yaml b/src/content/questions/fundamentals/chem-amethyst-colour-centre.yaml new file mode 100644 index 0000000..f418f07 --- /dev/null +++ b/src/content/questions/fundamentals/chem-amethyst-colour-centre.yaml @@ -0,0 +1,66 @@ +id: chem-amethyst-colour-centre + +stem: | + A candidate explains amethyst's purple colour as "just dispersed Fe3+ ions sitting in the + quartz lattice, the same kind of mechanism as yellow sapphire's iron chromophore." Why is + this an incomplete explanation? + +type: mcq + +options: + - text: "Amethyst's colour is a hole-trapped Fe4+ colour centre, formed when irradiation converts substitutional Fe3+ via the transition Fe4+ + O2- → Fe3+ + O1-, not simple dispersed Fe3+." + isCorrect: true + rationale: | + EPR spectroscopy of amethyst quartz shows the colour centre is a positive hole trapped on + a substitutional Fe3+ ion, converting it to Fe4+; the visible colour comes from the + charge-transfer transition back to Fe3+, not from Fe3+ sitting passively in the lattice + the way iron does in yellow sapphire. + - text: "Amethyst's colour comes from Al3+ substitution alone, with no iron involved." + isCorrect: false + rationale: | + Aluminium substitution is associated with other quartz colour centres (such as smoky + quartz), but amethyst's specific EPR-confirmed colour centre involves iron, not + aluminium alone. + - text: "Amethyst and citrine have entirely unrelated colour mechanisms and cannot occur in the same crystal." + isCorrect: false + rationale: | + Amethyst and citrine are chemically linked through iron-based colour mechanisms in + quartz, and heat can convert amethyst to citrine-like colour — they are not unrelated, + isolated phenomena confined to separate crystals. + +rationaleCorrect: | + Amethyst's purple is a genuine colour centre, not a simple dispersed-ion chromophore like + Fe3+ in yellow sapphire. Natural irradiation converts substitutional Fe3+ in the quartz + lattice into a hole-trapped Fe4+ state; EPR spectroscopy confirms the colour centre + corresponds to "a positive hole trapped on a substitutional Fe(3+) ion," with the colour + ascribed to the charge-transfer transition Fe4+ + O2- → Fe3+ + O1-. This distinguishes + amethyst's mechanism from simpler single-ion chromophores and explains why amethyst can be + bleached or altered by heat treatment (which can disrupt the colour centre) in ways that + simple dispersed-ion colours like ruby's chromium cannot be. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - quartz + - amethyst + +sourceArticle: species/quartz + +examRelevance: FGA-diploma + +confusionPairs: + - chem-idiochromatic-allochromatic-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: lehmann-moore-1966 + citation: "Lehmann, G. & Moore, M.E. (1966). Color Center in Amethyst Quartz. Science, 152(3725), 1061–1062." + url: https://doi.org/10.1126/science.152.3725.1061 diff --git a/src/content/questions/fundamentals/chem-be-diffusion-mechanism.yaml b/src/content/questions/fundamentals/chem-be-diffusion-mechanism.yaml new file mode 100644 index 0000000..1f88198 --- /dev/null +++ b/src/content/questions/fundamentals/chem-be-diffusion-mechanism.yaml @@ -0,0 +1,64 @@ +id: chem-be-diffusion-mechanism + +stem: | + A candidate is asked to describe how beryllium diffusion treatment alters corundum colour, + and answers, "it's basically a surface coating of chromium sprayed onto the stone." What is + the correct mechanism? + +type: mcq + +options: + - text: "The small Be2+ ion is diffused into the corundum lattice at temperatures above 1800°C, altering trace-element colour chemistry throughout, not just the surface." + isCorrect: true + rationale: | + Beryllium diffusion works by driving Be2+ — a very small ion — into the corundum crystal + structure at very high temperature, creating yellow, orange or brown colour components + (and modifying existing colour) via genuine lattice diffusion, not a surface coating. + - text: "Be diffusion works by adding chromium, not beryllium, to the surface." + isCorrect: false + rationale: | + The treatment is named for the element it diffuses — beryllium — not chromium. Chromium + is corundum's native red chromophore; Be diffusion introduces a different trace element + entirely. + - text: "Be diffusion is a surface coating, not a lattice diffusion process." + isCorrect: false + rationale: | + This is the misconception in the stem. The process is a genuine high-temperature lattice + diffusion, driving beryllium into the crystal structure itself, which is exactly why it + reshaped corundum identification practice after being recognised in 2002 — surface + coatings are comparatively easy to detect, but lattice-level diffusion is not. + +rationaleCorrect: | + Beryllium diffusion treatment works by driving the very small Be2+ ion into the corundum + lattice at temperatures above 1800°C, altering the trace-element colour chemistry described + for corundum's native chromophores and creating yellow, orange or brown colour components + (or modifying existing colour). This is a genuine solid-state lattice diffusion process, not + a surface coating — and it is now known that a full range of corundum colours, including + yellow and blue as well as ruby, have been produced or altered by this treatment since it + reshaped corundum identification practice after 2002. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - treatments-chemistry + - corundum + +sourceArticle: identification/treatments-deep/beryllium-diffusion + +examRelevance: FGA-diploma + +confusionPairs: + - chem-bench-chemistry-match + - chem-ruby-chromium + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 diff --git a/src/content/questions/fundamentals/chem-bench-chemistry-match.yaml b/src/content/questions/fundamentals/chem-bench-chemistry-match.yaml new file mode 100644 index 0000000..6c9523c --- /dev/null +++ b/src/content/questions/fundamentals/chem-bench-chemistry-match.yaml @@ -0,0 +1,68 @@ +id: chem-bench-chemistry-match + +stem: | + Match each bench-relevant material or phenomenon to the chemical process or composition that + explains it. These are the kinds of facts examiners expect you to connect treatment history, + simulant identity, and diamond type. + +type: matching + +pairs: + - left: "Beryllium-diffused corundum" + right: "Be2+ driven into the lattice at >1800°C" + - left: "Cubic zirconia (CZ)" + right: "Yttria- or calcia-stabilized ZrO2" + - left: "Moissanite" + right: "Silicon carbide (SiC)" + - left: "Type IIb diamond" + right: "Boron impurities, no measurable nitrogen" + +rationaleCorrect: | + Beryllium diffusion treatment works by driving the very small Be2+ ion into the corundum + lattice at temperatures above 1800°C, altering trace-element colour chemistry to create or + modify yellow, orange and brown components (and, since it was first recognised in 2002, a + wider range of corundum colours). Cubic zirconia is zirconium dioxide (ZrO2) that must be + chemically stabilized, typically with yttria or calcia, to keep its high-temperature cubic + form stable at room temperature — pure, unstabilized ZrO2 reverts to a monoclinic structure + on cooling. Moissanite is silicon carbide (SiC); although marketed as a lab-created diamond + simulant, SiC has also been confirmed, in at least one specimen, as a genuine (very rare) + rock-forming natural mineral. Type IIb diamonds contain no measurable nitrogen but do + contain boron impurities, which cause both their blue colour and their semiconducting + properties — the opposite impurity profile from the nitrogen-bearing Type Ia/Ib diamonds. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - treatments-chemistry + - diamond-types + - simulants + - corundum + +sourceArticle: identification/synthetics + +examRelevance: FGA-diploma + +confusionPairs: + - chem-be-diffusion-mechanism + - chem-cz-stabilization + - chem-moissanite-natural-occurrence + - chem-diamond-type-iib-boron + +unvetted: true + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 + - id: molodetsky-1998 + citation: "Molodetsky, I., Navrotsky, A., Lajavardi, M. & Brune, A. (1998). The Energetics of Cubic Zirconia from Solution Calorimetry of Yttria- and Calcia-Stabilized Zirconia. Zeitschrift für Physikalische Chemie, 207(1), 59–65." + url: https://doi.org/10.1524/zpch.1998.207.part_1_2.059 + - id: dipierro-2003 + citation: "Di Pierro, S., Gnos, E., Grobety, B.H., Armbruster, T., Bernasconi, S.M. & Ulmer, P. (2003). Rock-forming moissanite (natural α-silicon carbide). American Mineralogist, 88(11-12), 1817–1821." + url: https://doi.org/10.2138/am-2003-11-1223 + - id: breeding-shigley-2009 + citation: "Breeding, C.M. & Shigley, J.E. (2009). The 'Type' Classification System of Diamonds and Its Importance in Gemology. Gems & Gemology, 45(2), 96–111." + url: https://doi.org/10.5741/gems.45.2.96 diff --git a/src/content/questions/fundamentals/chem-beryl-formula.yaml b/src/content/questions/fundamentals/chem-beryl-formula.yaml new file mode 100644 index 0000000..e77b813 --- /dev/null +++ b/src/content/questions/fundamentals/chem-beryl-formula.yaml @@ -0,0 +1,50 @@ +id: chem-beryl-formula + +stem: | + A student mixes up beryl and chrysoberyl on a formula-matching worksheet, writing "BeAl2O4" + next to "beryl (emerald, aquamarine, morganite)." Write the formula that actually belongs to + beryl. + +type: fill-blank + +acceptedAnswers: + - "Be3Al2Si6O18" + - "Be₃Al₂Si₆O₁₈" + - "be3al2si6o18" + - "Be3Al2(Si6O18)" + - "Be3Al2[Si6O18]" + +rationaleCorrect: | + Beryl's formula is Be3Al2Si6O18 — a cyclosilicate built around six-membered [Si6O18] rings. + BeAl2O4 is chrysoberyl's formula, an oxide with no silicon at all; the two species share the + "beryl/beryllium" wordstem and the element beryllium, which is exactly why they are + routinely confused, but chrysoberyl is chemically an oxide while true beryl is a + ring silicate. Emerald, aquamarine, heliodor and morganite are all colour varieties of this + same Be3Al2Si6O18 host. + +difficulty: 1 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - beryl + - cyclosilicate + +sourceArticle: species/beryl + +examRelevance: FGA-foundation + +confusionPairs: + - chem-chrysoberyl-oxide-not-silicate + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: dhz-beryl-2013 + citation: "Deer, W.A., Howie, R.A. & Zussman, J. (2013). Beryl Be3Al2[Si6O18]. In An Introduction to the Rock-Forming Minerals (3rd ed.). Mineralogical Society of Great Britain and Ireland." + url: https://doi.org/10.1180/dhz.16 diff --git a/src/content/questions/fundamentals/chem-blue-sapphire-charge-transfer.yaml b/src/content/questions/fundamentals/chem-blue-sapphire-charge-transfer.yaml new file mode 100644 index 0000000..af0008d --- /dev/null +++ b/src/content/questions/fundamentals/chem-blue-sapphire-charge-transfer.yaml @@ -0,0 +1,65 @@ +id: chem-blue-sapphire-charge-transfer + +stem: | + A candidate assumes blue sapphire must be coloured by a single ion, "just like ruby is + coloured by chromium alone," and guesses Fe3+ is responsible. Why is this reasoning flawed? + +type: mcq + +options: + - text: "Blue sapphire's colour arises from an Fe2+–Ti4+ intervalence charge transfer between two adjacent ions, not from either ion alone." + isCorrect: true + rationale: | + Blue sapphire's mechanism is fundamentally different from a single-ion chromophore like + ruby's Cr3+. An electron effectively transfers ("hops") between adjacent Fe2+ and Ti4+ + ions, and it is this coupled two-ion process, not either ion in isolation, that absorbs + light and produces the blue colour. + - text: "Blue sapphire's colour is caused by Fe3+ alone." + isCorrect: false + rationale: | + Fe3+ alone is responsible for yellow sapphire, not blue. Blue requires the coupled + Fe2+-Ti4+ intervalence charge transfer mechanism, a fundamentally different process from + a single dispersed ion. + - text: "Blue sapphire is coloured by a boron-related defect, analogous to type IIb diamond." + isCorrect: false + rationale: | + Boron/nitrogen-defect colour mechanisms belong to diamond type classification, not + corundum. Sapphire's blue is unrelated to boron chemistry; it is an iron-titanium + charge-transfer effect within the corundum lattice. + +rationaleCorrect: | + Unlike ruby's single-ion Cr3+ chromophore, blue sapphire's colour comes from an Fe2+–Ti4+ + intervalence charge transfer: an electron effectively "hops" between adjacent iron and + titanium ions, and this coupled two-ion process is what absorbs light and produces the blue. + This mechanism is explicitly distinguished from single-ion Fe3+ or Cr3+ chromophores in the + Gems & Gemology beryllium-diffusion literature, and corroborated by materials-science work + on the electronic origin of blue in sapphire. It is a useful example that not all gem + colour reduces to "one trace element, one colour" — some of the most important gem colours + require two interacting ions. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - corundum + +sourceArticle: species/corundum + +examRelevance: FGA-diploma + +confusionPairs: + - chem-sapphire-iron-family + - chem-ruby-chromium + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 diff --git a/src/content/questions/fundamentals/chem-chrysoberyl-oxide-not-silicate.yaml b/src/content/questions/fundamentals/chem-chrysoberyl-oxide-not-silicate.yaml new file mode 100644 index 0000000..431ab97 --- /dev/null +++ b/src/content/questions/fundamentals/chem-chrysoberyl-oxide-not-silicate.yaml @@ -0,0 +1,65 @@ +id: chem-chrysoberyl-oxide-not-silicate + +stem: | + A candidate reasons: "Chrysoberyl must be a silicate — its name literally contains 'beryl,' + and beryl is a silicate." Which statement correctly resolves this? + +type: mcq + +options: + - text: "Chrysoberyl is BeAl2O4, an oxide with no silicon; the name is a historical false friend, not a chemical relationship." + isCorrect: true + rationale: | + Chrysoberyl's formula, BeAl2O4, contains no SiO4 tetrahedra at all — it is an oxide, in + the same broad chemical class as corundum and spinel. Both chrysoberyl and true beryl + contain beryllium, which is the actual (and coincidental) source of the shared wordstem, + not a shared silicate structure. + - text: "Chrysoberyl is Be3Al2Si6O18, identical to beryl's cyclosilicate formula." + isCorrect: false + rationale: | + This formula belongs to true beryl, not chrysoberyl. Confusing the two is a classic + naming trap: chrysoberyl (BeAl2O4) and beryl (Be3Al2Si6O18) are chemically unrelated + beyond sharing the element beryllium. + - text: "Chrysoberyl is BeSiO4, related to phenakite." + isCorrect: false + rationale: | + BeSiO4 is not a real mineral formula in this fact set; phenakite is Be2SiO4, a genuine + beryllium silicate, but chrysoberyl itself is the oxide BeAl2O4. This distractor mixes up + chrysoberyl with its beryllium-silicate cousin phenakite. + +rationaleCorrect: | + Chrysoberyl's formula is BeAl2O4 — an oxide, structurally unrelated to beryl's cyclosilicate + Be3Al2Si6O18, despite the shared "beryl" wordstem and the shared element beryllium. Because + it has no SiO4 tetrahedra, chrysoberyl sits in the same broad "oxide" chemical class as + corundum (Al2O3) and spinel (MgAl2O4), not with the silicate gems. This is one of + gemmology's most persistent naming traps, precisely because the etymological link (both + contain beryllium, both have "beryl" in the name) has nothing to do with crystal chemistry. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - chrysoberyl + - oxide + +sourceArticle: species/chrysoberyl + +examRelevance: FGA-diploma + +confusionPairs: + - chem-beryl-formula + - chem-species-formula-match + +unvetted: true + +references: + - id: hazen-finger-1987 + citation: "Hazen, R.M. & Finger, L.W. (1987). High-temperature crystal chemistry of phenakite (Be2SiO4) and chrysoberyl (BeAl2O4). Physics and Chemistry of Minerals, 14(5), 426–434." + url: https://doi.org/10.1007/bf00628819 + - id: heuer-lagerlof-1999 + citation: "Heuer, A.H. & Lagerlöf, K.P.D. (1999). Oxygen self-diffusion in corundum (alpha-Al2O3): A conundrum. Philosophical Magazine Letters, 79(8), 619–627." + url: https://doi.org/10.1080/095008399177002 diff --git a/src/content/questions/fundamentals/chem-corundum-formula.yaml b/src/content/questions/fundamentals/chem-corundum-formula.yaml new file mode 100644 index 0000000..96ee482 --- /dev/null +++ b/src/content/questions/fundamentals/chem-corundum-formula.yaml @@ -0,0 +1,45 @@ +id: chem-corundum-formula + +stem: | + A candidate's revision notes read: "Corundum, the mineral species behind both ruby and + sapphire, is an aluminosilicate related to andalusite." Their tutor asks them to write out + corundum's correct chemical formula to check the claim. What should the candidate write? + +type: fill-blank + +acceptedAnswers: + - "Al2O3" + - "Al₂O₃" + - "al2o3" + +rationaleCorrect: | + Corundum is aluminium oxide, Al2O3 — a simple oxide with no silicon at all, not an + aluminosilicate like andalusite (Al2SiO5). The revision note conflates corundum with the + Al2SiO5 polymorphs (andalusite, kyanite, sillimanite) purely because both groups contain + aluminium; corundum's structure has no SiO4 tetrahedra whatsoever. Every gem colour of + corundum (ruby, and the various sapphire colours) is built on this same colourless Al2O3 + host, coloured only by trace transition-metal impurities substituting for Al3+. + +difficulty: 1 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - corundum + - oxide + +sourceArticle: species/corundum + +examRelevance: FGA-foundation + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: heuer-lagerlof-1999 + citation: "Heuer, A.H. & Lagerlöf, K.P.D. (1999). Oxygen self-diffusion in corundum (alpha-Al2O3): A conundrum. Philosophical Magazine Letters, 79(8), 619–627." + url: https://doi.org/10.1080/095008399177002 diff --git a/src/content/questions/fundamentals/chem-cz-stabilization.yaml b/src/content/questions/fundamentals/chem-cz-stabilization.yaml new file mode 100644 index 0000000..fcbda95 --- /dev/null +++ b/src/content/questions/fundamentals/chem-cz-stabilization.yaml @@ -0,0 +1,62 @@ +id: chem-cz-stabilization + +stem: | + A trainee assumes cubic zirconia is "just zirconium dioxide, no different chemically from + any other ZrO2," and is puzzled why manufacturers bother adding other oxides during + production. What is the chemical reason for this? + +type: mcq + +options: + - text: "Pure, unstabilized ZrO2 reverts to a monoclinic structure on cooling; yttria or calcia doping is needed to keep the cubic form stable at room temperature." + isCorrect: true + rationale: | + ZrO2's high-temperature cubic crystal form is not stable on its own at room temperature. + Manufacturers add stabilizing oxides — typically yttria or calcia — specifically to lock + in the cubic structure that gives cubic zirconia its name and its diamond-simulant + optical properties. + - text: "CZ requires no stabilizing dopant; its cubic form is intrinsically stable." + isCorrect: false + rationale: | + This is the misconception in the stem. Without a stabilizing dopant, ZrO2 does not + remain cubic at room temperature — it reverts to a different (monoclinic) crystal form, + which would defeat the purpose of the material as "cubic" zirconia. + - text: "CZ's stabilizer (yttria) is also its primary colour-causing chromophore in colourless material." + isCorrect: false + rationale: | + Yttria's role is structural (stabilizing the cubic crystal form), not chromophoric. + Colourless cubic zirconia is not coloured by its stabilizer; different trace dopants are + used separately when coloured CZ varieties are manufactured. + +rationaleCorrect: | + Cubic zirconia, a diamond simulant, is zirconium dioxide (ZrO2) that must be chemically + stabilized — typically with yttria or calcia — to keep its high-temperature cubic crystal + form stable at room temperature. Pure, unstabilized ZrO2 would revert to a different + (monoclinic) crystal structure on cooling, according to calorimetric studies of yttria- and + calcia-stabilized zirconia. This is why "cubic" zirconia is, chemically, always a doped + material rather than pure ZrO2 — the stabilizing dopant is what makes the cubic form (and + therefore the gemstone) possible at all. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - simulants + - classification + +sourceArticle: identification/synthetics + +examRelevance: FGA-diploma + +confusionPairs: + - chem-zircon-formula + - chem-moissanite-natural-occurrence + +unvetted: true + +references: + - id: molodetsky-1998 + citation: "Molodetsky, I., Navrotsky, A., Lajavardi, M. & Brune, A. (1998). The Energetics of Cubic Zirconia from Solution Calorimetry of Yttria- and Calcia-Stabilized Zirconia. Zeitschrift für Physikalische Chemie, 207(1), 59–65." + url: https://doi.org/10.1524/zpch.1998.207.part_1_2.059 diff --git a/src/content/questions/fundamentals/chem-diamond-purity.yaml b/src/content/questions/fundamentals/chem-diamond-purity.yaml new file mode 100644 index 0000000..20c3eea --- /dev/null +++ b/src/content/questions/fundamentals/chem-diamond-purity.yaml @@ -0,0 +1,62 @@ +id: chem-diamond-purity + +stem: | + A first-year student claims that diamond owes part of its exceptional hardness to "a trace + of silica (SiO2) locking the carbon lattice together." Which statement correctly describes + diamond's chemical composition? + +type: mcq + +options: + - text: "Diamond is pure carbon (C); no silica or other compound is structurally essential." + isCorrect: true + rationale: | + Diamond crystallises in the isometric system as pure carbon. Its hardness comes from the + strength and geometry of carbon-carbon covalent bonding throughout the lattice, not from + any second compound. + - text: "Diamond contains trace silica (SiO2) as an essential structural component." + isCorrect: false + rationale: | + This is the claim in the stem, and it is incorrect. Diamond's lattice is carbon + throughout; silica plays no structural role. This distractor likely arises from + confusing diamond with silicate gems, where SiO4 groups genuinely are essential. + - text: "Diamond is a carbide of boron." + isCorrect: false + rationale: | + Boron can occur as a trace impurity (and is responsible for Type IIb diamond's blue + colour and semiconductivity), but it is never an essential, structural part of diamond's + formula. Diamond is not a carbide of any element — it is a single-element mineral. + +rationaleCorrect: | + Diamond's formula is simply C. A 2009 Gems & Gemology paper on diamond type classification + describes lattice vacancies as carbon sites left unoccupied, confirming the lattice is built + of carbon atoms throughout — there is no second compound or bonding element woven into the + structure. Trace elements like nitrogen and boron can substitute into that carbon lattice + (this is the basis of the type classification system), but they are impurities, not + essential formula components. + +difficulty: 1 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - diamond + +sourceArticle: species/diamond + +examRelevance: FGA-foundation + +confusionPairs: + - chem-diamond-type-iib-boron + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: breeding-shigley-2009 + citation: "Breeding, C.M. & Shigley, J.E. (2009). The 'Type' Classification System of Diamonds and Its Importance in Gemology. Gems & Gemology, 45(2), 96–111." + url: https://doi.org/10.5741/gems.45.2.96 diff --git a/src/content/questions/fundamentals/chem-diamond-type-iib-boron.yaml b/src/content/questions/fundamentals/chem-diamond-type-iib-boron.yaml new file mode 100644 index 0000000..66719db --- /dev/null +++ b/src/content/questions/fundamentals/chem-diamond-type-iib-boron.yaml @@ -0,0 +1,66 @@ +id: chem-diamond-type-iib-boron + +stem: | + A rare natural blue diamond is submitted for type classification. A trainee guesses it must + be "Type Ia, since that's the most common natural diamond type, and common things get + submitted most often." What type and impurity chemistry actually explain a natural blue + diamond? + +type: mcq + +options: + - text: "Type IIb — no IR-measurable nitrogen, but boron impurities responsible for the blue colour and semiconducting properties." + isCorrect: true + rationale: | + Type IIb diamonds contain no easily measurable nitrogen; instead, isolated boron atoms + replace carbon in the lattice, producing both the blue colour and the characteristic + electrical conductivity of these rare stones. + - text: "Blue diamond colour (type IIb) is caused by nitrogen aggregates, the same defect as yellow type Ia." + isCorrect: false + rationale: | + Type IIb diamonds are specifically defined by an absence of measurable nitrogen; the + nitrogen-aggregate defects that cause yellowish body colour belong to type Ia, an + entirely different impurity profile from boron-bearing type IIb. + - text: "Type II diamonds are defined by high nitrogen content, the opposite of type I." + isCorrect: false + rationale: | + This reverses the definition: type I diamonds contain measurable nitrogen (detectable by + IR absorption spectroscopy), while type II diamonds do not contain enough nitrogen to be + detected at all — type II is defined by an absence of nitrogen, not a surplus. + +rationaleCorrect: | + Diamond colour is closely tied to type. Type I diamonds are defined as containing sufficient + nitrogen to be measurable by IR absorption spectroscopy (Ia = aggregated nitrogen, the + common form; Ib = isolated single nitrogen atoms, associated with rare "canary" yellow), + whereas type II diamonds do not contain enough nitrogen to be detected. Within type II, type + IIb diamonds contain no IR-measurable nitrogen but do contain boron impurities — thought to + be isolated single atoms replacing carbon in the lattice — and these boron impurities are + directly responsible for both the blue colour and the electrical conductivity that make type + IIb diamonds so distinctive and rare. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - diamond-types + - colour-origin + - diamond + +sourceArticle: species/diamond + +examRelevance: FGA-diploma + +confusionPairs: + - chem-diamond-purity + - chem-bench-chemistry-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: breeding-shigley-2009 + citation: "Breeding, C.M. & Shigley, J.E. (2009). The 'Type' Classification System of Diamonds and Its Importance in Gemology. Gems & Gemology, 45(2), 96–111." + url: https://doi.org/10.5741/gems.45.2.96 diff --git a/src/content/questions/fundamentals/chem-element-colour-match.yaml b/src/content/questions/fundamentals/chem-element-colour-match.yaml new file mode 100644 index 0000000..6db67fa --- /dev/null +++ b/src/content/questions/fundamentals/chem-element-colour-match.yaml @@ -0,0 +1,58 @@ +id: chem-element-colour-match + +stem: | + Match each chromophore mechanism to the colour it is best known for producing in a major gem + species. One pair involves two ions acting together, not a single ion alone. + +type: matching + +pairs: + - left: "Cr3+ in corundum" + right: "Red (ruby)" + - left: "Fe3+ at high concentration in corundum" + right: "Yellow sapphire" + - left: "Fe2+–Ti4+ intervalence charge transfer in corundum" + right: "Blue sapphire" + - left: "Cu2+ in cuprian ('Paraíba') elbaite" + right: "Intense blue-to-green" + +rationaleCorrect: | + Cr3+ substituting for Al3+ in corundum produces ruby's red. Fe3+ at concentrations of + roughly 2,500 ppma or more produces yellow sapphire. Blue sapphire is different in kind: + it is not caused by either iron or titanium alone but by an Fe2+–Ti4+ intervalence charge + transfer, an electron effectively "hopping" between the two adjacent ions. In cuprian + elbaite tourmaline, the intense saturated blue-to-green "Paraíba" colour comes from Cu2+ at + concentrations far above those of ordinary tourmaline. A common error is crediting Ti4+ + alone for sapphire blue, or assuming yellow sapphire shares ruby's Cr3+ mechanism just + because both are corundum. + +difficulty: 2 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - corundum + - tourmaline + +sourceArticle: fundamentals/colour-theory + +examRelevance: FGA-diploma + +confusionPairs: + - chem-blue-sapphire-charge-transfer + - chem-sapphire-iron-family + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 + - id: fritsch-1990 + citation: "Fritsch, E., Shigley, J.E., Rossman, G.R., Mercer, M.E., Muhlmeister, S.M. & Moon, M. (1990). Gem-Quality Cuprian-Elbaite Tourmalines From São José Da Batalha, Paraíba, Brazil. Gems & Gemology, 26(3), 189–205." + url: https://doi.org/10.5741/gems.26.3.189 diff --git a/src/content/questions/fundamentals/chem-garnet-general-formula.yaml b/src/content/questions/fundamentals/chem-garnet-general-formula.yaml new file mode 100644 index 0000000..ce44403 --- /dev/null +++ b/src/content/questions/fundamentals/chem-garnet-general-formula.yaml @@ -0,0 +1,65 @@ +id: chem-garnet-general-formula + +stem: | + An exam question gives an unfamiliar garnet-supergroup mineral and asks the candidate to + identify the general site pattern any garnet formula must follow, without relying on any one + named end-member (pyrope, almandine, etc.). Which general formula is correct? + +type: mcq + +options: + - text: "{X3}[Y2](Z3)φ12, where X, Y, Z are dodecahedral, octahedral and tetrahedral sites and φ = O, OH, or F." + isCorrect: true + rationale: | + This is the IMA-recognised general structural formula for the entire garnet supergroup, + covering not just the familiar silicate end-members but also non-silicate garnet-group + minerals where φ can be OH or F rather than O. + - text: "Mg3Al2Si3O12, a single fixed formula shared by every garnet species." + isCorrect: false + rationale: | + This is only pyrope's specific end-member formula, not a general formula for the + supergroup. Treating all garnets as chemically identical ignores the wide compositional + range covered by the X, Y, Z site substitutions (Mg, Fe2+, Mn2+, Ca in X; Al, Fe3+, Cr3+ + in Y, and so on). + - text: "A formula requiring essential chromium in every garnet species, as in uvarovite." + isCorrect: false + rationale: | + Chromium is essential only in a chromium end-member such as uvarovite; it is not part of + the general garnet formula, and most garnet species (pyrope, almandine, spessartine, + grossular, andradite) contain no essential chromium at all. + +rationaleCorrect: | + The garnet supergroup's general structural formula, as established by the IMA nomenclature + review, is {X3}[Y2](Z3)φ12, where X, Y and Z denote the dodecahedral, octahedral and + tetrahedral sites respectively, and φ represents O, OH, or F. This general pattern is what + allows dozens of named garnet species — silicate end-members like pyrope, almandine, + spessartine, grossular and andradite among them — to be classified within one supergroup + despite very different site occupancies. A candidate should be able to recognise this + general pattern independent of memorising every specific end-member subscript formula. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - garnet + - nesosilicate + +sourceArticle: species/garnet + +examRelevance: FGA-diploma + +confusionPairs: + - chem-tsavorite-vanadium-caution + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: grew-2013 + citation: "Grew, E.S., Locock, A.J., Mills, S.J., Galuskina, I.O., Galuskin, E.V. & Hålenius, U. (2013). Nomenclature of the garnet supergroup. American Mineralogist, 98(4), 785–811." + url: https://doi.org/10.2138/am.2013.4201 diff --git a/src/content/questions/fundamentals/chem-idiochromatic-allochromatic-match.yaml b/src/content/questions/fundamentals/chem-idiochromatic-allochromatic-match.yaml new file mode 100644 index 0000000..041697b --- /dev/null +++ b/src/content/questions/fundamentals/chem-idiochromatic-allochromatic-match.yaml @@ -0,0 +1,71 @@ +id: chem-idiochromatic-allochromatic-match + +stem: | + Match each species to its correct colour-mechanism classification: idiochromatic (the + colouring cation is an essential part of the mineral's own formula) or allochromatic (the + colour comes from a trace impurity in an otherwise colourless or differently coloured host). + +type: matching + +pairs: + - left: "Corundum (ruby/sapphire)" + right: "Allochromatic" + - left: "Beryl (emerald/aquamarine)" + right: "Allochromatic" + - left: "Quartz (amethyst)" + right: "Allochromatic" + - left: "Turquoise" + right: "Idiochromatic" + +rationaleCorrect: | + Corundum is colourless as pure Al2O3; every gem colour it shows (ruby's red, every sapphire + hue) is produced by trace Cr, Fe and Ti impurities, making it a classic allochromatic + mineral. Beryl is the same story: colourless goshenite is pure Be3Al2Si6O18, while emerald, + aquamarine and morganite owe their colours to different trace cations riding in the same + host. Quartz is likewise allochromatic — amethyst's purple comes from an iron-related colour + centre (a hole trapped on substitutional Fe3+, converting it to Fe4+), not from silicon or + oxygen. Turquoise is different in kind: its blue-forming Cu2+ is written directly into its + own formula, CuAl6(PO4)4(OH)8·4H2O, so it is idiochromatic. The classic trap is assuming + common trace elements (like chromium in both ruby and emerald) make a mineral idiochromatic + because the colour "feels" intrinsic — idiochromatic refers strictly to whether the ion is + part of the formula, not how vivid or characteristic the colour is. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - classification + - corundum + - beryl + - quartz + - turquoise + +sourceArticle: fundamentals/colour-theory + +examRelevance: FGA-diploma + +confusionPairs: + - chem-turquoise-formula + - chem-organic-hydrous-classification-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 + - id: fritsch-rossman-1988-pt1 + citation: "Fritsch, E. & Rossman, G.R. (1987). An Update on Color in Gems. Part 1: Introduction and Colors Caused by Dispersed Metal Ions. Gems & Gemology, 23(3), 126–139." + url: https://doi.org/10.5741/gems.23.3.126 + - id: lehmann-moore-1966 + citation: "Lehmann, G. & Moore, M.E. (1966). Color Center in Amethyst Quartz. Science, 152(3725), 1061–1062." + url: https://doi.org/10.1126/science.152.3725.1061 + - id: cid-dresdner-1965 + citation: "Cid-Dresdner, H. (1965). Determination and refinement of the crystal structure of turquois, CuAl6(PO4)4(OH)8·4H2O. Zeitschrift für Kristallographie, 121(2–4), 87–113." + url: https://doi.org/10.1524/zkri.1965.121.2-4.87 diff --git a/src/content/questions/fundamentals/chem-jadeite-formula.yaml b/src/content/questions/fundamentals/chem-jadeite-formula.yaml new file mode 100644 index 0000000..91249f5 --- /dev/null +++ b/src/content/questions/fundamentals/chem-jadeite-formula.yaml @@ -0,0 +1,71 @@ +id: chem-jadeite-formula + +stem: | + A trainee, revising "jade," writes down a single formula and calls it "jade's chemical + formula," without distinguishing jadeite from nephrite. Which formula correctly belongs to + jadeite specifically? + +type: mcq + +options: + - text: "NaAlSi2O6 — a single-chain inosilicate in the pyroxene group." + isCorrect: true + rationale: | + Jadeite's formula is NaAlSi2O6, placing it in the pyroxene group as a single-chain + inosilicate. This is chemically and structurally distinct from nephrite, which belongs + to a different silicate chain group entirely. + - text: "Ca(Mg,Fe)Si2O6, the diopside formula." + isCorrect: false + rationale: | + This is diopside's formula, another pyroxene-group member. Diopside and jadeite are both + single-chain pyroxenes but are chemically distinct species (sodium-aluminium jadeite vs. + calcium-magnesium-iron diopside), and diopside is not what is meant by gem "jade." + - text: "A double-chain silicate formula identical to nephrite's." + isCorrect: false + rationale: | + Jadeite and nephrite do not share a formula or even a chain type: jadeite is a + single-chain pyroxene, while nephrite (a compact tremolite-actinolite amphibole) is a + double-chain silicate. Treating "jade" as one chemical substance obscures this + important structural difference. + +rationaleCorrect: | + Jadeite's formula is NaAlSi2O6, an inosilicate belonging to the pyroxene group (a + single-chain silicate), as confirmed both by the IMA pyroxene nomenclature and by + crystal-growth literature on synthetic jadeite. "Jade" is a market umbrella term covering + two chemically and structurally distinct minerals: jadeite (single-chain pyroxene) and + nephrite (double-chain amphibole, from the tremolite-actinolite series). Treating jade as a + single chemical species, or assuming jadeite's green colour requires essential chromium in + its formula, are both common errors — jadeite's own end-member formula contains no + chromium; trace Cr3+ is an allochromatic addition responsible for the finest "imperial" + green jadeite jade. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - silicates + - jadeite + +sourceArticle: origin/burma/jadeite + +examRelevance: FGA-diploma + +confusionPairs: + - chem-jadeite-nephrite-toughness + - chem-silicate-subclass-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: zhao-2018 + citation: "Zhao, D., Ma, C., Fang, C., Ding, X. & Jia, X. (2018). Synthesis and characterization of purple NaAlSi2O6 jadeite under high pressure and high temperature. Journal of Crystal Growth, 499, 30–34." + url: https://doi.org/10.1016/j.jcrysgro.2018.07.034 + - id: morimoto-1988 + citation: "Morimoto, N., Fabries, J., Ferguson, A.K., Ginzburg, I.V., Ross, M., Seifert, F.A., Zussman, J., Aoki, K. & Gottardi, G. (1988). Nomenclature of Pyroxenes. Mineralogical Magazine, 52(367), 535–550." + url: https://doi.org/10.1180/minmag.1988.052.367.15 diff --git a/src/content/questions/fundamentals/chem-jadeite-nephrite-toughness.yaml b/src/content/questions/fundamentals/chem-jadeite-nephrite-toughness.yaml new file mode 100644 index 0000000..abcc10d --- /dev/null +++ b/src/content/questions/fundamentals/chem-jadeite-nephrite-toughness.yaml @@ -0,0 +1,70 @@ +id: chem-jadeite-nephrite-toughness + +stem: | + A carver explains that nephrite jade is notably tougher (more resistant to chipping and + breaking) than jadeite jade, despite jadeite usually having a slightly higher refractive + index and density. What structural chemistry explains nephrite's superior toughness? + +type: mcq + +options: + - text: "Nephrite is a double-chain amphibole (felted fibrous structure); jadeite is a single-chain pyroxene." + isCorrect: true + rationale: | + Nephrite belongs to the tremolite-actinolite amphibole series, a double-chain inosilicate + whose interlocking, felted fibrous crystal habit gives it exceptional toughness. Jadeite, + a single-chain pyroxene, lacks that double-chain interlocking structure, which is why it + is typically less tough despite its higher RI and SG. + - text: "Jadeite is the double-chain silicate and nephrite the single-chain silicate." + isCorrect: false + rationale: | + This reverses the correct assignment. Jadeite (a pyroxene) is the single-chain + inosilicate; nephrite (an amphibole, from the tremolite-actinolite series) is the + double-chain inosilicate whose felted structure produces its superior toughness. + - text: "Both are amphiboles, so their toughness difference must come from trace-element content, not structure." + isCorrect: false + rationale: | + Jadeite and nephrite are not both amphiboles — jadeite is a pyroxene. The toughness + difference is explained by their different silicate chain structures (single vs. + double chain), not by trace-element chemistry. + +rationaleCorrect: | + Jadeite (NaAlSi2O6) is a pyroxene-group mineral, a single-chain inosilicate, per the IMA + pyroxene nomenclature. Nephrite is a compact, fine-grained variety within the + tremolite-actinolite series of the amphibole supergroup (general formula AB2C5T8O22W2), a + double-chain inosilicate, per the IMA amphibole nomenclature. The double-chain, felted + fibrous structure of nephrite's interlocking amphibole crystals gives it markedly greater + toughness than jadeite's single-chain structure, even though jadeite typically shows a + slightly higher refractive index and specific gravity — a useful reminder that toughness, + hardness and optical/density properties are governed by different aspects of crystal + chemistry and are not interchangeable. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - classification + - silicates + - jadeite + +sourceArticle: origin/burma/jadeite + +examRelevance: FGA-diploma + +confusionPairs: + - chem-jadeite-formula + - chem-silicate-subclass-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: morimoto-1988 + citation: "Morimoto, N., Fabries, J., Ferguson, A.K., Ginzburg, I.V., Ross, M., Seifert, F.A., Zussman, J., Aoki, K. & Gottardi, G. (1988). Nomenclature of Pyroxenes. Mineralogical Magazine, 52(367), 535–550." + url: https://doi.org/10.1180/minmag.1988.052.367.15 + - id: hawthorne-2012 + citation: "Hawthorne, F.C., Oberti, R., Harlow, G.E., Maresch, W.V., Martin, R.F., Schumacher, J.C. & Welch, M.D. (2012). Nomenclature of the amphibole supergroup. American Mineralogist, 97(11-12), 2031–2048." + url: https://doi.org/10.2138/am.2012.4276 diff --git a/src/content/questions/fundamentals/chem-moissanite-natural-occurrence.yaml b/src/content/questions/fundamentals/chem-moissanite-natural-occurrence.yaml new file mode 100644 index 0000000..2269c47 --- /dev/null +++ b/src/content/questions/fundamentals/chem-moissanite-natural-occurrence.yaml @@ -0,0 +1,64 @@ +id: chem-moissanite-natural-occurrence + +stem: | + A candidate states that "moissanite is purely a laboratory-created diamond simulant and has + never been confirmed in terrestrial rock." Is this an accurate description of moissanite's + chemistry and occurrence? + +type: mcq + +options: + - text: "No — moissanite is silicon carbide (SiC), and although marketed as lab-created, it has been petrographically confirmed as a genuine, very rare rock-forming natural mineral in at least one terrestrial specimen." + isCorrect: true + rationale: | + A 2003 American Mineralogist study reports the first occurrence of moissanite (SiC) as a + rock-forming mineral (8.4 vol%) in a unique terrestrial rock specimen, directly + contradicting the idea that natural moissanite is unknown outside meteorites. + - text: "Moissanite is a pure carbon allotrope, like diamond." + isCorrect: false + rationale: | + Moissanite's formula is silicon carbide (SiC), not pure carbon. Confusing it with a + carbon allotrope like diamond ignores that its diamond-simulant status comes from similar + optical/physical properties, not a shared chemical formula. + - text: "Natural moissanite has never been confirmed in terrestrial rocks, only in meteorites." + isCorrect: false + rationale: | + This is the claim being tested, and it is incorrect: the 2003 study specifically reports + moissanite as a rock-forming mineral in a terrestrial (not meteoritic) specimen, expanding + its known natural occurrence beyond meteoritic contexts. + +rationaleCorrect: | + Moissanite is silicon carbide (SiC). Although marketed and mass-produced as a + laboratory-created diamond simulant, SiC has also been confirmed petrographically as a + genuine, very rare rock-forming natural mineral: a 2003 American Mineralogist study reports + its first occurrence as a rock-forming mineral (8.4 vol%) in one unique terrestrial rock + specimen. This nuance matters for gemmologists because it shows that "natural vs. + laboratory-created" and "common vs. rare" are independent axes — a material can be + overwhelmingly lab-produced for commercial purposes while still having a confirmed, if + vanishingly rare, natural occurrence. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - simulants + - classification + +sourceArticle: identification/synthetics + +examRelevance: FGA-diploma + +confusionPairs: + - chem-cz-stabilization + - chem-diamond-purity + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: dipierro-2003 + citation: "Di Pierro, S., Gnos, E., Grobety, B.H., Armbruster, T., Bernasconi, S.M. & Ulmer, P. (2003). Rock-forming moissanite (natural α-silicon carbide). American Mineralogist, 88(11-12), 1817–1821." + url: https://doi.org/10.2138/am-2003-11-1223 diff --git a/src/content/questions/fundamentals/chem-morganite-manganese.yaml b/src/content/questions/fundamentals/chem-morganite-manganese.yaml new file mode 100644 index 0000000..1cc5322 --- /dev/null +++ b/src/content/questions/fundamentals/chem-morganite-manganese.yaml @@ -0,0 +1,64 @@ +id: chem-morganite-manganese + +stem: | + A candidate assumes morganite's soft pink colour must come from the same chromophore as ruby + and emerald, since all three are pink-to-red gem colours in silicate hosts. Which chromophore + is actually responsible for morganite's colour? + +type: mcq + +options: + - text: "Manganese (Mn2+ and/or Mn3+), not chromium or iron." + isCorrect: true + rationale: | + Morganite, the pink/peach variety of beryl, is documented as manganese-activated beryl — + its colour centre involves manganese, not the chromium responsible for emerald or the + iron responsible for aquamarine and heliodor in the same beryl host. + - text: "Morganite's pink colour is caused by Cr3+, as in ruby/emerald." + isCorrect: false + rationale: | + Chromium colours emerald green in the same beryl host, not morganite pink. Assuming every + pink-to-red gem shares chromium as its chromophore overlooks manganese's distinct role in + morganite specifically. + - text: "Morganite and rhodochrosite share the same chromophore mechanism because both are pink-to-red." + isCorrect: false + rationale: | + Sharing a similar colour family does not imply a shared chromophore mechanism or even a + shared mineral class — rhodochrosite is a manganese carbonate, an entirely different + chemical class from beryl, and its specific colour mechanism is not established in this + question's source material. + +rationaleCorrect: | + Morganite, the pink-to-peach variety of beryl, owes its colour to manganese (Mn2+ and/or + Mn3+) rather than to the chromium responsible for emerald or the iron responsible for + aquamarine and heliodor in the same Be3Al2Si6O18 host. This is documented in EPR + spectroscopy research on manganese-activated beryl. It is a useful reminder that a single + host mineral (beryl) can carry several unrelated chromophores across its colour varieties, + and that superficially similar colours (pink-to-red) do not imply a shared chromophore + across different species. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - beryl + - morganite + +sourceArticle: species/beryl + +examRelevance: FGA-diploma + +confusionPairs: + - chem-ruby-chromium + - chem-same-ion-different-host + +unvetted: true + +references: + - id: solntsev-1981 + citation: "Solntsev, V.P., Kharchenko, E.I., Lebedev, A.S., Klyakhin, V.A. & Il'in, A.G. (1981). Nature of color centers and EPR of a manganese-activated beryl. Journal of Applied Spectroscopy, 34(1), 111–115." + url: https://doi.org/10.1007/bf00613738 diff --git a/src/content/questions/fundamentals/chem-organic-hydrous-classification-match.yaml b/src/content/questions/fundamentals/chem-organic-hydrous-classification-match.yaml new file mode 100644 index 0000000..1a40092 --- /dev/null +++ b/src/content/questions/fundamentals/chem-organic-hydrous-classification-match.yaml @@ -0,0 +1,57 @@ +id: chem-organic-hydrous-classification-match + +stem: | + Match each material to the chemical/structural class that best describes it. Not everything + a jeweller sells is a single-phase crystalline mineral. + +type: matching + +pairs: + - left: "Amber" + right: "Organic (fossilized resin, not a true mineral)" + - left: "Common opal" + right: "Hydrous, largely non-crystalline silica" + - left: "Pearl / nacre" + right: "Biogenic composite (aragonite + conchiolin protein)" + - left: "Corundum" + right: "Crystalline oxide (contrast case)" + +rationaleCorrect: | + Amber is fossilized tree resin — an organic gem material, not a mineral in the strict + crystallographic sense. Common opal is SiO2·nH2O, hydrous and largely non-crystalline + (opal-A) or poorly ordered (opal-CT/opal-C), fundamentally different from crystalline + quartz despite sharing the same silica chemistry. Pearl and nacre are biogenic composites: + aragonite (a CaCO3 polymorph) platelets bound together by the organic protein conchiolin, + not a single-phase mineral at all. Corundum is included as a contrast case — a fully + crystalline oxide with none of the organic, hydrous or biogenic complications of the other + three, useful for testing whether a candidate can tell "gem material" apart from "mineral + species" in the strict sense. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - classification + - organic-gems + - amber + - opal + - pearl + +sourceArticle: fundamentals/chemical-properties + +examRelevance: FGA-diploma + +unvetted: true + +references: + - id: gough-mills-1972 + citation: "Gough, L.J. & Mills, J.S. (1972). The Composition of Succinite (Baltic Amber). Nature, 239, 527–528." + url: https://doi.org/10.1038/239527a0 + - id: jones-segnit-1971 + citation: "Jones, J.B. & Segnit, E.R. (1971). The nature of opal I. Nomenclature and constituent phases. Journal of the Geological Society of Australia, 18(1), 57–68." + url: https://doi.org/10.1080/00167617108728743 + - id: bowen-tang-1996 + citation: "Bowen, C.E. & Tang, H. (1996). Conchiolin-protein in aragonite shells of mollusks. Comparative Biochemistry and Physiology Part A, 115(4), 269–275." + url: https://doi.org/10.1016/s0300-9629(96)00059-x diff --git a/src/content/questions/fundamentals/chem-paraiba-copper.yaml b/src/content/questions/fundamentals/chem-paraiba-copper.yaml new file mode 100644 index 0000000..08fed19 --- /dev/null +++ b/src/content/questions/fundamentals/chem-paraiba-copper.yaml @@ -0,0 +1,63 @@ +id: chem-paraiba-copper + +stem: | + A dealer describes an intensely saturated neon-blue-to-green tourmaline as "Paraíba-type" + and is asked which trace element is chemically responsible for that saturation. What is the + correct answer? + +type: mcq + +options: + - text: "Copper (Cu2+), sometimes combined with manganese, at unusually high concentration." + isCorrect: true + rationale: | + Quantitative chemical analysis of the original São José da Batalha, Paraíba material + found surprisingly high copper concentrations, far above ordinary + tourmaline — this is the chromophore behind the intense, saturated blue-to-green colour. + - text: "Cobalt, like some synthetic spinel imitations." + isCorrect: false + rationale: | + Cobalt is a chromophore associated with certain synthetic blue spinel imitations of + sapphire, not with Paraíba-type tourmaline. Confusing the two mixes up an unrelated + simulant chemistry with a genuine trace-element gem colour. + - text: "Copper in tourmaline is always paired with chromium, as in emerald." + isCorrect: false + rationale: | + Paraíba tourmaline's chemistry does not require chromium; the analysed material's + distinguishing feature is exceptionally high copper (sometimes with manganese), not a + copper-chromium pairing analogous to emerald's chromium chemistry. + +rationaleCorrect: | + Cuprian ("Paraíba") elbaite tourmaline owes its intensely saturated blue-to-green colour to + copper (Cu2+), sometimes combined with manganese, at concentrations far higher than in + ordinary tourmaline. Quantitative analysis of the original São José da Batalha, Paraíba, + Brazil material found exceptionally high copper concentrations, a level that + explains why Paraíba-type material is prized for a saturation unmatched by ordinary + tourmaline colours (which are typically driven by iron or manganese alone at far lower + concentrations). + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - tourmaline + +sourceArticle: species/tourmaline + +examRelevance: FGA-diploma + +confusionPairs: + - chem-element-colour-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: fritsch-1990 + citation: "Fritsch, E., Shigley, J.E., Rossman, G.R., Mercer, M.E., Muhlmeister, S.M. & Moon, M. (1990). Gem-Quality Cuprian-Elbaite Tourmalines From São José Da Batalha, Paraíba, Brazil. Gems & Gemology, 26(3), 189–205." + url: https://doi.org/10.5741/gems.26.3.189 diff --git a/src/content/questions/fundamentals/chem-ruby-chromium.yaml b/src/content/questions/fundamentals/chem-ruby-chromium.yaml new file mode 100644 index 0000000..c8f496c --- /dev/null +++ b/src/content/questions/fundamentals/chem-ruby-chromium.yaml @@ -0,0 +1,64 @@ +id: chem-ruby-chromium + +stem: | + A student is asked to name the chromophore ion responsible for ruby's red colour, and + suggests vanadium because "vanadium and chromium are often confused in gem chemistry." + Which ion actually produces ruby's colour, and over what range? + +type: mcq + +options: + - text: "Cr3+ substituting for Al3+, producing pale pink through deep red depending on concentration." + isCorrect: true + rationale: | + Cr3+ substituting for Al3+ in the corundum lattice is ruby's chromophore. As chromium + concentration rises, the colour deepens from pale pink through to deep red — the basis + for the traditional pink-sapphire/ruby boundary debate. + - text: "V3+ is the primary ruby chromophore." + isCorrect: false + rationale: | + Vanadium is a real trace element of interest in several gem species, but it is not the + chromophore responsible for ruby's characteristic red — that role belongs to chromium. + Vanadium's role in gem colour is frequently over-stated in informal literature. + - text: "Ruby red comes from Fe3+, the same ion that causes yellow sapphire." + isCorrect: false + rationale: | + Fe3+ at high concentration causes yellow sapphire, a different corundum colour entirely. + Ruby's red is produced by chromium, not iron; mixing up the two ignores that different + chromophores in the same host produce entirely different hues. + +rationaleCorrect: | + Cr3+ substituting for Al3+ in corundum produces red coloration, ranging from pale pink + through deep red depending on how much chromium is present, as documented in the Gems & + Gemology study of beryllium diffusion treatment (which had to characterise corundum's + native chromophores in detail to understand how treatment alters them). This is one of the + clearest single-ion chromophore relationships in gemmology and the starting point for + understanding how the same Cr3+ ion behaves differently in other host lattices, such as + beryl. + +difficulty: 2 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - corundum + +sourceArticle: species/corundum + +examRelevance: FGA-foundation + +confusionPairs: + - chem-same-ion-different-host + - chem-alexandrite-chromium-not-vanadium + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 diff --git a/src/content/questions/fundamentals/chem-same-ion-different-host.yaml b/src/content/questions/fundamentals/chem-same-ion-different-host.yaml new file mode 100644 index 0000000..9faa6b5 --- /dev/null +++ b/src/content/questions/fundamentals/chem-same-ion-different-host.yaml @@ -0,0 +1,66 @@ +id: chem-same-ion-different-host + +stem: | + Ruby is red and emerald is green, yet both owe their colour to the same chromophore ion, + Cr3+. A candidate is asked to explain how one ion can cause two such different colours. + Which explanation is correct? + +type: mcq + +options: + - text: "The different host lattice (corundum vs. beryl) changes the crystal environment around the Cr3+ ion, shifting its absorption and the perceived colour." + isCorrect: true + rationale: | + Chromium is the chromophore in both ruby (corundum host) and emerald (beryl host). + Placing the same ion in two different crystal structures changes how its surrounding + atoms interact with it, shifting where it absorbs light and therefore what colour is + transmitted — the essence of the "same ion, different host, different colour" principle. + - text: "Emerald green comes from V3+ only; chromium is exclusive to ruby." + isCorrect: false + rationale: | + Chromium is documented as the emerald chromophore in the Gems & Gemology colour-origin + literature, describing "an atom (chromium in emerald)" directly. Restricting chromium to + ruby only misses this well-established shared chromophore relationship. + - text: "Emerald's colour is a diffraction/interference phenomenon, not an absorption-based chromophore." + isCorrect: false + rationale: | + Emerald's green is a classic dispersed-ion absorption colour, the same broad category as + ruby's red — not a physical (diffraction/interference) colour like the iridescence seen + in some other gem phenomena. + +rationaleCorrect: | + The same Cr3+ ion produces two completely different perceived colours depending on its + host: red in corundum (ruby) and green in beryl (emerald). Fritsch & Rossman's Gems & + Gemology series on colour origin identifies chromium explicitly as the emerald chromophore, + the same element already established for ruby. The underlying reason is that a + transition-metal ion's absorption spectrum depends on the surrounding crystal field, which + differs between corundum's and beryl's structures — so identical trace chemistry can + produce strikingly different gem colours purely because of the host lattice. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - corundum + - beryl + +sourceArticle: fundamentals/colour-theory + +examRelevance: FGA-diploma + +confusionPairs: + - chem-ruby-chromium + - chem-morganite-manganese + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: fritsch-rossman-1988-pt1 + citation: "Fritsch, E. & Rossman, G.R. (1987). An Update on Color in Gems. Part 1: Introduction and Colors Caused by Dispersed Metal Ions. Gems & Gemology, 23(3), 126–139." + url: https://doi.org/10.5741/gems.23.3.126 diff --git a/src/content/questions/fundamentals/chem-sapphire-iron-family.yaml b/src/content/questions/fundamentals/chem-sapphire-iron-family.yaml new file mode 100644 index 0000000..19220a3 --- /dev/null +++ b/src/content/questions/fundamentals/chem-sapphire-iron-family.yaml @@ -0,0 +1,66 @@ +id: chem-sapphire-iron-family + +stem: | + A candidate is shown a green sapphire and a purple/violet sapphire and asked to explain both + colours using the same short list of chromophores already established for yellow and blue + sapphire. Which explanation correctly accounts for both? + +type: mcq + +options: + - text: "Green sapphire combines Fe3+ yellow with Fe2+–Ti4+ blue; purple/violet combines Cr3+ red with Fe2+–Ti4+ blue." + isCorrect: true + rationale: | + Both unusual sapphire colours are combinations of the same chromophores already + established for yellow (Fe3+ at high concentration) and blue (Fe2+-Ti4+ charge transfer), + with chromium's red added for the purple/violet case. No new chromophore is needed to + explain either colour. + - text: "Green sapphire always indicates a chromium-vanadium mix, as in emerald." + isCorrect: false + rationale: | + Green sapphire's mechanism (Fe3+ yellow plus Fe2+-Ti4+ blue) is unrelated to emerald's + chromium/vanadium chemistry. Assuming any green gem must share emerald's chromophore + ignores that green can arise from combining two different colour components within one + host. + - text: "Iron plays no role in sapphire colour; only titanium matters." + isCorrect: false + rationale: | + Iron is central to at least three sapphire colours: yellow (Fe3+ alone), blue (in + combination with Ti4+), and green (Fe3+ yellow plus Fe2+-Ti4+ blue). Titanium alone, + without iron, does not explain any of these established sapphire colours. + +rationaleCorrect: | + Corundum's colour chemistry, as documented in the Gems & Gemology study of beryllium + diffusion treatment, builds every unusual sapphire colour from the same small set of + chromophores. Fe3+ at high concentration (roughly 2,500 ppma or more) gives yellow; combined + with the Fe2+-Ti4+ blue component it gives green sapphire; and Cr3+ red combined with that + same Fe2+-Ti4+ blue component gives purple or violet sapphire. Recognising this compositional + logic — rather than treating every colour as needing its own unique chromophore — is central + to understanding sapphire's colour range as variations on a small chemical theme. + +difficulty: 4 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - corundum + +sourceArticle: species/corundum + +examRelevance: FGA-diploma + +confusionPairs: + - chem-blue-sapphire-charge-transfer + - chem-element-colour-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: emmett-2003 + citation: "Emmett, J.L., Scarratt, K., McClure, S.F., Moses, T., Douthit, T.R., Hughes, R., Novak, S., Shigley, J.E., Wang, W., Bordelon, O. & Kane, R.E. (2003). Beryllium Diffusion of Ruby and Sapphire. Gems & Gemology, 39(2), 84–135." + url: https://doi.org/10.5741/gems.39.2.84 diff --git a/src/content/questions/fundamentals/chem-silicate-subclass-match.yaml b/src/content/questions/fundamentals/chem-silicate-subclass-match.yaml new file mode 100644 index 0000000..8768e95 --- /dev/null +++ b/src/content/questions/fundamentals/chem-silicate-subclass-match.yaml @@ -0,0 +1,66 @@ +id: chem-silicate-subclass-match + +stem: | + Match each species to its correct silicate structural subclass (or, for one of them, its + non-silicate class). Think about how the SiO4 tetrahedra (if present) are linked together. + +type: matching + +pairs: + - left: "Beryl" + right: "Cyclosilicate (ring)" + - left: "Zircon" + right: "Nesosilicate (isolated tetrahedra)" + - left: "Jadeite" + right: "Inosilicate (single chain)" + - left: "Turquoise" + right: "Phosphate (not a silicate)" + +rationaleCorrect: | + Beryl's Be3Al2Si6O18 formula is built of six-membered [Si6O18] rings — a cyclosilicate. + Zircon's ZrSiO4 has isolated SiO4 tetrahedra sharing no oxygens with each other — a + nesosilicate, the same class as garnet and topaz. Jadeite, NaAlSi2O6, is a pyroxene: a + single-chain inosilicate, distinct from double-chain amphiboles like nephrite's + tremolite-actinolite. Turquoise, CuAl6(PO4)4(OH)8·4H2O, contains no SiO4 tetrahedra at all — + it is a phosphate, a completely different chemical class from every silicate in this list. + The classic trap is assuming any gem with "silica-adjacent" hardness or lustre must be a + silicate, or mixing up which chain-silicate group (pyroxene vs. amphibole) is single- versus + double-chain. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - classification + - silicates + - beryl + - zircon + - jadeite + - turquoise + +sourceArticle: fundamentals/chemical-properties + +examRelevance: FGA-diploma + +confusionPairs: + - chem-jadeite-nephrite-toughness + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: dhz-beryl-2013 + citation: "Deer, W.A., Howie, R.A. & Zussman, J. (2013). Beryl Be3Al2[Si6O18]. In An Introduction to the Rock-Forming Minerals (3rd ed.). Mineralogical Society of Great Britain and Ireland." + url: https://doi.org/10.1180/dhz.16 + - id: finch-hanchar-2003 + citation: "Finch, R.J. & Hanchar, J.M. (2003). Structure and Chemistry of Zircon and Zircon-Group Minerals. Reviews in Mineralogy and Geochemistry, 53(1), 1–25." + url: https://doi.org/10.2113/0530001 + - id: morimoto-1988 + citation: "Morimoto, N., Fabries, J., Ferguson, A.K., Ginzburg, I.V., Ross, M., Seifert, F.A., Zussman, J., Aoki, K. & Gottardi, G. (1988). Nomenclature of Pyroxenes. Mineralogical Magazine, 52(367), 535–550." + url: https://doi.org/10.1180/minmag.1988.052.367.15 + - id: cid-dresdner-1965 + citation: "Cid-Dresdner, H. (1965). Determination and refinement of the crystal structure of turquois, CuAl6(PO4)4(OH)8·4H2O. Zeitschrift für Kristallographie, 121(2–4), 87–113." + url: https://doi.org/10.1524/zkri.1965.121.2-4.87 diff --git a/src/content/questions/fundamentals/chem-species-formula-match.yaml b/src/content/questions/fundamentals/chem-species-formula-match.yaml new file mode 100644 index 0000000..ddbee6e --- /dev/null +++ b/src/content/questions/fundamentals/chem-species-formula-match.yaml @@ -0,0 +1,65 @@ +id: chem-species-formula-match + +stem: | + Match each gem species to its correct chemical formula. Two of these species are pure oxides + with no silicon at all, and the other two are silicates — sort carefully. + +type: matching + +pairs: + - left: "Diamond" + right: "C" + - left: "Corundum" + right: "Al2O3" + - left: "Beryl" + right: "Be3Al2Si6O18" + - left: "Spinel" + right: "MgAl2O4" + +rationaleCorrect: | + Diamond is pure carbon (C), crystallising in the isometric system. Corundum (Al2O3) and + spinel (MgAl2O4) are both oxides — neither contains silicon, despite both being common, + hard, gem-quality minerals often mentally grouped with "silicate" gems. Beryl (Be3Al2Si6O18) + is the odd one out chemically: a cyclosilicate built of six-membered [Si6O18] rings. The + most common matching error is pairing corundum or spinel with a silicate-style formula out + of habit, or crediting chrysoberyl's oxide formula (BeAl2O4) to beryl because both contain + beryllium. + +difficulty: 2 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - diamond + - corundum + - beryl + - spinel + - oxide + +sourceArticle: fundamentals/chemical-properties + +examRelevance: FGA-foundation + +confusionPairs: + - chem-chrysoberyl-oxide-not-silicate + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: breeding-shigley-2009 + citation: "Breeding, C.M. & Shigley, J.E. (2009). The 'Type' Classification System of Diamonds and Its Importance in Gemology. Gems & Gemology, 45(2), 96–111." + url: https://doi.org/10.5741/gems.45.2.96 + - id: heuer-lagerlof-1999 + citation: "Heuer, A.H. & Lagerlöf, K.P.D. (1999). Oxygen self-diffusion in corundum (alpha-Al2O3): A conundrum. Philosophical Magazine Letters, 79(8), 619–627." + url: https://doi.org/10.1080/095008399177002 + - id: dhz-beryl-2013 + citation: "Deer, W.A., Howie, R.A. & Zussman, J. (2013). Beryl Be3Al2[Si6O18]. In An Introduction to the Rock-Forming Minerals (3rd ed.). Mineralogical Society of Great Britain and Ireland." + url: https://doi.org/10.1180/dhz.16 + - id: stoll-1964 + citation: "Stoll, E., Fischer, P., Hälg, W. & Maier, G. (1964). Redetermination of the cation distribution of spinel (MgAl2O4) by means of neutron diffraction. Journal de Physique, 25(5), 447–448." + url: https://doi.org/10.1051/jphys:01964002505044700 diff --git a/src/content/questions/fundamentals/chem-spinel-formula.yaml b/src/content/questions/fundamentals/chem-spinel-formula.yaml new file mode 100644 index 0000000..e06ceee --- /dev/null +++ b/src/content/questions/fundamentals/chem-spinel-formula.yaml @@ -0,0 +1,45 @@ +id: chem-spinel-formula + +stem: | + A gem dealer's inventory sheet lists a red stone as "spinel, FeAl2O4." Write the formula that + should actually appear for gem spinel, the mineral that gives the structure type its name. + +type: fill-blank + +acceptedAnswers: + - "MgAl2O4" + - "MgAl₂O₄" + - "mgal2o4" + - "Mg(AlO2)2" + +rationaleCorrect: | + Spinel, the mineral that lends its name to the whole "spinel group" of oxides, is MgAl2O4. + FeAl2O4 is hercynite, a different (iron-bearing) member of the same structural family — an + easy slip to make because both are simple oxides with the general spinel-structure formula + AB2O4. Gem spinel's characteristic reds, blues and pinks come from trace Cr, Fe and other + cations substituting into this Mg-Al oxide host, not from iron or chromium being essential to + the formula itself. + +difficulty: 2 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - spinel + - oxide + +sourceArticle: species/spinel + +examRelevance: FGA-foundation + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: stoll-1964 + citation: "Stoll, E., Fischer, P., Hälg, W. & Maier, G. (1964). Redetermination of the cation distribution of spinel (MgAl2O4) by means of neutron diffraction. Journal de Physique, 25(5), 447–448." + url: https://doi.org/10.1051/jphys:01964002505044700 diff --git a/src/content/questions/fundamentals/chem-topaz-formula.yaml b/src/content/questions/fundamentals/chem-topaz-formula.yaml new file mode 100644 index 0000000..e515b1c --- /dev/null +++ b/src/content/questions/fundamentals/chem-topaz-formula.yaml @@ -0,0 +1,47 @@ +id: chem-topaz-formula + +stem: | + A candidate writes topaz's formula as "Al2SiO5, the same as andalusite, kyanite and + sillimanite." Their tutor says this is incomplete. Write the formula that correctly + distinguishes topaz from that Al2SiO5 group. + +type: fill-blank + +acceptedAnswers: + - "Al2SiO4(F,OH)2" + - "Al2SiO4(OH,F)2" + - "Al₂SiO₄(F,OH)₂" + - "al2sio4(f,oh)2" + - "Al2[SiO4](OH,F)2" + - "Al2[SiO4](F,OH)2" + +rationaleCorrect: | + Topaz is Al2SiO4(F,OH)2 — a nesosilicate that, unlike the Al2SiO5 polymorphs (andalusite, + kyanite, sillimanite), has essential fluorine and/or hydroxyl in its formula instead of the + extra oxygen. Dropping the (F,OH) group and writing Al2SiO5 is a common shorthand error that + erases exactly the feature that makes topaz a distinct species rather than a fourth Al2SiO5 + polymorph. + +difficulty: 2 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - topaz + - nesosilicate + +sourceArticle: species/topaz + +examRelevance: FGA-foundation + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: dhz-topaz-2013 + citation: "Deer, W.A., Howie, R.A. & Zussman, J. (2013). Topaz Al2[SiO4](OH,F)2. In An Introduction to the Rock-Forming Minerals (3rd ed.). Mineralogical Society of Great Britain and Ireland." + url: https://doi.org/10.1180/dhz.10 diff --git a/src/content/questions/fundamentals/chem-tsavorite-vanadium-caution.yaml b/src/content/questions/fundamentals/chem-tsavorite-vanadium-caution.yaml new file mode 100644 index 0000000..d6b4175 --- /dev/null +++ b/src/content/questions/fundamentals/chem-tsavorite-vanadium-caution.yaml @@ -0,0 +1,69 @@ +id: chem-tsavorite-vanadium-caution + +stem: | + Older gemmological references often state flatly that "tsavorite's colour and luminescence + are caused by vanadium." A 2025 XPS/fluorescence study revisited this claim specifically for + tsavorite's luminescence spectrum. What did it find, and what caution does this raise for + gemmologists? + +type: mcq + +options: + - text: "Tsavorite's luminescence bands are due to Mn2+ (~591 nm) and Cr3+ (~690–750 nm); the study explicitly states vanadium is 'not responsible for the red emissions of tsavorite.'" + isCorrect: true + rationale: | + The 2025 study's abstract explicitly contradicts the common vanadium attribution: + "Contrary to what is often reported in the literature... V2+... is not responsible for + the red emissions of tsavorite." This is a direct caution against assuming vanadium is + the operative ion without checking current evidence. + - text: "Tsavorite's colour and luminescence are caused exclusively by V3+." + isCorrect: false + rationale: | + This is precisely the older, over-attributed claim that the 2025 study set out to + re-examine, and it found the opposite: vanadium is not responsible for the luminescence + bands studied, which are instead due to manganese and chromium. + - text: "Tsavorite never contains chromium; its green is diagnostic for isolating vanadium as sole chromophore." + isCorrect: false + rationale: | + The 2025 study identifies chromium (alongside manganese) as contributing to tsavorite's + luminescence spectrum, so chromium is demonstrably present and relevant — the opposite + of a chromium-free, vanadium-only picture. + +rationaleCorrect: | + A 2025 XPS/fluorescence study of grossular garnet var. tsavorite found its luminescence + spectrum is explained by Mn2+ (yellow emission, around 591 nm) and Cr3+ (red emission, around + 690–750 nm), and states directly that vanadium "is not responsible for the red emissions of + tsavorite" — contradicting older literature that often credits vanadium as the primary cause + of tsavorite's colour and glow. The caution for gemmologists is twofold: vanadium is a + recurring trace element across several unrelated gem species (corundum, tsavorite, emerald) + but its role as a genuine chromophore is frequently over-stated relative to more careful + modern analysis, and this particular study addresses tsavorite's luminescence specifically — + its bulk visible-absorption body colour is a related but distinct question that should not + be conflated with the luminescence findings here. + +difficulty: 5 + +category: fundamentals + +conceptTags: + - chemistry + - chromophores + - colour-origin + - garnet + +sourceArticle: species/garnet + +examRelevance: FGA-diploma + +confusionPairs: + - chem-garnet-general-formula + - chem-alexandrite-chromium-not-vanadium + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: idini-2025 + citation: "Idini, A., Argazzi, R., Frau, F., Fantauzzi, M. & Angeli, C. (2025). Unravelling the luminescence spectrum of garnet grossular var. tsavorite: The role of chromium (III), manganese (II) and misattribution of vanadium (II). Journal of Luminescence, 277, 120936." + url: https://doi.org/10.1016/j.jlumin.2024.120936 diff --git a/src/content/questions/fundamentals/chem-turquoise-formula.yaml b/src/content/questions/fundamentals/chem-turquoise-formula.yaml new file mode 100644 index 0000000..d87e449 --- /dev/null +++ b/src/content/questions/fundamentals/chem-turquoise-formula.yaml @@ -0,0 +1,52 @@ +id: chem-turquoise-formula + +stem: | + A candidate is asked to justify why turquoise's blue colour is described as "idiochromatic" + rather than caused by a trace impurity. To answer, they first need turquoise's full chemical + formula, showing which cation is structurally essential. Write it. + +type: fill-blank + +acceptedAnswers: + - "CuAl6(PO4)4(OH)8·4H2O" + - "CuAl6(PO4)4(OH)8.4H2O" + - "CuAl6(PO4)4(OH)8*4H2O" + - "CuAl₆(PO₄)₄(OH)₈·4H₂O" + - "cual6(po4)4(oh)8.4h2o" + - "CuAl6(PO4)4(OH)8 4H2O" + +rationaleCorrect: | + Turquoise is CuAl6(PO4)4(OH)8·4H2O — a hydrated copper-aluminium phosphate. Because copper + is written directly into this formula (not added as a trace impurity to a colourless host), + turquoise's blue is idiochromatic: the colouring cation is inseparable from the mineral's own + chemical identity. This also confirms turquoise belongs to the phosphate class, not the + silicate or oxide classes that most other gem species in this fact set fall into, and that + its water content is structural, bound in the crystal lattice rather than merely absorbed. + +difficulty: 3 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - colour-origin + - turquoise + - phosphate + +sourceArticle: fundamentals/chemical-properties + +examRelevance: FGA-diploma + +confusionPairs: + - chem-idiochromatic-allochromatic-match + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: cid-dresdner-1965 + citation: "Cid-Dresdner, H. (1965). Determination and refinement of the crystal structure of turquois, CuAl6(PO4)4(OH)8·4H2O. Zeitschrift für Kristallographie, 121(2–4), 87–113." + url: https://doi.org/10.1524/zkri.1965.121.2-4.87 diff --git a/src/content/questions/fundamentals/chem-zircon-formula.yaml b/src/content/questions/fundamentals/chem-zircon-formula.yaml new file mode 100644 index 0000000..ff6b588 --- /dev/null +++ b/src/content/questions/fundamentals/chem-zircon-formula.yaml @@ -0,0 +1,51 @@ +id: chem-zircon-formula + +stem: | + Asked to write zircon's formula, a candidate answers "ZrO2, since zircon is basically a + zirconium oxide." Give the correct formula that shows why this answer is wrong. + +type: fill-blank + +acceptedAnswers: + - "ZrSiO4" + - "ZrSiO₄" + - "zrsio4" + - "Zr[SiO4]" + - "Zr(SiO4)" + +rationaleCorrect: | + Zircon is ZrSiO4, a nesosilicate built of isolated SiO4 tetrahedra bonded to zirconium — it + is not an oxide, and silicon is an essential part of the formula, not absent from it. ZrO2 + (zirconia) is a completely different substance: the raw material behind the diamond simulant + cubic zirconia, which is never found as gem-quality natural zircon. Confusing the two is a + common trap because both contain zirconium and both can be near-colourless faceted stones. + +difficulty: 1 + +category: fundamentals + +conceptTags: + - chemistry + - formulas + - classification + - zircon + - nesosilicate + +sourceArticle: species/zircon + +examRelevance: FGA-foundation + +confusionPairs: + - chem-cz-stabilization + +unvetted: false +authorReviewed: "gem-chemistry-track-v1" +lastReviewed: "2026-07-14T00:00:00Z" + +references: + - id: finch-hanchar-2003 + citation: "Finch, R.J. & Hanchar, J.M. (2003). Structure and Chemistry of Zircon and Zircon-Group Minerals. Reviews in Mineralogy and Geochemistry, 53(1), 1–25." + url: https://doi.org/10.2113/0530001 + - id: dhz-zircon-2013 + citation: "Deer, W.A., Howie, R.A. & Zussman, J. (2013). Zircon Zr[SiO4]. In An Introduction to the Rock-Forming Minerals (3rd ed.). Mineralogical Society of Great Britain and Ireland." + url: https://doi.org/10.1180/dhz.2 diff --git a/src/hooks/useCaseRunner.test.ts b/src/hooks/useCaseRunner.test.ts new file mode 100644 index 0000000..994e498 --- /dev/null +++ b/src/hooks/useCaseRunner.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCaseRunner } from './useCaseRunner'; +import type { CaseDefinition } from '../lib/cases/case-types'; +import { CASE_STORAGE_KEY } from '../lib/cases/store'; + +function makeCase(overrides: Partial = {}): CaseDefinition { + return { + id: 'dealers-ruby', + title: "A Dealer's Ruby", + difficulty: 'foundation', + estimatedMinutes: 10, + backstory: 'A dealer brings in an unset red stone.', + specimenSummary: 'Transparent red stone, ~2ct, unset.', + groundTruth: { speciesFamilyId: 'corundum', treatment: 'heat' }, + steps: [ + { + id: 'step-1', + type: 'choose-next-test', + prompt: 'What do you test first?', + options: [ + { + id: 'ri-first', + text: 'Refractometer', + weight: 'optimal', + score: 10, + rationale: 'RI is fast and non-destructive.', + timeCost: 1, + revealsEvidenceIds: ['ri-reading'], + }, + { + id: 'hardness-first', + text: 'Hardness pick', + weight: 'poor', + score: 0, + rationale: 'Risks damaging the stone.', + timeCost: 3, + }, + ], + pointsMultiplier: 1, + evidenceRevealed: [ + { id: 'ri-reading', kind: 'ri', label: 'RI', value: '1.762-1.770' }, + ], + }, + { + id: 'step-2', + type: 'reading-interpretation', + prompt: 'What does the RI reading suggest?', + options: [ + { + id: 'corundum', + text: 'Corundum, not garnet or spinel', + weight: 'optimal', + score: 10, + rationale: 'Birefringence confirms uniaxial corundum.', + }, + { + id: 'garnet', + text: 'Garnet', + weight: 'poor', + score: 0, + rationale: 'Garnet is singly refractive; this is birefringent.', + }, + ], + pointsMultiplier: 2, + }, + { + id: 'step-3', + type: 'final-identification', + prompt: 'What is your final identification?', + options: [ + { + id: 'natural-ruby-heated', + text: 'Natural ruby (corundum), heat-treated', + weight: 'optimal', + score: 10, + rationale: 'Consistent with all evidence gathered.', + }, + { + id: 'spinel', + text: 'Red spinel', + weight: 'poor', + score: 0, + rationale: 'Spinel is singly refractive; ruled out by RI.', + }, + ], + pointsMultiplier: 2, + }, + ], + debrief: { + summary: 'The stone was a heat-treated natural ruby.', + expertPath: ['Checked RI', 'Checked SG', 'Noted silk halos', 'Confirmed ruby'], + }, + unvetted: false, + ...overrides, + }; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe('useCaseRunner — decision flow', () => { + it('starts at step 0, in-progress, with no decisions', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + expect(result.current.state.currentStepIndex).toBe(0); + expect(result.current.state.status).toBe('in-progress'); + expect(result.current.state.decisions).toHaveLength(0); + expect(result.current.currentStep?.id).toBe('step-1'); + }); + + it('submitDecision records a DecisionRecord and reveals evidence', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + + expect(result.current.isSubmitted).toBe(true); + expect(result.current.currentDecision).toMatchObject({ + stepId: 'step-1', + optionId: 'ri-first', + weight: 'optimal', + scoreAwarded: 10, + timeCostIncurred: 1, + }); + expect(result.current.revealedEvidence.map((e) => e.id)).toEqual(['ri-reading']); + }); + + it('does not allow selecting a new option after submission', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + act(() => result.current.selectOption('hardness-first')); + expect(result.current.selectedOptionId).toBe('ri-first'); + }); + + it('nextStep advances to the next step and resets submission state', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + act(() => result.current.nextStep()); + + expect(result.current.state.currentStepIndex).toBe(1); + expect(result.current.currentStep?.id).toBe('step-2'); + expect(result.current.isSubmitted).toBe(false); + expect(result.current.selectedOptionId).toBeNull(); + }); + + it('accumulates score across steps and completes after the final step', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + act(() => result.current.nextStep()); + + act(() => result.current.selectOption('corundum')); + act(() => result.current.submitDecision()); + act(() => result.current.nextStep()); + + act(() => result.current.selectOption('natural-ruby-heated')); + act(() => result.current.submitDecision()); + act(() => result.current.nextStep()); + + expect(result.current.isComplete).toBe(true); + expect(result.current.result).not.toBeNull(); + expect(result.current.result!.rawScore).toBe(10 + 20 + 20); + expect(result.current.result!.maxScore).toBe(10 + 20 + 20); + expect(result.current.result!.percentage).toBe(100); + }); + + it('resetCase clears decisions and returns to step 0', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: false })); + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + act(() => result.current.nextStep()); + + act(() => result.current.resetCase()); + + expect(result.current.state.currentStepIndex).toBe(0); + expect(result.current.state.decisions).toHaveLength(0); + expect(result.current.revealedEvidence).toHaveLength(0); + expect(result.current.isComplete).toBe(false); + expect(result.current.result).toBeNull(); + }); +}); + +describe('useCaseRunner — persistence / resume', () => { + it('persists progress to localStorage under CASE_STORAGE_KEY when persist is true', () => { + const { result } = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: true })); + act(() => result.current.selectOption('ri-first')); + act(() => result.current.submitDecision()); + + const raw = window.localStorage.getItem(CASE_STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.cases['dealers-ruby'].state.decisions).toHaveLength(1); + }); + + it('resumes an in-progress case from a fresh hook instance', () => { + const first = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: true })); + act(() => first.result.current.selectOption('ri-first')); + act(() => first.result.current.submitDecision()); + act(() => first.result.current.nextStep()); + + const second = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: true })); + expect(second.result.current.state.currentStepIndex).toBe(1); + expect(second.result.current.state.decisions).toHaveLength(1); + }); + + it('does not resume a completed case into an active session', () => { + const first = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: true })); + act(() => first.result.current.selectOption('ri-first')); + act(() => first.result.current.submitDecision()); + act(() => first.result.current.nextStep()); + act(() => first.result.current.selectOption('corundum')); + act(() => first.result.current.submitDecision()); + act(() => first.result.current.nextStep()); + act(() => first.result.current.selectOption('natural-ruby-heated')); + act(() => first.result.current.submitDecision()); + act(() => first.result.current.nextStep()); + expect(first.result.current.isComplete).toBe(true); + + // A fresh mount should surface the already-computed result, not silently + // resume "in-progress" (there is no more progress to resume). + const second = renderHook(() => useCaseRunner({ caseData: makeCase(), persist: true })); + expect(second.result.current.isComplete).toBe(true); + expect(second.result.current.result).not.toBeNull(); + }); +}); diff --git a/src/hooks/useCaseRunner.ts b/src/hooks/useCaseRunner.ts new file mode 100644 index 0000000..3aadbcb --- /dev/null +++ b/src/hooks/useCaseRunner.ts @@ -0,0 +1,216 @@ +/** + * React hook for running a single Lab Simulation case. + * Handles step navigation, decision recording, evidence reveal, scoring, + * and localStorage persistence via LocalCaseStore (mid-case resume). + * + * Modeled on useQuiz.ts's structure, but persistence is synchronous + * (LocalCaseStore, like Phase 1's LocalChallengeStore) rather than the + * async StudyStore used by the quiz system. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { + CaseDefinition, + CaseStep, + CaseRunnerState, + DecisionRecord, + EvidenceItem, + CaseResult, +} from '../lib/cases/case-types'; +import { scoreDecision, buildCaseResult } from '../lib/cases/scoring'; +import { getCaseStore } from '../lib/cases/store'; + +interface UseCaseRunnerOptions { + /** The case definition to run. */ + caseData: CaseDefinition; + /** Whether to persist state to localStorage (mid-case resume). Defaults to true. */ + persist?: boolean; +} + +interface UseCaseRunnerReturn { + /** Current runner state. */ + state: CaseRunnerState; + /** The current step, or `null` once complete. */ + currentStep: CaseStep | null; + /** Currently selected (not-yet-submitted) option id for the current step. */ + selectedOptionId: string | null; + /** Select an option for the current step (before submitting). */ + selectOption: (optionId: string) => void; + /** Whether the current step's decision has already been submitted. */ + isSubmitted: boolean; + /** Submit the selected option as the decision for the current step. */ + submitDecision: () => void; + /** The decision recorded for the current step, if submitted. */ + currentDecision: DecisionRecord | null; + /** Advance past the current (submitted) step, or complete the case from the last step. */ + nextStep: () => void; + /** Reset the case to a fresh, in-progress attempt (clears persisted state). */ + resetCase: () => void; + /** Whether the case run is complete. */ + isComplete: boolean; + /** Final result, once complete. */ + result: CaseResult | null; + /** Evidence accumulated so far, in reveal order. */ + revealedEvidence: EvidenceItem[]; +} + +function freshState(caseId: string): CaseRunnerState { + return { + caseId, + currentStepIndex: 0, + decisions: [], + revealedEvidence: [], + status: 'in-progress', + startedAt: Date.now(), + }; +} + +/** All evidence items defined anywhere in the case, keyed by id (for revealsEvidenceIds lookups). */ +function allEvidenceById(caseData: CaseDefinition): Map { + const map = new Map(); + for (const step of caseData.steps) { + for (const item of step.evidenceRevealed ?? []) { + map.set(item.id, item); + } + } + return map; +} + +export function useCaseRunner({ caseData, persist = true }: UseCaseRunnerOptions): UseCaseRunnerReturn { + const store = useRef(getCaseStore()).current; + const stepStartTime = useRef(Date.now()); + const evidenceById = useRef(allEvidenceById(caseData)).current; + + // Always initialize to the deterministic fresh state so server-rendered and + // first-client-render markup match; persisted state (mid-case resume or a + // completed case reopening to its debrief) hydrates in the mount effect + // below. Reading localStorage inside the useState initializer caused React + // hydration errors (#418/#423) on reload with saved progress. + const [state, setState] = useState(() => freshState(caseData.id)); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!persist) return; + const existing = store.getCaseState(caseData.id); + if (existing) { + setState(existing.state); + if (existing.result) setResult(existing.result); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [selectedOptionId, setSelectedOptionId] = useState(null); + // The stepId of the most recently submitted step. A step can only be + // submitted once — options are disabled thereafter, mirroring AnswerOption. + const [submittedStepId, setSubmittedStepId] = useState(null); + + const currentStep: CaseStep | null = caseData.steps[state.currentStepIndex] ?? null; + const isComplete = state.status === 'complete'; + const isSubmitted = currentStep !== null && submittedStepId === currentStep.id; + const currentDecision = currentStep + ? state.decisions.find((d) => d.stepId === currentStep.id) ?? null + : null; + + const selectOption = useCallback( + (optionId: string) => { + if (!currentStep || isSubmitted) return; + setSelectedOptionId(optionId); + }, + [currentStep, isSubmitted], + ); + + const submitDecision = useCallback(() => { + if (!currentStep || !selectedOptionId || isSubmitted) return; + const option = currentStep.options.find((o) => o.id === selectedOptionId); + if (!option) return; + + const decision: DecisionRecord = { + stepId: currentStep.id, + optionId: option.id, + weight: option.weight, + scoreAwarded: scoreDecision(option, currentStep), + timeCostIncurred: option.timeCost ?? 0, + timeMs: Date.now() - stepStartTime.current, + timestamp: Date.now(), + }; + + const revealed = [...state.revealedEvidence]; + const seen = new Set(revealed.map((e) => e.id)); + const addEvidence = (item: EvidenceItem) => { + if (!seen.has(item.id)) { + seen.add(item.id); + revealed.push(item); + } + }; + + (currentStep.evidenceRevealed ?? []).forEach(addEvidence); + (option.revealsEvidenceIds ?? []).forEach((id) => { + const item = evidenceById.get(id); + if (item) addEvidence(item); + }); + + const newState: CaseRunnerState = { + ...state, + decisions: [...state.decisions, decision], + revealedEvidence: revealed, + }; + + setState(newState); + setSubmittedStepId(currentStep.id); + if (persist) store.saveCaseState(caseData.id, newState); + }, [currentStep, selectedOptionId, isSubmitted, state, evidenceById, persist, store, caseData.id]); + + const nextStep = useCallback(() => { + if (!currentStep || !isSubmitted) return; + + const isLastStep = state.currentStepIndex >= caseData.steps.length - 1; + + if (!isLastStep) { + stepStartTime.current = Date.now(); + const newState: CaseRunnerState = { + ...state, + currentStepIndex: state.currentStepIndex + 1, + }; + setState(newState); + setSelectedOptionId(null); + setSubmittedStepId(null); + if (persist) store.saveCaseState(caseData.id, newState); + return; + } + + const completedAt = Date.now(); + const finalState: CaseRunnerState = { + ...state, + status: 'complete', + completedAt, + }; + const caseResult = buildCaseResult(caseData, finalState.decisions, completedAt); + setState(finalState); + setResult(caseResult); + if (persist) store.saveCaseState(caseData.id, finalState, caseResult); + }, [currentStep, isSubmitted, state, caseData, persist, store]); + + const resetCase = useCallback(() => { + if (persist) store.clearCase(caseData.id); + stepStartTime.current = Date.now(); + setState(freshState(caseData.id)); + setResult(null); + setSelectedOptionId(null); + setSubmittedStepId(null); + }, [persist, store, caseData.id]); + + return { + state, + currentStep, + selectedOptionId, + selectOption, + isSubmitted, + submitDecision, + currentDecision, + nextStep, + resetCase, + isComplete, + result, + revealedEvidence: state.revealedEvidence, + }; +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index ddb3095..9e99bd2 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -119,6 +119,12 @@ export function clearQuizStorage(): void { // Migration version stamp (gemmology-study-version) so a fresh start // re-runs migrations cleanly. 'gemmology-study-version', + // Study Challenge Tracks progress (must mirror CHALLENGE_STORAGE_KEY in + // src/lib/quiz/challenge-store.ts). + 'gemmology-challenge-progress', + // Lab Simulation case progress (must mirror CASE_STORAGE_KEY in + // src/lib/cases/store.ts). + 'gemmology-cases-v1', ]; keysToRemove.forEach(key => { diff --git a/src/hooks/useQuiz.ts b/src/hooks/useQuiz.ts index 7c28af1..e943e45 100644 --- a/src/hooks/useQuiz.ts +++ b/src/hooks/useQuiz.ts @@ -150,13 +150,17 @@ export function useQuiz({ const score = useMemo(() => { let correct = 0; for (const [questionId, answer] of state.answers) { + // Only submitted answers count toward the running score — fill-blank + // and matching register a selection before "Check Answer", and counting + // those early produced impossible live fractions like 2/1 (200%). + if (!submittedQuestions.has(questionId)) continue; const question = state.questions.find(q => q.id === questionId); if (question && checkAnswer(question, answer)) { correct++; } } return correct; - }, [state.questions, state.answers]); + }, [state.questions, state.answers, submittedQuestions]); // Calculate results when quiz is complete const results = useMemo(() => { diff --git a/src/lib/cases/case-types.ts b/src/lib/cases/case-types.ts new file mode 100644 index 0000000..b737b82 --- /dev/null +++ b/src/lib/cases/case-types.ts @@ -0,0 +1,143 @@ +/** + * Runtime types for Lab Simulation cases (Phase 3). + * + * These are plain TypeScript types (no `astro:content` import) so this + * module — and everything that builds on it (scoring, mapper, store, + * useCaseRunner) — can be unit-tested with plain fixtures, mirroring the + * src/lib/quiz/challenges.ts convention. + */ + +export type EvidenceKind = + | 'visual' + | 'ri' + | 'sg' + | 'birefringence' + | 'optic-character' + | 'pleochroism' + | 'spectroscope' + | 'uv-fluorescence' + | 'chelsea-filter' + | 'inclusion' + | 'hardness' + | 'other'; + +export interface EvidenceItem { + id: string; + kind: EvidenceKind; + label: string; + value: string; + detail?: string; + toolHref?: string; +} + +export type CaseOptionWeight = 'optimal' | 'acceptable' | 'poor'; + +export interface CaseCandidate { + familyId: string; + name: string; +} + +export interface CaseOption { + id: string; + text: string; + weight: CaseOptionWeight; + score: number; + rationale: string; + revealsEvidenceIds?: string[]; + candidatesAfter?: CaseCandidate[]; + timeCost?: number; +} + +export type CaseStepType = + | 'choose-next-test' + | 'reading-interpretation' + | 'candidate-narrowing' + | 'final-identification' + | 'treatment-call'; + +export interface CaseToolLink { + href: string; + label: string; +} + +export interface CaseStep { + id: string; + type: CaseStepType; + prompt: string; + evidenceRevealed?: EvidenceItem[]; + options: CaseOption[]; + pointsMultiplier: number; + learnLinks?: string[]; + toolLinks?: CaseToolLink[]; +} + +export interface CaseGroundTruth { + speciesFamilyId: string; + variety?: string; + treatment?: string; + originNote?: string; +} + +export interface CaseDebriefInfo { + summary: string; + expertPath: string[]; + furtherReading?: string[]; +} + +export interface CaseReference { + id: string; + citation: string; + url?: string; +} + +export interface CaseDefinition { + id: string; + title: string; + difficulty: 'foundation' | 'intermediate' | 'diploma'; + estimatedMinutes: number; + backstory: string; + specimenSummary: string; + groundTruth: CaseGroundTruth; + steps: CaseStep[]; + debrief: CaseDebriefInfo; + conceptTags?: string[]; + references?: CaseReference[]; + unvetted: boolean; +} + +/** One recorded decision within a case run. */ +export interface DecisionRecord { + stepId: string; + optionId: string; + weight: CaseOptionWeight; + scoreAwarded: number; + timeCostIncurred: number; + /** Wall-clock time (ms) spent on this step before submitting. */ + timeMs: number; + timestamp: number; +} + +export type CaseRunStatus = 'in-progress' | 'complete'; + +/** Persisted/in-memory state of a single case attempt. */ +export interface CaseRunnerState { + caseId: string; + currentStepIndex: number; + decisions: DecisionRecord[]; + revealedEvidence: EvidenceItem[]; + status: CaseRunStatus; + startedAt: number; + completedAt?: number; +} + +/** Final computed outcome of a completed case attempt. */ +export interface CaseResult { + caseId: string; + rawScore: number; + maxScore: number; + percentage: number; + efficiencyBonus: number; + grade: string; + decisions: DecisionRecord[]; + completedAt: number; +} diff --git a/src/lib/cases/index.ts b/src/lib/cases/index.ts new file mode 100644 index 0000000..0325cb4 --- /dev/null +++ b/src/lib/cases/index.ts @@ -0,0 +1,41 @@ +/** + * Barrel export for the Lab Simulation cases library (Phase 3). + * Mirrors the export-barrel convention in src/lib/quiz/index.ts. + */ + +export type { + EvidenceKind, + EvidenceItem, + CaseOptionWeight, + CaseCandidate, + CaseOption, + CaseStepType, + CaseToolLink, + CaseStep, + CaseGroundTruth, + CaseDebriefInfo, + CaseReference, + CaseDefinition, + DecisionRecord, + CaseRunStatus, + CaseRunnerState, + CaseResult, +} from './case-types'; + +export { + getGrade, + scoreDecision, + computeMaxScore, + computeRawScore, + computePercentage, + computeParTimeCost, + computeTotalTimeCost, + computeEfficiencyBonus, + buildCaseResult, +} from './scoring'; + +export type { CaseSource, CaseStepSource } from './mapper'; +export { mapCaseSource, mapCaseSources } from './mapper'; + +export type { CaseStoredEntry } from './store'; +export { CASE_STORAGE_KEY, LocalCaseStore, getCaseStore } from './store'; diff --git a/src/lib/cases/mapper.test.ts b/src/lib/cases/mapper.test.ts new file mode 100644 index 0000000..27af804 --- /dev/null +++ b/src/lib/cases/mapper.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { mapCaseSource, mapCaseSources, type CaseSource, type CaseStepSource } from './mapper'; +import type { CaseOption } from './case-types'; + +function makeOption(overrides: Partial = {}): CaseOption { + return { + id: 'opt-1', + text: 'Option text', + weight: 'optimal', + score: 10, + rationale: 'Because.', + ...overrides, + }; +} + +function makeStepSource(overrides: Partial = {}): CaseStepSource { + return { + id: 'step-1', + type: 'choose-next-test', + prompt: 'What next?', + options: [makeOption()], + ...overrides, + }; +} + +function makeSource(overrides: Partial = {}): CaseSource { + return { + id: 'my-case', + title: 'My Case', + difficulty: 'foundation', + estimatedMinutes: 8, + backstory: 'Backstory.', + specimenSummary: 'Summary.', + groundTruth: { speciesFamilyId: 'corundum' }, + steps: [ + makeStepSource({ id: 'step-1' }), + makeStepSource({ id: 'step-2', type: 'reading-interpretation' }), + makeStepSource({ id: 'step-3', type: 'final-identification' }), + ], + debrief: { summary: 'Debrief.', expertPath: ['A', 'B'] }, + ...overrides, + }; +} + +describe('mapCaseSource', () => { + it('maps a well-formed source into a CaseDefinition, defaulting pointsMultiplier to 1', () => { + const mapped = mapCaseSource(makeSource()); + expect(mapped).not.toBeNull(); + expect(mapped!.id).toBe('my-case'); + expect(mapped!.steps).toHaveLength(3); + expect(mapped!.steps[0].pointsMultiplier).toBe(1); + }); + + it('preserves an explicit pointsMultiplier rather than overriding it', () => { + const source = makeSource({ + steps: [ + makeStepSource({ id: 'step-1', pointsMultiplier: 3 }), + makeStepSource({ id: 'step-2', type: 'reading-interpretation' }), + makeStepSource({ id: 'step-3', type: 'final-identification' }), + ], + }); + const mapped = mapCaseSource(source); + expect(mapped!.steps[0].pointsMultiplier).toBe(3); + }); + + it('defaults unvetted to false when omitted', () => { + const mapped = mapCaseSource(makeSource({ unvetted: undefined })); + expect(mapped!.unvetted).toBe(false); + }); + + it('preserves unvetted: true', () => { + const mapped = mapCaseSource(makeSource({ unvetted: true })); + expect(mapped!.unvetted).toBe(true); + }); + + it('returns null when there are fewer than 3 steps', () => { + const source = makeSource({ + steps: [ + makeStepSource({ id: 'step-1' }), + makeStepSource({ id: 'step-2', type: 'final-identification' }), + ], + }); + expect(mapCaseSource(source)).toBeNull(); + }); + + it('returns null when there is no final-identification step', () => { + const source = makeSource({ + steps: [ + makeStepSource({ id: 'step-1' }), + makeStepSource({ id: 'step-2', type: 'reading-interpretation' }), + makeStepSource({ id: 'step-3', type: 'candidate-narrowing' }), + ], + }); + expect(mapCaseSource(source)).toBeNull(); + }); + + it('carries through evidenceRevealed, learnLinks, and toolLinks on a step', () => { + const source = makeSource({ + steps: [ + makeStepSource({ + id: 'step-1', + evidenceRevealed: [{ id: 'ev-1', kind: 'ri', label: 'RI', value: '1.762-1.770' }], + learnLinks: ['crystal-systems'], + toolLinks: [{ href: '/tools/measurement', label: 'RI tool' }], + }), + makeStepSource({ id: 'step-2', type: 'reading-interpretation' }), + makeStepSource({ id: 'step-3', type: 'final-identification' }), + ], + }); + const mapped = mapCaseSource(source)!; + expect(mapped.steps[0].evidenceRevealed).toEqual([ + { id: 'ev-1', kind: 'ri', label: 'RI', value: '1.762-1.770' }, + ]); + expect(mapped.steps[0].learnLinks).toEqual(['crystal-systems']); + expect(mapped.steps[0].toolLinks).toEqual([{ href: '/tools/measurement', label: 'RI tool' }]); + }); +}); + +describe('mapCaseSources', () => { + it('drops unmappable entries and keeps the rest', () => { + const good = makeSource({ id: 'good' }); + const bad = makeSource({ + id: 'bad', + steps: [makeStepSource({ id: 's1' }), makeStepSource({ id: 's2' })], + }); + const mapped = mapCaseSources([good, bad]); + expect(mapped.map((c) => c.id)).toEqual(['good']); + }); + + it('returns an empty array for an empty input', () => { + expect(mapCaseSources([])).toEqual([]); + }); +}); diff --git a/src/lib/cases/mapper.ts b/src/lib/cases/mapper.ts new file mode 100644 index 0000000..cb733d3 --- /dev/null +++ b/src/lib/cases/mapper.ts @@ -0,0 +1,106 @@ +/** + * Maps case content-collection entries (src/content/cases/**\/*.yaml, validated + * by the Zod schema in src/content/config.ts) into the runtime `CaseDefinition` + * shape consumed by useCaseRunner / CaseRunner. + * + * Pure module: no `astro:content` import, so it can be unit tested with plain + * fixture objects (mirrors src/lib/quiz/curated-questions.ts). The caller + * (src/pages/study/cases/[id].astro) calls `getCollection('cases')` and + * passes the mapped `.data` shape in. + * + * The content schema and the runtime type are structurally very close (the + * schema *is* the source of truth for shape), so this mapper is mostly a + * defensive pass-through: it re-applies the same defaults the Zod schema + * already applies (`pointsMultiplier` default 1, `unvetted` default false) + * so this module stays correct even if called with data that bypassed the + * schema (e.g. hand-built test fixtures). + */ + +import type { + CaseDefinition, + CaseStep, + CaseOption, + EvidenceItem, + CaseGroundTruth, + CaseDebriefInfo, + CaseReference, +} from './case-types'; + +/** Minimal shape of a single case content-collection entry. */ +export interface CaseSource { + id: string; + title: string; + difficulty: 'foundation' | 'intermediate' | 'diploma'; + estimatedMinutes: number; + backstory: string; + specimenSummary: string; + groundTruth: CaseGroundTruth; + steps: CaseStepSource[]; + debrief: CaseDebriefInfo; + conceptTags?: string[]; + references?: CaseReference[]; + unvetted?: boolean; +} + +export interface CaseStepSource { + id: string; + type: CaseStep['type']; + prompt: string; + evidenceRevealed?: EvidenceItem[]; + options: CaseOption[]; + pointsMultiplier?: number; + learnLinks?: string[]; + toolLinks?: CaseStep['toolLinks']; +} + +function mapStep(source: CaseStepSource): CaseStep { + return { + id: source.id, + type: source.type, + prompt: source.prompt, + evidenceRevealed: source.evidenceRevealed, + options: source.options, + pointsMultiplier: source.pointsMultiplier ?? 1, + learnLinks: source.learnLinks, + toolLinks: source.toolLinks, + }; +} + +/** + * Maps one case content-collection source entry into the runtime + * `CaseDefinition` shape. Returns `null` when the case is structurally + * unusable — fewer than 3 steps, or no `final-identification` step — so + * malformed content can never reach the runner UI. (`validate-cases.mjs` + * and the Zod schema should already have caught this at author time; this + * is a last-resort defensive check.) + */ +export function mapCaseSource(source: CaseSource): CaseDefinition | null { + if (!Array.isArray(source.steps) || source.steps.length < 3) return null; + const hasFinalIdentification = source.steps.some((s) => s.type === 'final-identification'); + if (!hasFinalIdentification) return null; + + return { + id: source.id, + title: source.title, + difficulty: source.difficulty, + estimatedMinutes: source.estimatedMinutes, + backstory: source.backstory, + specimenSummary: source.specimenSummary, + groundTruth: source.groundTruth, + steps: source.steps.map(mapStep), + debrief: source.debrief, + conceptTags: source.conceptTags, + references: source.references, + unvetted: source.unvetted ?? false, + }; +} + +/** Maps a list of case sources, dropping any that fail to map. */ +export function mapCaseSources(sources: CaseSource[]): CaseDefinition[] { + const mapped: CaseDefinition[] = []; + for (const source of sources) { + const caseDef = mapCaseSource(source); + if (caseDef) mapped.push(caseDef); + } + return mapped; +} diff --git a/src/lib/cases/scoring.test.ts b/src/lib/cases/scoring.test.ts new file mode 100644 index 0000000..06f611e --- /dev/null +++ b/src/lib/cases/scoring.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { + scoreDecision, + computeMaxScore, + computeRawScore, + computePercentage, + computeParTimeCost, + computeTotalTimeCost, + computeEfficiencyBonus, + buildCaseResult, + getGrade, +} from './scoring'; +import type { CaseDefinition, CaseOption, CaseStep, DecisionRecord } from './case-types'; + +function makeOption(overrides: Partial = {}): CaseOption { + return { + id: 'opt-optimal', + text: 'Do the optimal thing', + weight: 'optimal', + score: 10, + rationale: 'This is the best next test.', + ...overrides, + }; +} + +function makeStep(overrides: Partial = {}): CaseStep { + return { + id: 'step-1', + type: 'choose-next-test', + prompt: 'What do you test first?', + options: [ + makeOption({ id: 'opt-optimal', weight: 'optimal', score: 10, timeCost: 1 }), + makeOption({ id: 'opt-acceptable', weight: 'acceptable', score: 5, timeCost: 2 }), + makeOption({ id: 'opt-poor', weight: 'poor', score: 0, timeCost: 3 }), + ], + pointsMultiplier: 1, + ...overrides, + }; +} + +function makeCase(overrides: Partial = {}): CaseDefinition { + return { + id: 'test-case', + title: 'Test Case', + difficulty: 'foundation', + estimatedMinutes: 10, + backstory: 'A walk-in customer brings in a stone.', + specimenSummary: 'Red transparent stone, unset.', + groundTruth: { speciesFamilyId: 'corundum' }, + steps: [ + makeStep({ id: 'step-1' }), + makeStep({ + id: 'step-2', + type: 'reading-interpretation', + pointsMultiplier: 2, + }), + makeStep({ + id: 'step-3', + type: 'final-identification', + pointsMultiplier: 2, + }), + ], + debrief: { summary: 'It was ruby.', expertPath: ['Checked RI', 'Checked SG', 'Confirmed'] }, + unvetted: false, + ...overrides, + }; +} + +function makeDecision(overrides: Partial = {}): DecisionRecord { + return { + stepId: 'step-1', + optionId: 'opt-optimal', + weight: 'optimal', + scoreAwarded: 10, + timeCostIncurred: 1, + timeMs: 1000, + timestamp: Date.now(), + ...overrides, + }; +} + +describe('scoreDecision', () => { + it('multiplies option score by the step pointsMultiplier', () => { + const step = makeStep({ pointsMultiplier: 3 }); + const option = makeOption({ score: 5 }); + expect(scoreDecision(option, step)).toBe(15); + }); + + it('returns 0 for a poor (score 0) option regardless of multiplier', () => { + const step = makeStep({ pointsMultiplier: 2 }); + const option = makeOption({ score: 0, weight: 'poor' }); + expect(scoreDecision(option, step)).toBe(0); + }); +}); + +describe('computeMaxScore', () => { + it('sums the best-option score * multiplier per step', () => { + const caseDef = makeCase(); + // step-1: 10*1=10, step-2: 10*2=20, step-3: 10*2=20 => 50 + expect(computeMaxScore(caseDef)).toBe(50); + }); +}); + +describe('computeRawScore / computePercentage', () => { + it('sums scoreAwarded across decisions', () => { + const decisions = [ + makeDecision({ scoreAwarded: 10 }), + makeDecision({ scoreAwarded: 5 }), + ]; + expect(computeRawScore(decisions)).toBe(15); + }); + + it('rounds and clamps percentage into [0, 100]', () => { + expect(computePercentage(1, 3)).toBe(33); + expect(computePercentage(2, 3)).toBe(67); + expect(computePercentage(0, 0)).toBe(0); + expect(computePercentage(100, 50)).toBe(100); // clamped even if raw > max somehow + expect(computePercentage(-5, 50)).toBe(0); + }); +}); + +describe('computeParTimeCost', () => { + it('sums the minimum optimal timeCost across choose-next-test steps only', () => { + const caseDef = makeCase({ + steps: [ + makeStep({ + id: 'step-1', + type: 'choose-next-test', + options: [ + makeOption({ id: 'a', weight: 'optimal', timeCost: 2 }), + makeOption({ id: 'b', weight: 'optimal', timeCost: 1 }), + makeOption({ id: 'c', weight: 'poor', timeCost: 0 }), + ], + }), + makeStep({ id: 'step-2', type: 'reading-interpretation' }), + makeStep({ id: 'step-3', type: 'final-identification' }), + ], + }); + // Only step-1 counts (choose-next-test); min optimal timeCost among a,b is 1. + expect(computeParTimeCost(caseDef)).toBe(1); + }); + + it('treats a missing timeCost on the optimal option as 0', () => { + const caseDef = makeCase({ + steps: [ + makeStep({ + id: 'step-1', + type: 'choose-next-test', + options: [makeOption({ id: 'a', weight: 'optimal', timeCost: undefined })], + }), + makeStep({ id: 'step-2', type: 'reading-interpretation' }), + makeStep({ id: 'step-3', type: 'final-identification' }), + ], + }); + expect(computeParTimeCost(caseDef)).toBe(0); + }); +}); + +describe('computeTotalTimeCost', () => { + it('sums timeCostIncurred across decisions', () => { + const decisions = [ + makeDecision({ timeCostIncurred: 1 }), + makeDecision({ timeCostIncurred: 2 }), + ]; + expect(computeTotalTimeCost(decisions)).toBe(3); + }); +}); + +describe('computeEfficiencyBonus', () => { + it('awards the full formula value when under the cap', () => { + const caseDef = makeCase(); // maxScore 50, cap round(50*0.1)=5 + const decisions = [makeDecision({ timeCostIncurred: 0 })]; + // parTimeCost for makeCase's step-1 optimal option timeCost=1 => par=1 + // raw = max(0, 1+1-0)*2 = 4, cap=5 => bonus=4 + expect(computeEfficiencyBonus(caseDef, decisions)).toBe(4); + }); + + it('is capped at round(maxScore * 0.1)', () => { + const caseDef = makeCase(); + // Zero time cost incurred at all -> raw = (1+1-0)*2 = 4, still under cap 5. + // Force an extreme case: no decisions at all -> totalTimeCost 0, same as above. + const decisions: DecisionRecord[] = []; + const bonus = computeEfficiencyBonus(caseDef, decisions); + const cap = Math.round(computeMaxScore(caseDef) * 0.1); + expect(bonus).toBeLessThanOrEqual(cap); + }); + + it('is 0 (not negative) when total time cost far exceeds par', () => { + const caseDef = makeCase(); + const decisions = [makeDecision({ timeCostIncurred: 50 })]; + expect(computeEfficiencyBonus(caseDef, decisions)).toBe(0); + }); +}); + +describe('buildCaseResult', () => { + it('assembles rawScore, maxScore, percentage, efficiencyBonus, grade, and echoes decisions', () => { + const caseDef = makeCase(); + const decisions = [ + makeDecision({ stepId: 'step-1', scoreAwarded: 10, timeCostIncurred: 1 }), + makeDecision({ stepId: 'step-2', scoreAwarded: 20, timeCostIncurred: 0 }), + makeDecision({ stepId: 'step-3', scoreAwarded: 20, timeCostIncurred: 0 }), + ]; + const completedAt = Date.now(); + const result = buildCaseResult(caseDef, decisions, completedAt); + + expect(result.caseId).toBe('test-case'); + expect(result.rawScore).toBe(50); + expect(result.maxScore).toBe(50); + expect(result.percentage).toBe(100); + expect(result.grade).toBe(getGrade(100)); + expect(result.decisions).toBe(decisions); + expect(result.completedAt).toBe(completedAt); + expect(result.efficiencyBonus).toBeGreaterThanOrEqual(0); + }); + + it('grade reuses the quiz scoring A-F bands', () => { + expect(getGrade(95)).toBe('A'); + expect(getGrade(85)).toBe('B'); + expect(getGrade(75)).toBe('C'); + expect(getGrade(65)).toBe('D'); + expect(getGrade(40)).toBe('F'); + }); +}); diff --git a/src/lib/cases/scoring.ts b/src/lib/cases/scoring.ts new file mode 100644 index 0000000..a2bc8c7 --- /dev/null +++ b/src/lib/cases/scoring.ts @@ -0,0 +1,107 @@ +/** + * Scoring for Lab Simulation cases. + * + * - Each decision's raw points are `option.score * step.pointsMultiplier`. + * - `maxScore` is the sum, per step, of the best possible + * `option.score * step.pointsMultiplier` — i.e. what a perfect run scores. + * - `percentage` is `round(rawScore / maxScore * 100)`, clamped to [0, 100]. + * - `grade` reuses `getGrade` from the quiz scoring module (same A–F bands). + * - `efficiencyBonus` rewards resolving the case at or under a computed par + * time cost; it is always reported separately and never folded into the + * percentage. + */ + +import { getGrade } from '../quiz/scoring'; +import type { CaseDefinition, CaseOption, CaseStep, DecisionRecord, CaseResult } from './case-types'; + +export { getGrade }; + +/** Points awarded for choosing `option` on `step`. */ +export function scoreDecision(option: CaseOption, step: CaseStep): number { + return option.score * step.pointsMultiplier; +} + +/** Sum, per step, of the best available `score * pointsMultiplier`. */ +export function computeMaxScore(caseDef: CaseDefinition): number { + return caseDef.steps.reduce((total, step) => { + const best = step.options.reduce((max, option) => Math.max(max, option.score), 0); + return total + best * step.pointsMultiplier; + }, 0); +} + +/** Sum of points actually awarded across all recorded decisions. */ +export function computeRawScore(decisions: DecisionRecord[]): number { + return decisions.reduce((total, decision) => total + decision.scoreAwarded, 0); +} + +/** Clamped, rounded percentage. Returns 0 when `maxScore` is 0 (defensive; schema guarantees steps.length >= 3). */ +export function computePercentage(rawScore: number, maxScore: number): number { + if (maxScore <= 0) return 0; + const pct = Math.round((rawScore / maxScore) * 100); + return Math.min(100, Math.max(0, pct)); +} + +/** + * The "par" time cost for a case: the sum, over every `choose-next-test` + * step, of the minimum `timeCost` among that step's `optimal`-weight + * options. This is *computed* from the authored data, never itself + * authored, so it always stays in sync with whatever options a case + * actually offers. + * + * A step with no optimal option (schema requires >= 1, so this is + * defensive only) or where the optimal option(s) omit `timeCost` + * contributes 0. + */ +export function computeParTimeCost(caseDef: CaseDefinition): number { + return caseDef.steps.reduce((total, step) => { + if (step.type !== 'choose-next-test') return total; + const optimalCosts = step.options + .filter((option) => option.weight === 'optimal') + .map((option) => option.timeCost ?? 0); + if (optimalCosts.length === 0) return total; + return total + Math.min(...optimalCosts); + }, 0); +} + +/** Total time cost actually incurred across recorded decisions. */ +export function computeTotalTimeCost(decisions: DecisionRecord[]): number { + return decisions.reduce((total, decision) => total + decision.timeCostIncurred, 0); +} + +/** + * Efficiency bonus: `max(0, parTimeCost + 1 - totalTimeCost) * 2`, capped at + * `round(maxScore * 0.1)`. The `+ 1` grants a one-unit grace margin over par + * before the bonus starts shrinking, so an exactly-par run still earns a + * bonus rather than zero. + */ +export function computeEfficiencyBonus(caseDef: CaseDefinition, decisions: DecisionRecord[]): number { + const parTimeCost = computeParTimeCost(caseDef); + const totalTimeCost = computeTotalTimeCost(decisions); + const raw = Math.max(0, parTimeCost + 1 - totalTimeCost) * 2; + const maxScore = computeMaxScore(caseDef); + const cap = Math.round(maxScore * 0.1); + return Math.min(raw, cap); +} + +/** Build the final result summary for a completed case run. */ +export function buildCaseResult( + caseDef: CaseDefinition, + decisions: DecisionRecord[], + completedAt: number, +): CaseResult { + const rawScore = computeRawScore(decisions); + const maxScore = computeMaxScore(caseDef); + const percentage = computePercentage(rawScore, maxScore); + const efficiencyBonus = computeEfficiencyBonus(caseDef, decisions); + + return { + caseId: caseDef.id, + rawScore, + maxScore, + percentage, + efficiencyBonus, + grade: getGrade(percentage), + decisions, + completedAt, + }; +} diff --git a/src/lib/cases/store.test.ts b/src/lib/cases/store.test.ts new file mode 100644 index 0000000..521f11b --- /dev/null +++ b/src/lib/cases/store.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for LocalCaseStore. + * Runs in a Node.js/jsdom environment with a Map-backed localStorage mock + * (same pattern as src/lib/quiz/challenge-store.test.ts and + * src/lib/quiz/store/local.test.ts). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { LocalCaseStore, getCaseStore, CASE_STORAGE_KEY } from './store'; +import type { CaseRunnerState, CaseResult } from './case-types'; + +class LocalStorageMock implements Storage { + private store: Map = new Map(); + get length() { return this.store.size; } + clear() { this.store.clear(); } + getItem(key: string) { return this.store.get(key) ?? null; } + setItem(key: string, value: string) { this.store.set(key, value); } + removeItem(key: string) { this.store.delete(key); } + key(index: number) { return [...this.store.keys()][index] ?? null; } +} + +let mockStorage: LocalStorageMock; + +beforeEach(() => { + mockStorage = new LocalStorageMock(); + Object.defineProperty(globalThis, 'localStorage', { + value: mockStorage, + writable: true, + configurable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeState(overrides: Partial = {}): CaseRunnerState { + return { + caseId: 'case-1', + currentStepIndex: 1, + decisions: [ + { + stepId: 'step-1', + optionId: 'opt-1', + weight: 'optimal', + scoreAwarded: 10, + timeCostIncurred: 1, + timeMs: 500, + timestamp: Date.now(), + }, + ], + revealedEvidence: [], + status: 'in-progress', + startedAt: Date.now(), + ...overrides, + }; +} + +function makeResult(overrides: Partial = {}): CaseResult { + return { + caseId: 'case-1', + rawScore: 40, + maxScore: 50, + percentage: 80, + efficiencyBonus: 2, + grade: 'B', + decisions: [], + completedAt: Date.now(), + ...overrides, + }; +} + +describe('getCaseState', () => { + it('returns null for a case with no recorded attempt', () => { + const store = new LocalCaseStore(); + expect(store.getCaseState('unknown-case')).toBeNull(); + }); +}); + +describe('saveCaseState / resume', () => { + it('persists and returns an in-progress state (mid-case resume)', () => { + const store = new LocalCaseStore(); + const state = makeState(); + store.saveCaseState('case-1', state); + + const entry = store.getCaseState('case-1'); + expect(entry).not.toBeNull(); + expect(entry!.state).toEqual(state); + expect(entry!.result).toBeUndefined(); + }); + + it('persists a completed state alongside its result', () => { + const store = new LocalCaseStore(); + const state = makeState({ status: 'complete', completedAt: Date.now() }); + const result = makeResult(); + store.saveCaseState('case-1', state, result); + + const entry = store.getCaseState('case-1'); + expect(entry!.state.status).toBe('complete'); + expect(entry!.result).toEqual(result); + }); + + it('overwrites the previous entry for the same caseId', () => { + const store = new LocalCaseStore(); + store.saveCaseState('case-1', makeState({ currentStepIndex: 0 })); + store.saveCaseState('case-1', makeState({ currentStepIndex: 2 })); + + expect(store.getCaseState('case-1')!.state.currentStepIndex).toBe(2); + }); + + it('keeps separate cases independent in getAllCaseSummaries', () => { + const store = new LocalCaseStore(); + store.saveCaseState('case-1', makeState({ caseId: 'case-1' })); + store.saveCaseState('case-2', makeState({ caseId: 'case-2' })); + + const all = store.getAllCaseSummaries(); + expect(Object.keys(all).sort()).toEqual(['case-1', 'case-2']); + }); + + it('persists across store instances via localStorage', () => { + const storeA = new LocalCaseStore(); + storeA.saveCaseState('case-1', makeState()); + + const storeB = new LocalCaseStore(); + expect(storeB.getCaseState('case-1')!.state.caseId).toBe('case-1'); + }); +}); + +describe('clearCase', () => { + it('removes only the targeted case', () => { + const store = new LocalCaseStore(); + store.saveCaseState('case-1', makeState({ caseId: 'case-1' })); + store.saveCaseState('case-2', makeState({ caseId: 'case-2' })); + + store.clearCase('case-1'); + + expect(store.getCaseState('case-1')).toBeNull(); + expect(store.getCaseState('case-2')).not.toBeNull(); + }); + + it('does not throw when clearing a case with no recorded state', () => { + const store = new LocalCaseStore(); + expect(() => store.clearCase('never-started')).not.toThrow(); + }); +}); + +describe('resilience to corrupted storage', () => { + it('falls back to an empty envelope when the stored value is invalid JSON', () => { + mockStorage.setItem(CASE_STORAGE_KEY, 'not-json{{{'); + const store = new LocalCaseStore(); + expect(store.getCaseState('case-1')).toBeNull(); + }); + + it('falls back to an empty envelope when the stored value has the wrong shape', () => { + mockStorage.setItem(CASE_STORAGE_KEY, JSON.stringify({ unexpected: true })); + const store = new LocalCaseStore(); + expect(store.getAllCaseSummaries()).toEqual({}); + }); + + it('does not throw when localStorage.setItem throws (e.g. quota exceeded)', () => { + const store = new LocalCaseStore(); + const setItemSpy = vi.spyOn(mockStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(() => store.saveCaseState('case-1', makeState())).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + setItemSpy.mockRestore(); + }); +}); + +describe('getCaseStore', () => { + it('returns the same singleton instance across calls', () => { + expect(getCaseStore()).toBe(getCaseStore()); + }); +}); diff --git a/src/lib/cases/store.ts b/src/lib/cases/store.ts new file mode 100644 index 0000000..10dbfaa --- /dev/null +++ b/src/lib/cases/store.ts @@ -0,0 +1,102 @@ +/** + * LocalCaseStore — localStorage-backed persistence for Lab Simulation case + * runs (Phase 3). + * + * Mirrors the defensive read/write try/catch style and synchronous API of + * src/lib/quiz/challenge-store.ts (LocalChallengeStore): a small, + * self-contained store rather than an implementation of the async + * `StudyStore` interface, since case runs are tracked per-case rather than + * per-question. + */ + +import type { CaseRunnerState, CaseResult } from './case-types'; + +export const CASE_STORAGE_KEY = 'gemmology-cases-v1'; + +/** One case's persisted entry: its runner state, plus a result once complete. */ +export interface CaseStoredEntry { + state: CaseRunnerState; + result?: CaseResult; +} + +interface CaseStoreEnvelope { + version: 1; + cases: Record; +} + +function emptyEnvelope(): CaseStoreEnvelope { + return { version: 1, cases: {} }; +} + +function isEnvelope(v: unknown): v is CaseStoreEnvelope { + return ( + typeof v === 'object' && + v !== null && + (v as CaseStoreEnvelope).version === 1 && + typeof (v as CaseStoreEnvelope).cases === 'object' && + (v as CaseStoreEnvelope).cases !== null + ); +} + +export class LocalCaseStore { + /** The stored entry for a case, or `null` if no attempt has been recorded yet. */ + getCaseState(caseId: string): CaseStoredEntry | null { + const envelope = this._read(); + return envelope.cases[caseId] ?? null; + } + + /** All recorded case entries, keyed by caseId — used by the hub for summary cards. */ + getAllCaseSummaries(): Record { + return this._read().cases; + } + + /** + * Persist the runner state for a case, optionally alongside its final + * result (present once `state.status === 'complete'`). Overwrites any + * previous entry for this caseId. + */ + saveCaseState(caseId: string, state: CaseRunnerState, result?: CaseResult): CaseStoredEntry { + const envelope = this._read(); + const entry: CaseStoredEntry = result ? { state, result } : { state }; + envelope.cases[caseId] = entry; + this._write(envelope); + return entry; + } + + /** Remove all persisted progress for a single case (used by "Restart case"). */ + clearCase(caseId: string): void { + const envelope = this._read(); + if (!(caseId in envelope.cases)) return; + delete envelope.cases[caseId]; + this._write(envelope); + } + + private _read(): CaseStoreEnvelope { + try { + if (typeof localStorage === 'undefined') return emptyEnvelope(); + const raw = localStorage.getItem(CASE_STORAGE_KEY); + if (raw === null) return emptyEnvelope(); + const parsed: unknown = JSON.parse(raw); + return isEnvelope(parsed) ? parsed : emptyEnvelope(); + } catch { + return emptyEnvelope(); + } + } + + private _write(envelope: CaseStoreEnvelope): void { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(CASE_STORAGE_KEY, JSON.stringify(envelope)); + } catch (err) { + console.warn('[case-store] write failed:', err); + } + } +} + +let _singleton: LocalCaseStore | null = null; + +/** Lazy singleton. Call this from components/hooks rather than constructing directly. */ +export function getCaseStore(): LocalCaseStore { + if (_singleton === null) _singleton = new LocalCaseStore(); + return _singleton; +} diff --git a/src/lib/quiz/challenge-store.test.ts b/src/lib/quiz/challenge-store.test.ts new file mode 100644 index 0000000..b8d5fb3 --- /dev/null +++ b/src/lib/quiz/challenge-store.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for LocalChallengeStore. + * Runs in a Node.js/jsdom environment with a Map-backed localStorage mock + * (same pattern as src/lib/quiz/store/local.test.ts). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { LocalChallengeStore, getChallengeStore, CHALLENGE_STORAGE_KEY } from './challenge-store'; + +class LocalStorageMock implements Storage { + private store: Map = new Map(); + get length() { return this.store.size; } + clear() { this.store.clear(); } + getItem(key: string) { return this.store.get(key) ?? null; } + setItem(key: string, value: string) { this.store.set(key, value); } + removeItem(key: string) { this.store.delete(key); } + key(index: number) { return [...this.store.keys()][index] ?? null; } +} + +let mockStorage: LocalStorageMock; + +beforeEach(() => { + mockStorage = new LocalStorageMock(); + Object.defineProperty(globalThis, 'localStorage', { + value: mockStorage, + writable: true, + configurable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getProgress', () => { + it('returns a fresh empty progress for a track with no recorded attempts', () => { + const store = new LocalChallengeStore(); + const progress = store.getProgress('track-1'); + expect(progress).toEqual({ trackId: 'track-1', stages: {} }); + }); +}); + +describe('recordStageResult', () => { + it('records the first attempt with attempts=1 and the correct bestScore/passed', () => { + const store = new LocalChallengeStore(); + const result = store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, ['stage-1']); + + expect(result.stages['stage-1']).toMatchObject({ + stageId: 'stage-1', + attempts: 1, + bestScore: 0.8, + passed: true, + }); + expect(result.stages['stage-1'].lastAttemptAt).toBeGreaterThan(0); + }); + + it('increments attempts on every subsequent call', () => { + const store = new LocalChallengeStore(); + store.recordStageResult('track-1', 'stage-1', 5, 10, 0.7, ['stage-1']); + store.recordStageResult('track-1', 'stage-1', 6, 10, 0.7, ['stage-1']); + const result = store.recordStageResult('track-1', 'stage-1', 7, 10, 0.7, ['stage-1']); + + expect(result.stages['stage-1'].attempts).toBe(3); + }); + + it('bestScore only ever improves, never regresses on a worse later attempt', () => { + const store = new LocalChallengeStore(); + store.recordStageResult('track-1', 'stage-1', 9, 10, 0.7, ['stage-1']); + const result = store.recordStageResult('track-1', 'stage-1', 3, 10, 0.7, ['stage-1']); + + expect(result.stages['stage-1'].bestScore).toBe(0.9); + }); + + it('passed is sticky: a later attempt below threshold does not un-pass the stage', () => { + const store = new LocalChallengeStore(); + store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, ['stage-1']); + const result = store.recordStageResult('track-1', 'stage-1', 2, 10, 0.7, ['stage-1']); + + expect(result.stages['stage-1'].passed).toBe(true); + }); + + it('does not mark passed on a first attempt below threshold', () => { + const store = new LocalChallengeStore(); + const result = store.recordStageResult('track-1', 'stage-1', 5, 10, 0.7, ['stage-1']); + expect(result.stages['stage-1'].passed).toBe(false); + }); + + it('sets completedAt only once every stage id in allStageIds has passed', () => { + const store = new LocalChallengeStore(); + const allStageIds = ['stage-1', 'stage-2']; + + let result = store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, allStageIds); + expect(result.completedAt).toBeUndefined(); + + result = store.recordStageResult('track-1', 'stage-2', 3, 10, 0.7, allStageIds); + expect(result.completedAt).toBeUndefined(); + + result = store.recordStageResult('track-1', 'stage-2', 9, 10, 0.7, allStageIds); + expect(result.completedAt).toBeGreaterThan(0); + }); + + it('completedAt is sticky once set, even if a stage is later retried', () => { + const store = new LocalChallengeStore(); + const allStageIds = ['stage-1']; + const first = store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, allStageIds); + const completedAt = first.completedAt; + expect(completedAt).toBeDefined(); + + const second = store.recordStageResult('track-1', 'stage-1', 9, 10, 0.7, allStageIds); + expect(second.completedAt).toBe(completedAt); + }); + + it('persists progress across store instances via localStorage', () => { + const storeA = new LocalChallengeStore(); + storeA.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, ['stage-1']); + + const storeB = new LocalChallengeStore(); + const progress = storeB.getProgress('track-1'); + expect(progress.stages['stage-1'].bestScore).toBe(0.8); + }); + + it('keeps separate tracks independent in getAllProgress', () => { + const store = new LocalChallengeStore(); + store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, ['stage-1']); + store.recordStageResult('track-2', 'stage-1', 2, 10, 0.7, ['stage-1']); + + const all = store.getAllProgress(); + expect(Object.keys(all).sort()).toEqual(['track-1', 'track-2']); + expect(all['track-1'].stages['stage-1'].passed).toBe(true); + expect(all['track-2'].stages['stage-1'].passed).toBe(false); + }); +}); + +describe('resilience to corrupted storage', () => { + it('falls back to an empty envelope when the stored value is invalid JSON', () => { + mockStorage.setItem(CHALLENGE_STORAGE_KEY, 'not-json{{{'); + const store = new LocalChallengeStore(); + expect(store.getProgress('track-1')).toEqual({ trackId: 'track-1', stages: {} }); + }); + + it('falls back to an empty envelope when the stored value has the wrong shape', () => { + mockStorage.setItem(CHALLENGE_STORAGE_KEY, JSON.stringify({ unexpected: true })); + const store = new LocalChallengeStore(); + expect(store.getAllProgress()).toEqual({}); + }); + + it('does not throw when localStorage.setItem throws (e.g. quota exceeded)', () => { + const store = new LocalChallengeStore(); + const setItemSpy = vi.spyOn(mockStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(() => + store.recordStageResult('track-1', 'stage-1', 8, 10, 0.7, ['stage-1']), + ).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + setItemSpy.mockRestore(); + }); +}); + +describe('getChallengeStore', () => { + it('returns the same singleton instance across calls', () => { + expect(getChallengeStore()).toBe(getChallengeStore()); + }); +}); diff --git a/src/lib/quiz/challenge-store.ts b/src/lib/quiz/challenge-store.ts new file mode 100644 index 0000000..ff39552 --- /dev/null +++ b/src/lib/quiz/challenge-store.ts @@ -0,0 +1,146 @@ +/** + * LocalChallengeStore — localStorage-backed progress tracking for Study + * Challenge Tracks (Phase 1). + * + * Mirrors the defensive read/write try/catch style of + * src/lib/quiz/store/local.ts (LocalStudyStore), but is a smaller, + * self-contained store: it does not implement the `StudyStore` interface, + * since challenge progress is tracked per stage/track rather than + * per-question. + */ + +export const CHALLENGE_STORAGE_KEY = 'gemmology-challenge-progress'; + +export interface ChallengeStageResult { + stageId: string; + /** Number of times this stage has been attempted (submitted). */ + attempts: number; + /** Best score achieved so far, 0..1. Only ever improves. */ + bestScore: number; + /** Sticky once true — a later, worse attempt never un-passes a stage. */ + passed: boolean; + lastAttemptAt: number; +} + +export interface ChallengeProgress { + trackId: string; + stages: Record; + /** Set the first time every stage id in the track has `passed: true`. Sticky thereafter. */ + completedAt?: number; +} + +interface ChallengeProgressEnvelope { + version: 1; + tracks: Record; +} + +function emptyEnvelope(): ChallengeProgressEnvelope { + return { version: 1, tracks: {} }; +} + +function emptyProgress(trackId: string): ChallengeProgress { + return { trackId, stages: {} }; +} + +function isEnvelope(v: unknown): v is ChallengeProgressEnvelope { + return ( + typeof v === 'object' && + v !== null && + (v as ChallengeProgressEnvelope).version === 1 && + typeof (v as ChallengeProgressEnvelope).tracks === 'object' && + (v as ChallengeProgressEnvelope).tracks !== null + ); +} + +export class LocalChallengeStore { + /** Progress for a single track. Returns a fresh empty progress if none is recorded yet. */ + getProgress(trackId: string): ChallengeProgress { + const envelope = this._read(); + return envelope.tracks[trackId] ?? emptyProgress(trackId); + } + + /** All recorded track progress, keyed by trackId — used by the hub for summary cards. */ + getAllProgress(): Record { + return this._read().tracks; + } + + /** + * Record the result of a stage attempt. + * + * - `attempts` increments on every call. + * - `bestScore` only ever improves (max of previous and this attempt). + * - `passed` is sticky: once true, it never reverts to false even if a + * later attempt scores below `passThreshold`. + * - `completedAt` is set (once, and only once) the first time every id in + * `allStageIds` has `passed: true`; it is never cleared afterwards. + */ + recordStageResult( + trackId: string, + stageId: string, + correct: number, + total: number, + passThreshold: number, + allStageIds: string[], + ): ChallengeProgress { + const envelope = this._read(); + const existing = envelope.tracks[trackId] ?? emptyProgress(trackId); + const score = total > 0 ? correct / total : 0; + const passedThisAttempt = total > 0 && score >= passThreshold; + + const previous = existing.stages[stageId]; + const nextResult: ChallengeStageResult = { + stageId, + attempts: (previous?.attempts ?? 0) + 1, + bestScore: Math.max(previous?.bestScore ?? 0, score), + passed: (previous?.passed ?? false) || passedThisAttempt, + lastAttemptAt: Date.now(), + }; + + const updatedStages: Record = { + ...existing.stages, + [stageId]: nextResult, + }; + + const allPassed = + allStageIds.length > 0 && allStageIds.every(id => updatedStages[id]?.passed === true); + + const updated: ChallengeProgress = { + trackId, + stages: updatedStages, + completedAt: allPassed ? existing.completedAt ?? Date.now() : existing.completedAt, + }; + + envelope.tracks[trackId] = updated; + this._write(envelope); + return updated; + } + + private _read(): ChallengeProgressEnvelope { + try { + if (typeof localStorage === 'undefined') return emptyEnvelope(); + const raw = localStorage.getItem(CHALLENGE_STORAGE_KEY); + if (raw === null) return emptyEnvelope(); + const parsed: unknown = JSON.parse(raw); + return isEnvelope(parsed) ? parsed : emptyEnvelope(); + } catch { + return emptyEnvelope(); + } + } + + private _write(envelope: ChallengeProgressEnvelope): void { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(CHALLENGE_STORAGE_KEY, JSON.stringify(envelope)); + } catch (err) { + console.warn('[challenge-store] write failed:', err); + } + } +} + +let _singleton: LocalChallengeStore | null = null; + +/** Lazy singleton. Call this from components rather than constructing directly. */ +export function getChallengeStore(): LocalChallengeStore { + if (_singleton === null) _singleton = new LocalChallengeStore(); + return _singleton; +} diff --git a/src/lib/quiz/challenges.test.ts b/src/lib/quiz/challenges.test.ts new file mode 100644 index 0000000..29e3fbe --- /dev/null +++ b/src/lib/quiz/challenges.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + resolveStageQuestions, + resolveTrackStages, + trackStagePassed, + type ChallengeStageSource, + type ChallengeTrackSource, +} from './challenges'; +import type { Question } from './question-types'; + +function makeQuestion(overrides: Partial = {}): Question { + return { + id: 'q-1', + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: 'Which system has three equal axes at 90 degrees?', + options: ['Cubic', 'Trigonal', 'Monoclinic'], + correctAnswer: 'Cubic', + ...overrides, + }; +} + +function makeStage(overrides: Partial = {}): ChallengeStageSource { + return { + id: 'stage-1', + title: 'Stage One', + tagMatch: 'all', + passThreshold: 0.7, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('resolveStageQuestions — explicit questionIds', () => { + it('preserves the order given in questionIds, independent of pool order', () => { + const pool = [ + makeQuestion({ id: 'a' }), + makeQuestion({ id: 'b' }), + makeQuestion({ id: 'c' }), + ]; + const stage = makeStage({ questionIds: ['c', 'a', 'b'] }); + const resolved = resolveStageQuestions(stage, pool); + expect(resolved.map(q => q.id)).toEqual(['c', 'a', 'b']); + }); + + it('skips missing ids and warns, without throwing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pool = [makeQuestion({ id: 'a' })]; + const stage = makeStage({ questionIds: ['a', 'missing-id'] }); + + const resolved = resolveStageQuestions(stage, pool); + + expect(resolved.map(q => q.id)).toEqual(['a']); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('missing-id'), + ); + }); + + it('returns an empty array (not throwing) when every id is missing', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pool = [makeQuestion({ id: 'a' })]; + const stage = makeStage({ questionIds: ['nope'] }); + expect(resolveStageQuestions(stage, pool)).toEqual([]); + }); +}); + +describe('resolveStageQuestions — tag fallback', () => { + const pool = [ + makeQuestion({ id: 'z', conceptTags: ['ri', 'sg'] }), + makeQuestion({ id: 'a', conceptTags: ['ri'] }), + makeQuestion({ id: 'm', conceptTags: ['ri', 'sg', 'fluorescence'] }), + makeQuestion({ id: 'n', conceptTags: ['unrelated'] }), + ]; + + it('tagMatch "all" requires every tag to be present', () => { + const stage = makeStage({ conceptTags: ['ri', 'sg'], tagMatch: 'all', count: 10 }); + const resolved = resolveStageQuestions(stage, pool); + expect(resolved.map(q => q.id).sort()).toEqual(['m', 'z']); + }); + + it('tagMatch "any" matches if at least one tag is present', () => { + const stage = makeStage({ conceptTags: ['fluorescence', 'sg'], tagMatch: 'any', count: 10 }); + const resolved = resolveStageQuestions(stage, pool); + expect(resolved.map(q => q.id).sort()).toEqual(['m', 'z']); + }); + + it('sorts matches deterministically by id before truncating to count', () => { + const stage = makeStage({ conceptTags: ['ri'], tagMatch: 'any', count: 2 }); + const resolved = resolveStageQuestions(stage, pool); + // Matches (by id): a, m, z -> sorted -> a, m, z -> sliced to 2 -> a, m + expect(resolved.map(q => q.id)).toEqual(['a', 'm']); + }); + + it('truncates to count even when more questions match', () => { + const stage = makeStage({ conceptTags: ['ri'], tagMatch: 'any', count: 1 }); + const resolved = resolveStageQuestions(stage, pool); + expect(resolved).toHaveLength(1); + }); + + it('treats a question with no conceptTags as never matching', () => { + const poolWithUntagged = [...pool, makeQuestion({ id: 'untagged' })]; + const stage = makeStage({ conceptTags: ['ri'], tagMatch: 'all', count: 10 }); + const resolved = resolveStageQuestions(stage, poolWithUntagged); + expect(resolved.map(q => q.id)).not.toContain('untagged'); + }); +}); + +describe('resolveStageQuestions — neither selection path configured', () => { + it('returns an empty array', () => { + const stage = makeStage(); + expect(resolveStageQuestions(stage, [makeQuestion()])).toEqual([]); + }); +}); + +describe('resolveTrackStages', () => { + it('resolves every stage in order, carrying id/title/description/passThreshold through', () => { + const pool = [makeQuestion({ id: 'a' }), makeQuestion({ id: 'b' })]; + const track: ChallengeTrackSource = { + id: 'track-1', + title: 'Track One', + description: 'A test track.', + stages: [ + makeStage({ id: 'stage-a', title: 'Stage A', description: 'first', questionIds: ['a'] }), + makeStage({ id: 'stage-b', title: 'Stage B', questionIds: ['b'], passThreshold: 0.9 }), + ], + }; + + const resolved = resolveTrackStages(track, pool); + + expect(resolved).toHaveLength(2); + expect(resolved[0]).toEqual({ + id: 'stage-a', + title: 'Stage A', + description: 'first', + passThreshold: 0.7, + questions: [pool[0]], + }); + expect(resolved[1].passThreshold).toBe(0.9); + expect(resolved[1].questions).toEqual([pool[1]]); + }); +}); + +describe('trackStagePassed', () => { + it('passes when the score is exactly at the threshold', () => { + expect(trackStagePassed(0.7, 7, 10)).toBe(true); + }); + + it('passes when the score is above the threshold', () => { + expect(trackStagePassed(0.7, 8, 10)).toBe(true); + }); + + it('fails when the score is below the threshold', () => { + expect(trackStagePassed(0.7, 6, 10)).toBe(false); + }); + + it('fails (does not throw) when total is zero', () => { + expect(trackStagePassed(0.7, 0, 0)).toBe(false); + }); +}); diff --git a/src/lib/quiz/challenges.ts b/src/lib/quiz/challenges.ts new file mode 100644 index 0000000..69a07f5 --- /dev/null +++ b/src/lib/quiz/challenges.ts @@ -0,0 +1,125 @@ +/** + * Study Challenge Tracks — pure logic (Phase 1). + * + * Maps `challenges` content-collection entries (src/content/challenges/*.yaml, + * validated by the Zod schema in src/content/config.ts) into ordered, + * fully-resolved question lists for each stage of a track. + * + * Pure module: no `astro:content` import, so it can be unit tested with plain + * fixture objects, mirroring the style of curated-questions.ts. The caller + * (src/pages/study/challenges/*.astro) is responsible for calling + * `getCollection('challenges')` and `getCollection('questions')`, mapping the + * latter through `mapCuratedQuestions` to build the `pool`, then passing both + * into `resolveTrackStages`. + */ + +import type { Category, Question } from './question-types'; + +/** Locally-declared mirror of the `challengeStageSchema` shape in src/content/config.ts. */ +export interface ChallengeStageSource { + id: string; + title: string; + description?: string; + /** Explicit-ids path: an ordered set of curated question ids. */ + questionIds?: string[]; + /** Tag-fallback path: pull `count` questions matching conceptTags. */ + conceptTags?: string[]; + tagMatch: 'all' | 'any'; + count?: number; + passThreshold: number; +} + +/** Locally-declared mirror of the `challengeTrackSchema` shape in src/content/config.ts. */ +export interface ChallengeTrackSource { + id: string; + title: string; + description: string; + icon?: string; + categoryAffinity?: Category; + conceptTags?: string[]; + estimatedMinutes?: number; + stages: ChallengeStageSource[]; +} + +/** A stage with its `questionIds`/`conceptTags` selection fully resolved to `Question[]`. */ +export interface ResolvedStage { + id: string; + title: string; + description?: string; + passThreshold: number; + questions: Question[]; +} + +/** + * Resolve a single stage's questions from the question pool. + * + * - Explicit-ids path (`questionIds` present): preserves the authored order. + * Missing ids are skipped (with a `console.warn`) rather than throwing, so + * a single stale reference in curated content never breaks the build or + * the page. + * - Tag-fallback path (`conceptTags` + `count`): filters the pool by + * `tagMatch` ('all' requires every tag to be present on the question, 'any' + * requires at least one), sorts the matches by question id for a + * deterministic order, then truncates to `count`. + */ +export function resolveStageQuestions( + stage: ChallengeStageSource, + pool: Question[], +): Question[] { + if (stage.questionIds && stage.questionIds.length > 0) { + const byId = new Map(pool.map(q => [q.id, q] as const)); + const resolved: Question[] = []; + for (const id of stage.questionIds) { + const question = byId.get(id); + if (!question) { + console.warn( + `[challenges] stage "${stage.id}": questionId "${id}" not found in question pool — skipping.`, + ); + continue; + } + resolved.push(question); + } + return resolved; + } + + if (stage.conceptTags && stage.conceptTags.length > 0 && stage.count) { + const tags = stage.conceptTags; + const matched = pool.filter(q => { + const qTags = q.conceptTags ?? []; + return stage.tagMatch === 'any' + ? tags.some(t => qTags.includes(t)) + : tags.every(t => qTags.includes(t)); + }); + matched.sort((a, b) => a.id.localeCompare(b.id)); + return matched.slice(0, stage.count); + } + + // Defensive: the content schema's refine() requires one of the two paths + // above, but a malformed source (e.g. hand-built fixture) falls through + // to an empty stage rather than throwing. + return []; +} + +/** Resolve every stage of a track into fully-ordered question lists. */ +export function resolveTrackStages( + track: ChallengeTrackSource, + pool: Question[], +): ResolvedStage[] { + return track.stages.map(stage => ({ + id: stage.id, + title: stage.title, + description: stage.description, + passThreshold: stage.passThreshold, + questions: resolveStageQuestions(stage, pool), + })); +} + +/** Whether a stage attempt clears its pass threshold. Zero-question stages never pass. */ +export function trackStagePassed( + passThreshold: number, + correct: number, + total: number, +): boolean { + if (total <= 0) return false; + return correct / total >= passThreshold; +} diff --git a/src/lib/quiz/curated-questions.ts b/src/lib/quiz/curated-questions.ts index eba83b3..9064225 100644 --- a/src/lib/quiz/curated-questions.ts +++ b/src/lib/quiz/curated-questions.ts @@ -40,6 +40,8 @@ export interface CuratedQuestionSource { imageRef?: string; unvetted?: boolean; deprecated?: boolean; + /** Concept tags used by Study Challenge Tracks to select tag-fallback stage questions. */ + conceptTags?: string[]; } const TYPE_MAP: Record = { @@ -109,6 +111,7 @@ export function mapCuratedQuestion(source: CuratedQuestionSource): Question | nu rationaleCorrect: source.rationaleCorrect, optionRationales, unvetted: source.unvetted ?? false, + conceptTags: source.conceptTags, }; return isRenderable(question) ? question : null; diff --git a/src/lib/quiz/index.ts b/src/lib/quiz/index.ts index 0f6f1d2..f38e6a8 100644 --- a/src/lib/quiz/index.ts +++ b/src/lib/quiz/index.ts @@ -116,3 +116,28 @@ export { LocalStudyStore, getStudyStore } from './store'; // Due-aware session selection (A4c) export { selectSessionQuestions } from './question-pool'; export type { QuestionPoolStore } from './question-pool'; + +// ---------------------------------------------------------------------- +// Study Challenge Tracks (Phase 1) +// ---------------------------------------------------------------------- + +export type { + ChallengeStageSource, + ChallengeTrackSource, + ResolvedStage, +} from './challenges'; +export { + resolveStageQuestions, + resolveTrackStages, + trackStagePassed, +} from './challenges'; + +export type { + ChallengeStageResult, + ChallengeProgress, +} from './challenge-store'; +export { + CHALLENGE_STORAGE_KEY, + LocalChallengeStore, + getChallengeStore, +} from './challenge-store'; diff --git a/src/lib/quiz/question-types.ts b/src/lib/quiz/question-types.ts index 5e3d4a7..9d47026 100644 --- a/src/lib/quiz/question-types.ts +++ b/src/lib/quiz/question-types.ts @@ -88,6 +88,8 @@ export interface Question { optionRationales?: OptionRationale[]; /** Whether this question is auto-generated and not yet expert-reviewed. */ unvetted?: boolean; + /** Concept tags carried from the curated question bank (Study Challenge Tracks). */ + conceptTags?: string[]; } /** Configuration for starting a quiz */ diff --git a/src/pages/quiz/index.astro b/src/pages/quiz/index.astro index 7960c7b..51af37e 100644 --- a/src/pages/quiz/index.astro +++ b/src/pages/quiz/index.astro @@ -38,6 +38,7 @@ const curatedSources: CuratedQuestionSource[] = curatedEntries.map(entry => ({ imageRef: entry.data.imageRef, unvetted: entry.data.unvetted, deprecated: entry.data.deprecated, + conceptTags: entry.data.conceptTags, })); const curatedQuestions = mapCuratedQuestions(curatedSources); diff --git a/src/pages/study/cases/[id].astro b/src/pages/study/cases/[id].astro new file mode 100644 index 0000000..ce0fca0 --- /dev/null +++ b/src/pages/study/cases/[id].astro @@ -0,0 +1,44 @@ +--- +/** + * Lab Simulation Cases — single-case run screen (Phase 3). + * + * getStaticPaths builds one static page per entry in the `cases` collection. + * Each case is mapped at build time (via mapCaseSource, mirroring how + * Phase 1's [track].astro resolves stages) into a plain-data CaseDefinition + * and passed into the client-side island — no astro:content + * on the client. + */ +import { getCollection } from 'astro:content'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { CaseRunner } from '../../../components/cases/CaseRunner'; +import { mapCaseSource } from '../../../lib/cases'; +import type { CaseSource } from '../../../lib/cases'; + +export async function getStaticPaths() { + const entries = await getCollection('cases'); + + return entries + .map((entry) => { + const source: CaseSource = entry.data; + const caseData = mapCaseSource(source); + if (!caseData) return null; + return { + params: { id: caseData.id }, + props: { caseData }, + }; + }) + .filter((p): p is NonNullable => p !== null); +} + +const { caseData } = Astro.props; + +const pageDescription = caseData.specimenSummary; +--- + + +
+
+ +
+
+
diff --git a/src/pages/study/cases/index.astro b/src/pages/study/cases/index.astro new file mode 100644 index 0000000..297951d --- /dev/null +++ b/src/pages/study/cases/index.astro @@ -0,0 +1,12 @@ +--- +/** + * /study/cases — redirect stub. + * + * Lab Simulation cases are unified into the Study Challenge Tracks hub + * (`/study/challenges`, "Lab Cases" section) rather than having their own + * standalone hub, per the Phase 3 coordination decision. This route only + * exists so the semantic `/study/cases` URL isn't a 404 for anyone who + * guesses it or follows an old link. + */ +return Astro.redirect('/study/challenges#lab-cases', 301); +--- diff --git a/src/pages/study/challenges/[track].astro b/src/pages/study/challenges/[track].astro new file mode 100644 index 0000000..8f2a0d7 --- /dev/null +++ b/src/pages/study/challenges/[track].astro @@ -0,0 +1,70 @@ +--- +/** + * Study Challenge Tracks — per-track stage screen (Phase 1). + * + * getStaticPaths builds one static page per track in the `challenges` + * collection. Each stage's full ordered Question[] is resolved at build + * time (via resolveTrackStages against the curated question pool) and + * passed as plain data into the client-side island. + */ +import { getCollection } from 'astro:content'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { ChallengeTrackScreen } from '../../../components/quiz/challenges/ChallengeTrackScreen'; +import { mapCuratedQuestions, resolveTrackStages } from '../../../lib/quiz'; +import type { CuratedQuestionSource, ChallengeTrackSource } from '../../../lib/quiz'; + +export async function getStaticPaths() { + const trackEntries = await getCollection('challenges'); + const curatedEntries = await getCollection('questions'); + const curatedSources: CuratedQuestionSource[] = curatedEntries.map(entry => ({ + id: entry.data.id, + stem: entry.data.stem, + type: entry.data.type, + options: entry.data.options, + acceptedAnswers: entry.data.acceptedAnswers, + pairs: entry.data.pairs, + rationaleCorrect: entry.data.rationaleCorrect, + difficulty: entry.data.difficulty, + category: entry.data.category, + sourceArticle: entry.data.sourceArticle, + imageRef: entry.data.imageRef, + unvetted: entry.data.unvetted, + deprecated: entry.data.deprecated, + conceptTags: entry.data.conceptTags, + })); + const pool = mapCuratedQuestions(curatedSources); + + return trackEntries.map(entry => { + const track: ChallengeTrackSource = entry.data; + const resolvedStages = resolveTrackStages(track, pool); + + return { + params: { track: track.id }, + props: { track, stages: resolvedStages }, + }; + }); +} + +const { track, stages } = Astro.props; + +const pageDescription = track.description; +--- + + +
+
+ +
+
+
diff --git a/src/pages/study/challenges/index.astro b/src/pages/study/challenges/index.astro new file mode 100644 index 0000000..34b6bac --- /dev/null +++ b/src/pages/study/challenges/index.astro @@ -0,0 +1,112 @@ +--- +/** + * Study Challenge Tracks — hub page (Phase 1). + * + * Loads all challenge tracks plus the curated question bank, resolves each + * track's stages against the question pool at build time (server-side, via + * resolveTrackStages), and passes only plain summary data down to the + * client-side island — no astro:content on the client. + */ +import { getCollection } from 'astro:content'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { ChallengesHub } from '../../../components/quiz/challenges/ChallengesHub'; +import type { ChallengeTrackSummary } from '../../../components/quiz/challenges/ChallengesHub'; +import { mapCuratedQuestions, resolveTrackStages } from '../../../lib/quiz'; +import type { CuratedQuestionSource, ChallengeTrackSource } from '../../../lib/quiz'; +import { CaseCard } from '../../../components/cases/CaseCard'; +import type { CaseSummary } from '../../../components/cases/CaseCard'; +import { mapCaseSources } from '../../../lib/cases'; +import type { CaseSource } from '../../../lib/cases'; + +const trackEntries = await getCollection('challenges'); + +const curatedEntries = await getCollection('questions'); +const curatedSources: CuratedQuestionSource[] = curatedEntries.map(entry => ({ + id: entry.data.id, + stem: entry.data.stem, + type: entry.data.type, + options: entry.data.options, + acceptedAnswers: entry.data.acceptedAnswers, + pairs: entry.data.pairs, + rationaleCorrect: entry.data.rationaleCorrect, + difficulty: entry.data.difficulty, + category: entry.data.category, + sourceArticle: entry.data.sourceArticle, + imageRef: entry.data.imageRef, + unvetted: entry.data.unvetted, + deprecated: entry.data.deprecated, + conceptTags: entry.data.conceptTags, +})); +// mapCuratedQuestions already drops deprecated / non-renderable entries. +const pool = mapCuratedQuestions(curatedSources); + +const trackSummaries: ChallengeTrackSummary[] = trackEntries.map(entry => { + const track: ChallengeTrackSource = entry.data; + const resolvedStages = resolveTrackStages(track, pool); + const totalQuestions = resolvedStages.reduce((sum, stage) => sum + stage.questions.length, 0); + + return { + id: track.id, + title: track.title, + description: track.description, + icon: track.icon, + estimatedMinutes: track.estimatedMinutes, + categoryAffinity: track.categoryAffinity, + stageCount: track.stages.length, + totalQuestions, + }; +}); + +const pageDescription = + 'Themed challenge tracks: structured, staged-mastery study paths through specific gemmology topics, with each stage unlocking once the previous one is passed.'; + +// ── Lab Simulation cases (Phase 3) ───────────────────────────────────────── +// Unified into this hub per the Phase 3 coordination decision (rather than a +// standalone /study/cases hub) — see src/pages/study/cases/index.astro. +const caseEntries = await getCollection('cases'); +const caseSources: CaseSource[] = caseEntries.map((entry) => entry.data); +const caseDefinitions = mapCaseSources(caseSources); +const caseSummaries: CaseSummary[] = caseDefinitions.map((c) => ({ + id: c.id, + title: c.title, + difficulty: c.difficulty, + estimatedMinutes: c.estimatedMinutes, + specimenSummary: c.specimenSummary, +})); +--- + + +
+
+
+

Challenge Tracks

+

+ Structured study with staged mastery: work through a themed sequence of quiz stages, + each one unlocking only once you pass the last. Progress is saved locally in your + browser. +

+
+ + + + {caseSummaries.length > 0 && ( +
+
+

Lab Cases

+

+ Scenario-driven identification exercises: work a case the way you would in the + lab — choose the next test, interpret readings, narrow candidates, and reach a + final identification. Scored by decision quality, not just the final answer. +

+
+ +
+ {caseSummaries.map((caseSummary) => ( + + ))} +
+
+ )} +
+
+
diff --git a/src/pages/tools/identification.astro b/src/pages/tools/identification.astro index c2b454e..040bc2d 100644 --- a/src/pages/tools/identification.astro +++ b/src/pages/tools/identification.astro @@ -36,6 +36,24 @@ const tools = [ +
+
+

Practise on a full case

+

+ This tool narrows a candidate list from RI and SG alone. To practise the full + identification workflow end-to-end — choosing tests, interpreting readings, calling a + treatment, and reaching a disclosed final identification — work through a scenario-driven + lab case. +

+ + Try a full case study → + +
+
+
About the identifier & methodology