diff --git a/docs/authoring-questions.md b/docs/authoring-questions.md index f8d3a7b..e774f0d 100644 --- a/docs/authoring-questions.md +++ b/docs/authoring-questions.md @@ -143,6 +143,30 @@ At least one other FGA-qualified reviewer should approve the rationale content b --- +## Fill-in-the-Blank Answer Matching + +`fill-blank` items are scored by comparing the student's typed input against every string in +`acceptedAnswers`. Matching is **normalized** on both sides before comparison: + +1. Leading/trailing whitespace is trimmed. +2. The string is Unicode-decomposed (NFKD) and combining diacritics are stripped (so `café` + and `cafe` are treated as equal). +3. The string is lowercased. +4. Runs of internal whitespace are collapsed to a single space. + +This means a student does not need to match case, accents, or exact spacing to be marked +correct. It does **not** perform numeric-word equivalence — `"10"` and `"ten"` are treated as +different answers. If a fill-blank item has a numeral form and a spelled-out form that should +both be accepted, list both explicitly in `acceptedAnswers`: + +```yaml +acceptedAnswers: + - "10" + - ten +``` + +--- + ## Quality Bar for Curated Items > This section restates V1-PLAN.md §10 with additional worked examples. diff --git a/src/components/calculator/MeasurementTools.tsx b/src/components/calculator/MeasurementTools.tsx index 273306a..9405be6 100644 --- a/src/components/calculator/MeasurementTools.tsx +++ b/src/components/calculator/MeasurementTools.tsx @@ -13,6 +13,17 @@ import { DensityEstimator } from './DensityEstimator'; import { HannemanRI } from './HannemanRI'; import { ToolSection } from '../ui/ToolSection'; +const JUMP_LINKS = [ + { id: 'sg', label: 'SG' }, + { id: 'ri-lookup', label: 'RI Lookup' }, + { id: 'birefringence', label: 'Birefringence' }, + { id: 'critical-angle', label: 'Critical Angle' }, + { id: 'dispersion', label: 'Dispersion' }, + { id: 'carat-estimate', label: 'Carat Estimator' }, + { id: 'density', label: 'Density' }, + { id: 'hanneman-ri', label: 'Hanneman RI' }, +]; + const ICON_PATHS = { scale: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3', search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', @@ -26,6 +37,21 @@ const ICON_PATHS = { export function MeasurementTools() { return (
+ + .callout { @apply relative flex gap-4 my-6 p-5 rounded-xl; - @apply border-l-4; + @apply border; } .callout-icon svg { @@ -59,22 +59,22 @@ const icons = { /* Variants */ .callout-info { - @apply bg-crystal-50/50 border-crystal-400 text-crystal-900; + @apply bg-crystal-50 border-crystal-400 text-crystal-900; } .callout-info .callout-icon { @apply text-crystal-500; } .callout-warning { - @apply bg-amber-50/50 border-amber-400 text-amber-900; + @apply bg-amber-50 border-amber-400 text-amber-900; } .callout-warning .callout-icon { @apply text-amber-500; } .callout-tip { - @apply bg-emerald-50/50 border-emerald-400 text-emerald-900; + @apply bg-emerald-50 border-emerald-400 text-emerald-900; } .callout-tip .callout-icon { @apply text-emerald-500; } .callout-error { - @apply bg-red-50/50 border-red-400 text-red-900; + @apply bg-red-50 border-red-400 text-red-900; } .callout-error .callout-icon { @apply text-red-500; } diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro index 886ae87..7fab413 100644 --- a/src/components/layout/Header.astro +++ b/src/components/layout/Header.astro @@ -15,7 +15,7 @@ const currentPath = Astro.url.pathname;
- + @@ -35,6 +35,7 @@ const currentPath = Astro.url.pathname; aria-current={isActive ? 'page' : undefined} class:list={[ 'relative px-4 py-2 rounded-lg text-sm font-medium transition-colors', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2', isActive ? 'bg-crystal-50 text-crystal-700 after:absolute after:left-3 after:right-3 after:-bottom-px after:h-0.5 after:bg-crystal-700 after:rounded-full' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50', @@ -63,8 +64,10 @@ const currentPath = Astro.url.pathname;
+
e.stopPropagation()} + > +

+ {title} +

+ {children && ( +
{children}
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/components/quiz/Exam.tsx b/src/components/quiz/Exam.tsx index 5e36396..4785d6a 100644 --- a/src/components/quiz/Exam.tsx +++ b/src/components/quiz/Exam.tsx @@ -1,15 +1,24 @@ /** * Exam mode component. * Timed assessment with no immediate feedback, question flagging, and navigation. + * + * A8: useQuizKeyboard wires digits 1-4 (select option) and Enter + * (advance/open submit-confirm) shortcuts. Enter never auto-submits the exam + * past the confirm step — on the last question it opens the confirm dialog + * instead of calling submitExam() directly. */ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import type { Question, QuizConfig } from '../../lib/quiz'; +import { isRenderable } from '../../lib/quiz'; import { useExam } from '../../hooks/useExam'; +import { useQuizKeyboard } from '../../hooks/useQuizKeyboard'; import { QuestionCard } from './QuestionCard'; +import { UnrenderableQuestionCard } from './UnrenderableQuestionCard'; import { ExamTimer } from './ExamTimer'; import { QuestionNav, QuestionNavCompact } from './QuestionNav'; import { ExamResults } from './ExamResults'; +import { ConfirmDialog } from './ConfirmDialog'; import { Button } from '../ui/Button'; import { cn } from '../ui/cn'; @@ -56,6 +65,7 @@ export function Exam({ isQuestionAnswered, submitExam, resetExam, + skipQuestion, } = useExam({ config, questions, @@ -63,6 +73,33 @@ export function Exam({ autoSubmitOnTimeout: true, }); + // A8: digit keys 1-4 select an option (multiple-choice/true-false only). + const handleSelectIndex = useCallback((index: number) => { + if (!currentQuestion) return; + if (currentQuestion.type !== 'multiple-choice' && currentQuestion.type !== 'true-false') return; + const options = currentQuestion.options; + if (!options || index < 0 || index >= options.length) return; + selectAnswer(options[index]); + }, [currentQuestion, selectAnswer]); + + // A8: Enter advances to the next question, or opens the submit-confirm + // dialog on the last question — it never submits the exam directly. + const handleEnter = useCallback(() => { + if (!currentQuestion) return; + const isLast = state.currentIndex === questions.length - 1; + if (isLast) { + setConfirmSubmit(true); + } else { + nextQuestion(); + } + }, [currentQuestion, state.currentIndex, questions.length, nextQuestion]); + + useQuizKeyboard({ + enabled: !isComplete && !!currentQuestion && !confirmSubmit, + onSelectIndex: handleSelectIndex, + onEnter: handleEnter, + }); + // Show results if exam is complete if (isComplete && results) { return ( @@ -172,85 +209,40 @@ export function Exam({ )} {/* Question card - no feedback in exam mode */} -
- {/* Question header */} -
-
- - Question {state.currentIndex + 1} of {questions.length} - -
- - {currentQuestion.difficulty} - - - {/* Flag button */} - -
-
-
- - {/* Question text */} -
-

- {currentQuestion.questionText} -

-
- - {/* Answer options - no feedback shown */} -
- {currentQuestion.options?.map((option, index) => { - const labels = ['A', 'B', 'C', 'D', 'E', 'F']; - const isSelected = state.answers.get(currentQuestion.id) === option; - - return ( - - ); - })} -
-
+ {isRenderable(currentQuestion) ? ( + + + + + + } + /> + ) : ( + + )} {/* Navigation buttons */}
@@ -288,56 +280,38 @@ export function Exam({
{/* Submit confirmation modal */} - {confirmSubmit && ( -
-
-

Submit Exam?

-
-

- {answeredCount} of {questions.length} questions answered -

- {unansweredCount > 0 && ( -

- - - - {unansweredCount} questions unanswered -

- )} - {flaggedCount > 0 && ( -

- - - - {flaggedCount} questions flagged for review -

- )} -
-

- Once submitted, you cannot change your answers. -

-
- - -
-
-
- )} + setConfirmSubmit(false)} + onConfirm={() => { + setConfirmSubmit(false); + submitExam(); + }} + > +

+ {answeredCount} of {questions.length} questions answered +

+ {unansweredCount > 0 && ( +

+ + + + {unansweredCount} questions unanswered +

+ )} + {flaggedCount > 0 && ( +

+ + + + {flaggedCount} questions flagged for review +

+ )} +

Once submitted, you cannot change your answers.

+
); } diff --git a/src/components/quiz/ExamResults.tsx b/src/components/quiz/ExamResults.tsx index 8d3eb1f..f0e1f53 100644 --- a/src/components/quiz/ExamResults.tsx +++ b/src/components/quiz/ExamResults.tsx @@ -8,6 +8,7 @@ import type { QuizResult, QuestionResult } from '../../lib/quiz'; import { getGrade, getFeedback, formatDuration, getPassStatus, CATEGORY_LABELS } from '../../lib/quiz'; import { Button } from '../ui/Button'; import { cn } from '../ui/cn'; +import { RationalePanel } from './study/RationalePanel'; interface ExamResultsProps { /** The exam results */ @@ -292,6 +293,14 @@ function QuestionReviewCard({ result, questionNumber }: QuestionReviewCardProps) ? question.correctAnswer[0] : question.correctAnswer; + // Rationale is exam-mode's only surface for RationalePanel (never shown + // mid-exam) — only rendered when the question carries curated rationale data. + const userPickedIndex = + (question.type === 'multiple-choice' || question.type === 'true-false') && + typeof userAnswer === 'string' && question.options + ? question.options.indexOf(userAnswer) + : undefined; + return (
)} - {/* Explanation */} - {question.explanation && ( -
-

{question.explanation}

-
+ {/* Rationale (curated questions) — falls back to plain explanation + (auto-generated questions) when no rationale fields are present. */} + {question.rationaleCorrect ? ( + + ) : ( + question.explanation && ( +
+

{question.explanation}

+
+ ) )} {/* Source link */} diff --git a/src/components/quiz/QuestionCard.test.tsx b/src/components/quiz/QuestionCard.test.tsx new file mode 100644 index 0000000..f7038b5 --- /dev/null +++ b/src/components/quiz/QuestionCard.test.tsx @@ -0,0 +1,292 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QuestionCard } from './QuestionCard'; +import type { Question } from '../../lib/quiz'; + +function mcQuestion(overrides: Partial = {}): Question { + return { + id: 'mc1', + 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', + explanation: 'Cubic crystals have three equal, mutually perpendicular axes.', + ...overrides, + }; +} + +function fillBlankQuestion(overrides: Partial = {}): Question { + return { + id: 'fb1', + type: 'fill-blank', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'hardness', + questionText: 'Diamond has a Mohs hardness of ___.', + correctAnswer: '10', + ...overrides, + }; +} + +function matchingQuestion(overrides: Partial = {}): Question { + return { + id: 'match1', + type: 'matching', + difficulty: 'intermediate', + category: 'species', + topic: 'gem-species', + questionText: 'Match each gem to its crystal system.', + matchingPairs: [ + { left: 'Diamond', right: 'Cubic' }, + { left: 'Quartz', right: 'Trigonal' }, + ], + correctAnswer: ['Diamond:Cubic', 'Quartz:Trigonal'], + ...overrides, + }; +} + +describe('QuestionCard — multiple-choice', () => { + it('renders all options', () => { + render( + {}} + /> + ); + expect(screen.getByText('Cubic')).toBeInTheDocument(); + expect(screen.getByText('Trigonal')).toBeInTheDocument(); + expect(screen.getByText('Monoclinic')).toBeInTheDocument(); + }); + + it('calls onSelectAnswer with the option text when clicked', async () => { + const user = userEvent.setup(); + const onSelectAnswer = vi.fn(); + render( + + ); + await user.click(screen.getByText('Cubic')); + expect(onSelectAnswer).toHaveBeenCalledWith('Cubic'); + }); + + it('shows "Correct!" feedback via shared checkAnswer when submitted with the right answer', () => { + render( + {}} + /> + ); + expect(screen.getByText('Correct!')).toBeInTheDocument(); + }); + + it('shows "Incorrect" feedback when submitted with the wrong answer', () => { + render( + {}} + /> + ); + expect(screen.getByText('Incorrect')).toBeInTheDocument(); + }); + + it('regression: shows zero correctness styling when isSubmitted=false (exam mode mid-question)', () => { + render( + {}} + /> + ); + // No feedback box at all. + expect(screen.queryByText('Correct!')).not.toBeInTheDocument(); + expect(screen.queryByText('Incorrect')).not.toBeInTheDocument(); + // The selected option is not disabled/marked answered. + const selectedOption = screen.getByRole('button', { name: /Cubic/i }); + expect(selectedOption).not.toBeDisabled(); + }); + + it('regression: shows zero correctness styling when showFeedback=false even if isSubmitted were true', () => { + render( + {}} + /> + ); + expect(screen.queryByText('Correct!')).not.toBeInTheDocument(); + expect(screen.queryByText('Incorrect')).not.toBeInTheDocument(); + }); + + it('renders headerExtra content when provided', () => { + render( + {}} + headerExtra={flag-button} + /> + ); + expect(screen.getByText('flag-button')).toBeInTheDocument(); + }); + + it('renders scheduleBadge content when provided', () => { + render( + {}} + scheduleBadge={New} + /> + ); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('renders the RationalePanel when showRationalePanel is true and rationaleCorrect is present', () => { + render( + {}} + /> + ); + expect(screen.getByRole('region', { name: /answer explanation/i })).toBeInTheDocument(); + }); +}); + +describe('QuestionCard — fill-blank', () => { + it('renders a text input and reports typed value to onSelectAnswer', async () => { + const user = userEvent.setup(); + const onSelectAnswer = vi.fn(); + render( + + ); + const input = screen.getByLabelText(/your answer/i); + await user.type(input, '10'); + expect(onSelectAnswer).toHaveBeenCalled(); + expect(input).toHaveValue('10'); + }); + + it('shows correct feedback using checkAnswer normalization (case/whitespace tolerant)', () => { + render( + {}} + /> + ); + expect(screen.getByText('Correct!')).toBeInTheDocument(); + }); +}); + +describe('QuestionCard — matching', () => { + it('renders one row per pair with a select of right-hand options', () => { + render( + {}} + /> + ); + expect(screen.getByText('Diamond')).toBeInTheDocument(); + expect(screen.getByText('Quartz')).toBeInTheDocument(); + expect(screen.getByLabelText(/match for diamond/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/match for quartz/i)).toBeInTheDocument(); + }); + + it('reports the full pair array to onSelectAnswer as selections are made', async () => { + const user = userEvent.setup(); + const onSelectAnswer = vi.fn(); + render( + + ); + await user.selectOptions(screen.getByLabelText(/match for diamond/i), 'Cubic'); + expect(onSelectAnswer).toHaveBeenCalledWith(['Diamond:Cubic']); + }); + + it('shows correct feedback when all pairs match regardless of order', () => { + render( + {}} + /> + ); + expect(screen.getByText('Correct!')).toBeInTheDocument(); + }); +}); diff --git a/src/components/quiz/QuestionCard.tsx b/src/components/quiz/QuestionCard.tsx index afc1f35..7d8d009 100644 --- a/src/components/quiz/QuestionCard.tsx +++ b/src/components/quiz/QuestionCard.tsx @@ -1,11 +1,14 @@ /** * Question card component for displaying a single quiz question. - * Handles multiple-choice and true/false question types. + * Handles multiple-choice, true/false, fill-blank, and matching question types. */ -import { useState, useCallback } from 'react'; -import type { Question } from '../../lib/quiz'; +import type { ReactNode } from 'react'; +import { useState, useEffect, useId } from 'react'; +import type { Question, OptionRationale as QuestionOptionRationale } from '../../lib/quiz'; +import { checkAnswer } from '../../lib/quiz'; import { AnswerOption, OPTION_LABELS } from './AnswerOption'; +import { RationalePanel } from './study/RationalePanel'; import { cn } from '../ui/cn'; interface QuestionCardProps { @@ -15,16 +18,115 @@ interface QuestionCardProps { questionNumber: number; /** Total number of questions */ totalQuestions: number; - /** The user's current answer (if any) */ - selectedAnswer?: string; + /** The user's current answer (if any). String for MCQ/true-false/fill-blank, string[] for matching. */ + selectedAnswer?: string | string[]; /** Whether to show feedback (practice mode) */ showFeedback: boolean; /** Whether this question has been submitted */ isSubmitted: boolean; /** Callback when an answer is selected */ - onSelectAnswer: (answer: string) => void; + onSelectAnswer: (answer: string | string[]) => void; /** Callback when the question is submitted (practice mode) */ onSubmit?: () => void; + /** Extra content rendered in the header, next to the difficulty/category badges (e.g. a flag button) */ + headerExtra?: ReactNode; + /** SM-2 schedule status badge, rendered in the header (practice mode, Study v1) */ + scheduleBadge?: ReactNode; + /** When true, renders the collapsible RationalePanel instead of the plain feedback box. Default false. */ + showRationalePanel?: boolean; +} + +/** Inline fill-blank text input. */ +function FillBlankInput({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (value: string) => void; + disabled: boolean; +}) { + return ( + onChange(e.target.value)} + disabled={disabled} + placeholder="Type your answer..." + className={cn( + 'w-full px-4 py-3 rounded-lg border-2 text-slate-900', + 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-crystal-500', + 'border-slate-200 bg-white', + disabled && 'opacity-70 cursor-not-allowed bg-slate-50' + )} + aria-label="Your answer" + /> + ); +} + +/** Inline matching-question rows: each left item gets a dropdown of right-side options. */ +function MatchingRows({ + pairs, + value, + onChange, + disabled, +}: { + pairs: Array<{ left: string; right: string }>; + value: string[]; + onChange: (value: string[]) => void; + disabled: boolean; +}) { + const uid = useId(); + // value entries are `${left}:${right}` strings, matching the generator convention. + const selections = new Map(); + for (const entry of value) { + const idx = entry.indexOf(':'); + if (idx === -1) continue; + selections.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + + const rightOptions = pairs.map(p => p.right); + + const handleSelect = (left: string, right: string) => { + const next = new Map(selections); + if (right) { + next.set(left, right); + } else { + next.delete(left); + } + onChange(Array.from(next.entries()).map(([l, r]) => `${l}:${r}`)); + }; + + return ( +
+ {pairs.map((pair, index) => ( +
+ {pair.left} + +
+ ))} +
+ ); } export function QuestionCard({ @@ -36,14 +138,36 @@ export function QuestionCard({ isSubmitted, onSelectAnswer, onSubmit, + headerExtra, + scheduleBadge, + showRationalePanel = false, }: QuestionCardProps) { const options = question.options || []; - const correctAnswer = Array.isArray(question.correctAnswer) + const selectedString = typeof selectedAnswer === 'string' ? selectedAnswer : undefined; + const selectedArray = Array.isArray(selectedAnswer) ? selectedAnswer : []; + + // Local text state for fill-blank so the input stays controlled even before + // the parent has recorded a first answer. + const [fillBlankValue, setFillBlankValue] = useState(selectedString ?? ''); + useEffect(() => { + setFillBlankValue(selectedString ?? ''); + }, [selectedString, question.id]); + + const hasAnswer = selectedAnswer !== undefined; + const isAnswered = isSubmitted && showFeedback; + const isCorrectAnswer = hasAnswer && checkAnswer(question, selectedAnswer); + + // For single-value display purposes (feedback text, RationalePanel highlighting). + const correctAnswerDisplay = Array.isArray(question.correctAnswer) ? question.correctAnswer[0] : question.correctAnswer; - // Determine if the selected answer is correct - const isCorrectAnswer = selectedAnswer === correctAnswer; + const userPickedIndex = + (question.type === 'multiple-choice' || question.type === 'true-false') && selectedString + ? options.indexOf(selectedString) + : undefined; + + const optionRationales: QuestionOptionRationale[] | undefined = question.optionRationales; return (
@@ -54,6 +178,7 @@ export function QuestionCard({ Question {questionNumber} of {totalQuestions}
+ {scheduleBadge} {question.category} + {headerExtra}
@@ -78,24 +204,60 @@ export function QuestionCard({
- {/* Answer options */} -
- {options.map((option, index) => ( - onSelectAnswer(option)} + {/* Answer input, branched by question type */} +
+ {(question.type === 'multiple-choice' || question.type === 'true-false') && ( +
+ {options.map((option, index) => ( + onSelectAnswer(option)} + /> + ))} +
+ )} + + {question.type === 'fill-blank' && ( + { + setFillBlankValue(value); + onSelectAnswer(value); + }} + disabled={isAnswered} /> - ))} + )} + + {question.type === 'matching' && question.matchingPairs && ( + + )}
{/* Feedback section (practice mode) */} - {isSubmitted && showFeedback && ( + {isAnswered && showRationalePanel && question.rationaleCorrect && ( +
+ +
+ )} + + {isAnswered && (!showRationalePanel || !question.rationaleCorrect) && (
{!isCorrectAnswer && (

- The correct answer is: {correctAnswer} + The correct answer is: {correctAnswerDisplay}

)} {question.explanation && ( @@ -154,6 +316,7 @@ export function QuestionCard({
)} +
); } diff --git a/src/components/quiz/Quiz.test.tsx b/src/components/quiz/Quiz.test.tsx new file mode 100644 index 0000000..435c273 --- /dev/null +++ b/src/components/quiz/Quiz.test.tsx @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Question, QuizConfig } from '../../lib/quiz'; +import { DEFAULT_STUDY_SETTINGS } from '../../lib/quiz/study-types'; +import { newScheduleEntry } from '../../lib/quiz/study-types'; + +// ── Mock the study store singleton so settings/schedule are observable and +// controllable per test. ───────────────────────────────────────────────── +const mockStore = { + appendResponse: vi.fn().mockResolvedValue(undefined), + getResponsesFor: vi.fn().mockResolvedValue([]), + getRecentResponses: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockResolvedValue(null), + updateSchedule: vi.fn().mockResolvedValue(undefined), + getDueItems: vi.fn().mockResolvedValue([]), + getProgress: vi.fn().mockResolvedValue({ + completedTopics: { + fundamentals: [], equipment: [], species: [], identification: [], + phenomena: [], origin: [], market: [], care: [], + }, + bestScores: { + fundamentals: 0, equipment: 0, species: 0, identification: 0, + phenomena: 0, origin: 0, market: 0, care: 0, + }, + totalQuizzes: 0, + totalCorrect: 0, + totalAttempted: 0, + lastActivity: 0, + }), + updateProgress: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue(DEFAULT_STUDY_SETTINGS), + updateSettings: vi.fn().mockResolvedValue(undefined), + exportAll: vi.fn(), + importAll: vi.fn(), +}; + +vi.mock('../../lib/quiz/store', () => ({ + getStudyStore: () => mockStore, +})); + +const { Quiz } = await import('./Quiz'); + +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, + }; +} + +function makeConfig(overrides: Partial = {}): QuizConfig { + return { + categories: ['fundamentals'], + questionCount: 1, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'practice', + ...overrides, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + mockStore.getSettings.mockResolvedValue(DEFAULT_STUDY_SETTINGS); + mockStore.getSchedule.mockResolvedValue(null); + mockStore.updateSchedule.mockResolvedValue(undefined); + mockStore.appendResponse.mockResolvedValue(undefined); +}); + +describe('Quiz — study v1 wiring (A4b)', () => { + it('shows ConfidenceTap once an answer is selected and gates Check Answer until confidence is chosen (requireConfidence: true)', async () => { + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + // Confidence tap not shown before an answer is picked. + expect(screen.queryByText('How confident are you?')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + + // Now the confidence tap should appear. + await waitFor(() => { + expect(screen.getByText('How confident are you?')).toBeInTheDocument(); + }); + + // Check Answer is disabled until confidence is picked. + const checkAnswerBtn = screen.getByRole('button', { name: 'Check Answer' }); + expect(checkAnswerBtn).toBeDisabled(); + + await user.click(screen.getByRole('radio', { name: /Certain/ })); + + await waitFor(() => { + expect(checkAnswerBtn).not.toBeDisabled(); + }); + }); + + it('does not render ConfidenceTap and does not gate Check Answer when requireConfidence is false', async () => { + const user = userEvent.setup(); + mockStore.getSettings.mockResolvedValue({ + ...DEFAULT_STUDY_SETTINGS, + requireConfidence: false, + }); + + const question = makeQuestion(); + render(); + + await waitFor(() => { + expect(mockStore.getSettings).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + + expect(screen.queryByText('How confident are you?')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Check Answer' })).not.toBeDisabled(); + }); + }); + + it('submits the chosen confidence to submitAnswer via the SM-2 schedule update', async () => { + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + await waitFor(() => screen.getByText('How confident are you?')); + await user.click(screen.getByRole('radio', { name: /Certain/ })); + await user.click(screen.getByRole('button', { name: 'Check Answer' })); + + await waitFor(() => { + expect(mockStore.appendResponse).toHaveBeenCalledWith( + expect.objectContaining({ confidence: 'certain', correct: true }) + ); + }); + }); + + it('shows the UnvettedFlag badge when the question is unvetted', async () => { + const question = makeQuestion({ unvetted: true }); + render(); + + expect(await screen.findByText('Auto-generated')).toBeInTheDocument(); + }); + + it('does not show the UnvettedFlag badge when the question is vetted', () => { + const question = makeQuestion({ unvetted: false }); + render(); + + expect(screen.queryByText('Auto-generated')).not.toBeInTheDocument(); + }); + + it('shows a "New" ScheduleBadge for a never-seen question', async () => { + mockStore.getSchedule.mockResolvedValue(null); + const question = makeQuestion(); + render(); + + expect(await screen.findByText('New')).toBeInTheDocument(); + }); + + it('shows a "Due" ScheduleBadge when the schedule entry is due now', async () => { + const dueEntry = { ...newScheduleEntry('q1'), totalReviews: 1, nextDue: Date.now() - 1000 }; + mockStore.getSchedule.mockResolvedValue(dueEntry); + + const question = makeQuestion(); + render(); + + expect(await screen.findByText('Due')).toBeInTheDocument(); + }); +}); + +describe('Quiz — mid-quiz restart (A6)', () => { + it('opens a confirm dialog from the Restart button and resets progress on confirm', async () => { + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + // Select an answer so we have state to lose. + await user.click(screen.getByRole('button', { name: /Cubic/ })); + expect(screen.getByRole('button', { name: /Cubic/ })).toHaveAttribute('aria-pressed', 'true'); + + await user.click(screen.getByRole('button', { name: 'Restart' })); + expect(screen.getByText('Restart Quiz?')).toBeInTheDocument(); + + // Two buttons are now named "Restart": the header ghost button and the + // confirm dialog's primary action. The dialog's is the one rendered last. + const restartButtons = screen.getAllByRole('button', { name: 'Restart' }); + await user.click(restartButtons[restartButtons.length - 1]); + + await waitFor(() => { + expect(screen.queryByText('Restart Quiz?')).not.toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /Cubic/ })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('cancelling the restart confirm dialog leaves progress untouched', async () => { + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + await user.click(screen.getByRole('button', { name: 'Restart' })); + expect(screen.getByText('Restart Quiz?')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByText('Restart Quiz?')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cubic/ })).toHaveAttribute('aria-pressed', 'true'); + }); +}); + +describe('Quiz — unrenderable questions and auto-offer banner (A6)', () => { + it('renders UnrenderableQuestionCard with a working Skip for a single malformed question, without showing the auto-offer banner', async () => { + const user = userEvent.setup(); + const bad = makeQuestion({ id: 'bad', options: ['OnlyOne'] }); + const good = makeQuestion({ id: 'good' }); + render(); + + expect(screen.getByText('This question could not be displayed')).toBeInTheDocument(); + expect(screen.queryByText('This saved session has a display issue — start fresh?')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Skip question' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Cubic/ })).toBeInTheDocument(); + }); + }); + + it('shows the auto-offer banner when every remaining question is unrenderable', () => { + const bad1 = makeQuestion({ id: 'bad1', options: ['OnlyOne'] }); + const bad2 = makeQuestion({ id: 'bad2', options: [] }); + render(); + + expect( + screen.getByText('This saved session has a display issue — start fresh?') + ).toBeInTheDocument(); + }); +}); + +describe('Quiz — keyboard essentials (A8)', () => { + it('pressing "1" selects the first option', async () => { + mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); + const question = makeQuestion(); + render(); + + await waitFor(() => expect(mockStore.getSettings).toHaveBeenCalled()); + + fireEvent.keyDown(document, { key: '1' }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Cubic/ })).toHaveAttribute('aria-pressed', 'true'); + }); + }); + + it('pressing Enter submits the current answer once it is selectable', async () => { + mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + await user.click(screen.getByRole('button', { name: /Cubic/ })); + fireEvent.keyDown(document, { key: 'Enter' }); + + await waitFor(() => { + expect(mockStore.appendResponse).toHaveBeenCalled(); + }); + }); + + it('digit shortcuts are inert while the restart confirm dialog is open', async () => { + mockStore.getSettings.mockResolvedValue({ ...DEFAULT_STUDY_SETTINGS, requireConfidence: false }); + const user = userEvent.setup(); + const question = makeQuestion(); + render(); + + await user.click(screen.getByRole('button', { name: 'Restart' })); + expect(screen.getByText('Restart Quiz?')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: '1' }); + + expect(screen.getByRole('button', { name: /Cubic/ })).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/src/components/quiz/Quiz.tsx b/src/components/quiz/Quiz.tsx index a85914c..b0475ce 100644 --- a/src/components/quiz/Quiz.tsx +++ b/src/components/quiz/Quiz.tsx @@ -1,15 +1,48 @@ /** * Main Quiz component for practice mode. * Combines question display, navigation, and results. + * + * Study v1 additions (A4b): + * - Loads StudySettings once via getStudyStore() (StudyReviewScreen pattern). + * - Renders ConfidenceTap (gated on settings.requireConfidence) between answer + * selection and submit; disables "Check Answer" until a confidence is chosen + * when required. Confidence is passed through to submitAnswer(). + * - Loads the SM-2 ScheduleEntry per question and passes it to QuestionCard as + * a ScheduleBadge. + * - Shows UnvettedFlag in the header for auto-generated (unvetted) questions. + * - RationalePanel is gated via QuestionCard's showRationalePanel prop, driven + * by settings.showRationaleOnSubmit. + * + * A6 additions: + * - Header row with a ghost "Restart" button that opens a shared ConfirmDialog + * before calling the existing resetQuiz(). + * - Structurally unrenderable questions fall back to UnrenderableQuestionCard + * (mirrors Exam.tsx) with a per-question Skip action. + * - Auto-offer banner ("This saved session has a display issue — start + * fresh?") shown only when ALL remaining questions are unrenderable. + * + * A8 additions: + * - useQuizKeyboard wires digits 1-4 (select option) and Enter (submit/ + * advance) shortcuts; inert while the restart confirm dialog is open. */ -import { useState, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import type { Question, QuizConfig } from '../../lib/quiz'; +import { isRenderable } from '../../lib/quiz'; import { useQuiz } from '../../hooks/useQuiz'; +import { useQuizKeyboard } from '../../hooks/useQuizKeyboard'; import { QuestionCard } from './QuestionCard'; +import { UnrenderableQuestionCard } from './UnrenderableQuestionCard'; import { QuizProgress } from './QuizProgress'; import { QuizResults } from './QuizResults'; import { Button } from '../ui/Button'; +import { ConfirmDialog } from './ConfirmDialog'; +import { ConfidenceTap } from './study/ConfidenceTap'; +import { ScheduleBadge } from './study/ScheduleBadge'; +import { UnvettedFlag } from './study/UnvettedFlag'; +import { getStudyStore } from '../../lib/quiz/store'; +import { DEFAULT_STUDY_SETTINGS } from '../../lib/quiz/study-types'; +import type { Confidence, ScheduleEntry, StudySettings } from '../../lib/quiz/study-types'; interface QuizProps { /** Quiz configuration */ @@ -42,8 +75,107 @@ export function Quiz({ previousQuestion, submitQuiz, resetQuiz, + skipQuestion, } = useQuiz({ config, questions, persist: true }); + // Restart confirmation dialog (A6). + const [showRestartConfirm, setShowRestartConfirm] = useState(false); + + // Study store singleton — stable reference. + const store = useRef(getStudyStore()).current; + + // User-tunable study behaviour (confidence requirement, rationale visibility). + const [settings, setSettings] = useState(DEFAULT_STUDY_SETTINGS); + useEffect(() => { + let mounted = true; + store.getSettings().then(loaded => { + if (mounted) setSettings(loaded); + }).catch((err: unknown) => { + console.warn('[Quiz] getSettings failed:', err); + }); + return () => { + mounted = false; + }; + }, [store]); + + // Confidence captured for the current question, reset whenever the question changes. + const [confidence, setConfidence] = useState(null); + const currentQuestionId = currentQuestion?.id; + useEffect(() => { + setConfidence(null); + }, [currentQuestionId]); + + // SM-2 schedule entry for the current question (drives the ScheduleBadge). + const [scheduleEntry, setScheduleEntry] = useState(null); + useEffect(() => { + if (!currentQuestionId) { + setScheduleEntry(null); + return; + } + let mounted = true; + store.getSchedule(currentQuestionId).then(entry => { + if (mounted) setScheduleEntry(entry); + }).catch((err: unknown) => { + console.warn('[Quiz] getSchedule failed:', err); + }); + return () => { + mounted = false; + }; + }, [currentQuestionId, store]); + + // Derived values used by both rendering and keyboard shortcuts. Computed + // here (before the early returns below) to satisfy the Rules of Hooks, + // since useQuizKeyboard and its callbacks must also be called unconditionally. + const isLastQuestion = state.currentIndex === state.questions.length - 1; + const allAnswered = state.answers.size === state.questions.length; + + // A6: only offer a "start fresh" escape hatch when every remaining question + // is structurally unrenderable — a single bad question is handled inline by + // the per-question Skip button on UnrenderableQuestionCard. + const remainingQuestions = state.questions.slice(state.currentIndex); + const allRemainingUnrenderable = + remainingQuestions.length > 0 && remainingQuestions.every(q => !isRenderable(q)); + + // Confidence is only required to unlock "Check Answer" when the setting is on. + const confidenceSatisfied = !settings.requireConfidence || confidence !== null; + const canCheckAnswer = hasAnswer && confidenceSatisfied; + + const handleSubmitAnswer = useCallback(() => { + submitAnswer(confidence ?? 'fairly-sure'); + }, [submitAnswer, confidence]); + + // A8: digit keys 1-4 select an option (multiple-choice/true-false only); + // suppressed while typing in an input/textarea/select (fill-blank/matching safe). + const handleSelectIndex = useCallback((index: number) => { + if (!currentQuestion || isSubmitted) return; + if (currentQuestion.type !== 'multiple-choice' && currentQuestion.type !== 'true-false') return; + const options = currentQuestion.options; + if (!options || index < 0 || index >= options.length) return; + selectAnswer(options[index]); + }, [currentQuestion, isSubmitted, selectAnswer]); + + // A8: Enter submits the current answer (once confidence, if required, is + // satisfied), or advances to the next question / finishes the quiz once the + // current question has been submitted. + const handleEnter = useCallback(() => { + if (!currentQuestion) return; + if (!isSubmitted) { + if (canCheckAnswer) handleSubmitAnswer(); + return; + } + if (isLastQuestion) { + submitQuiz(); + } else { + nextQuestion(); + } + }, [currentQuestion, isSubmitted, canCheckAnswer, handleSubmitAnswer, isLastQuestion, submitQuiz, nextQuestion]); + + useQuizKeyboard({ + enabled: !isComplete && !!currentQuestion && !showRestartConfirm, + onSelectIndex: handleSelectIndex, + onEnter: handleEnter, + }); + // Show results if quiz is complete if (isComplete && results) { return ( @@ -67,11 +199,18 @@ export function Quiz({ ); } - const isLastQuestion = state.currentIndex === state.questions.length - 1; - const allAnswered = state.answers.size === state.questions.length; - return (
+ {/* Header row: mid-quiz Restart (A6) */} +
+ +
+ {/* Progress bar */} + {/* Auto-offer banner: only when every remaining question is unrenderable */} + {allRemainingUnrenderable && ( +
+

+ This saved session has a display issue — start fresh? +

+ +
+ )} + {/* Question card */} - selectAnswer(answer)} - /> + {isRenderable(currentQuestion) ? ( + selectAnswer(answer)} + headerExtra={} + scheduleBadge={} + showRationalePanel={settings.showRationaleOnSubmit} + /> + ) : ( + + )} + + {/* Confidence tap — shown once an answer is selected, before submit */} + {config.mode === 'practice' && settings.requireConfidence && hasAnswer && !isSubmitted && ( + + )} {/* Navigation */}
@@ -107,7 +278,7 @@ export function Quiz({
{/* Submit answer button (practice mode) */} {config.mode === 'practice' && !isSubmitted && hasAnswer && ( - )} @@ -150,6 +321,23 @@ export function Quiz({
)} + + {/* Restart confirmation (A6) */} + { + setShowRestartConfirm(false); + resetQuiz(); + }} + onCancel={() => setShowRestartConfirm(false)} + > +

+ This will clear your current answers and start the quiz over from the first question. +

+
); } diff --git a/src/components/quiz/QuizPage.tsx b/src/components/quiz/QuizPage.tsx index 03bc5e4..d76bee7 100644 --- a/src/components/quiz/QuizPage.tsx +++ b/src/components/quiz/QuizPage.tsx @@ -1,39 +1,84 @@ /** * QuizPage - top-level quiz interface (practice + exam modes). + * + * A4c: `handleStart` is async and gated behind the existing `isLoading` + * state — it prefers due-aware session selection (`selectSessionQuestions`, + * backed by the SM-2 study store) and falls back to the plain + * shuffle/slice selection internally if the store is unavailable or + * throws, so quiz start can never hang. */ -import { useState, useEffect } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { QuizSetup, Quiz, Exam } from './index'; import type { Question, QuizConfig, Category, LearnEntry } from '../../lib/quiz'; -import { generateQuestions, selectQuestions, getQuestionStats } from '../../lib/quiz'; +import { + generateQuestions, + getQuestionStats, + selectSessionQuestions, + getStudyStore, + DEFAULT_STUDY_SETTINGS, +} from '../../lib/quiz'; +import { STORAGE_KEYS } from '../../hooks/useLocalStorage'; interface QuizPageProps { /** Learn content entries for generating questions */ learnEntries: LearnEntry[]; + /** Curated, expert-vetted question bank entries (already mapped to Question). Curated-first. */ + curatedQuestions?: Question[]; } -export function QuizPage({ learnEntries }: QuizPageProps) { +export function QuizPage({ learnEntries, curatedQuestions = [] }: QuizPageProps) { const [allQuestions, setAllQuestions] = useState([]); const [selectedQuestions, setSelectedQuestions] = useState([]); const [config, setConfig] = useState(null); const [timeLimit, setTimeLimit] = useState(30 * 60); const [isLoading, setIsLoading] = useState(true); + const store = useRef(getStudyStore()).current; useEffect(() => { - const questions = generateQuestions(learnEntries); - setAllQuestions(questions); + const autoGenerated = generateQuestions(learnEntries); + // Curated-first: the pool order matters for selection (fresh/new items + // are preferred in pool order), so the vetted bank leads. + const curatedIds = new Set(curatedQuestions.map(q => q.id)); + const merged = [ + ...curatedQuestions, + ...autoGenerated.filter(q => !curatedIds.has(q.id)), + ]; + setAllQuestions(merged); setIsLoading(false); - }, [learnEntries]); + }, [learnEntries, curatedQuestions]); const stats = getQuestionStats(allQuestions); const availableQuestions = stats.byCategory as Record; - const handleStart = (newConfig: QuizConfig, examTimeLimit?: number) => { - const questions = selectQuestions(allQuestions, newConfig); - setSelectedQuestions(questions); - setConfig(newConfig); - if (examTimeLimit) { - setTimeLimit(examTimeLimit); + const handleStart = async (newConfig: QuizConfig, examTimeLimit?: number) => { + setIsLoading(true); + try { + let settings = DEFAULT_STUDY_SETTINGS; + try { + settings = await store.getSettings(); + } catch (err) { + console.warn('[QuizPage] getSettings failed, using defaults:', err); + } + + // selectSessionQuestions never throws — it falls back to the plain + // shuffle().slice() selection internally on any store failure, so + // quiz start can never hang here. + const questions = await selectSessionQuestions(allQuestions, newConfig, store, settings); + // An explicit Start is always a fresh session: discard any persisted + // in-progress/completed state so useQuiz/useExam never resurrect a + // stale session into the newly chosen configuration. + if (typeof window !== 'undefined') { + window.localStorage.removeItem(STORAGE_KEYS.QUIZ_STATE); + window.localStorage.removeItem(STORAGE_KEYS.EXAM_STATE); + } + setSelectedQuestions(questions); + setConfig(newConfig); + if (examTimeLimit) { + setTimeLimit(examTimeLimit); + } + } finally { + setIsLoading(false); } }; @@ -46,7 +91,7 @@ export function QuizPage({ learnEntries }: QuizPageProps) {
-

Practice and Exam Mode

+

Practice and Exam Mode

756 questions across all FGA subject areas. Practise at your own pace, or sit a timed mock exam.

diff --git a/src/components/quiz/QuizSetup.test.tsx b/src/components/quiz/QuizSetup.test.tsx new file mode 100644 index 0000000..0c9bd1b --- /dev/null +++ b/src/components/quiz/QuizSetup.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QuizSetup } from './QuizSetup'; +import { CATEGORIES } from '../../lib/quiz'; +import type { Category } from '../../lib/quiz'; + +function makeAvailableQuestions(overrides: Partial> = {}): Record { + const base = Object.fromEntries(CATEGORIES.map(c => [c, 10])) as Record; + return { ...base, ...overrides }; +} + +describe('QuizSetup — category multi-select affordance (A5)', () => { + it('shows the "N of 8 categories selected" helper line, updating as categories toggle', async () => { + const user = userEvent.setup(); + render( + + ); + + // Default: only 'fundamentals' pre-selected. + expect(screen.getByText('1 of 8 categories selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Equipment/ })); + expect(screen.getByText('2 of 8 categories selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Gem Species/ })); + expect(screen.getByText('3 of 8 categories selected')).toBeInTheDocument(); + }); + + it('marks selected category buttons with aria-pressed="true" and a check icon, unselected with "false"', async () => { + const user = userEvent.setup(); + render( + + ); + + const fundamentalsBtn = screen.getByRole('button', { name: /Fundamentals/ }); + const equipmentBtn = screen.getByRole('button', { name: /Equipment/ }); + + expect(fundamentalsBtn).toHaveAttribute('aria-pressed', 'true'); + expect(equipmentBtn).toHaveAttribute('aria-pressed', 'false'); + + await user.click(equipmentBtn); + expect(equipmentBtn).toHaveAttribute('aria-pressed', 'true'); + + await user.click(equipmentBtn); + expect(equipmentBtn).toHaveAttribute('aria-pressed', 'false'); + }); + + it('does not allow deselecting the last remaining category (min-1 rule preserved)', async () => { + const user = userEvent.setup(); + render( + + ); + + const fundamentalsBtn = screen.getByRole('button', { name: /Fundamentals/ }); + expect(fundamentalsBtn).toHaveAttribute('aria-pressed', 'true'); + + // Only one category selected — clicking it again must be a no-op. + await user.click(fundamentalsBtn); + + expect(fundamentalsBtn).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('1 of 8 categories selected')).toBeInTheDocument(); + }); + + it('supports selecting multiple categories simultaneously', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /Equipment/ })); + await user.click(screen.getByRole('button', { name: /Gem Species/ })); + await user.click(screen.getByRole('button', { name: /Identification/ })); + + expect(screen.getByText('4 of 8 categories selected')).toBeInTheDocument(); + for (const name of [/Fundamentals/, /Equipment/, /Gem Species/, /Identification/]) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true'); + } + }); +}); diff --git a/src/components/quiz/QuizSetup.tsx b/src/components/quiz/QuizSetup.tsx index 84c3141..d7d86ea 100644 --- a/src/components/quiz/QuizSetup.tsx +++ b/src/components/quiz/QuizSetup.tsx @@ -161,20 +161,34 @@ export function QuizSetup({ type="button" onClick={() => toggleCategory(category)} disabled={count === 0} + aria-pressed={isSelected} className={cn( - 'px-4 py-2 rounded-lg text-sm font-medium transition-all', + 'relative px-4 py-2 rounded-lg text-sm font-medium transition-all', 'border-2', isSelected && 'border-crystal-500 bg-crystal-50 text-crystal-700', !isSelected && count > 0 && 'border-slate-200 text-slate-600 hover:border-slate-300', count === 0 && 'border-slate-100 text-slate-300 cursor-not-allowed' )} > + {isSelected && ( + + )} {CATEGORY_LABELS[category]} ({count}) ); })}
+

+ {selectedCategories.length} of {CATEGORIES.length} categories selected +

diff --git a/src/components/quiz/UnrenderableQuestionCard.tsx b/src/components/quiz/UnrenderableQuestionCard.tsx new file mode 100644 index 0000000..b6760b6 --- /dev/null +++ b/src/components/quiz/UnrenderableQuestionCard.tsx @@ -0,0 +1,50 @@ +/** + * Fallback card shown in place of a structurally unrenderable question + * (e.g. a matching question that lost its pairs, or an MCQ with fewer than + * 2 options). Never scored — the caller must call `onSkip` to advance. + */ + +import { AlertTriangle } from 'lucide-react'; +import { Button } from '../ui/Button'; +import { IconBox } from '../ui/IconBox'; + +interface UnrenderableQuestionCardProps { + /** Current question number (1-based) */ + questionNumber: number; + /** Total number of questions */ + totalQuestions: number; + /** Called when the user dismisses this question */ + onSkip: () => void; +} + +export function UnrenderableQuestionCard({ + questionNumber, + totalQuestions, + onSkip, +}: UnrenderableQuestionCardProps) { + return ( +
+
+ + Question {questionNumber} of {totalQuestions} + +
+ +
+ + +
+

This question could not be displayed

+

+ It is missing data required for its question type. It has not been scored — skip + ahead to continue. +

+
+ +
+
+ ); +} diff --git a/src/components/quiz/index.ts b/src/components/quiz/index.ts index 7944785..2dbd988 100644 --- a/src/components/quiz/index.ts +++ b/src/components/quiz/index.ts @@ -5,6 +5,7 @@ // Practice mode components export { AnswerOption, OPTION_LABELS } from './AnswerOption'; export { QuestionCard } from './QuestionCard'; +export { UnrenderableQuestionCard } from './UnrenderableQuestionCard'; export { QuizProgress, QuizProgressDots } from './QuizProgress'; export { QuizResults } from './QuizResults'; export { QuizSetup } from './QuizSetup'; diff --git a/src/components/quiz/study/ConfidenceTap.tsx b/src/components/quiz/study/ConfidenceTap.tsx index 537d361..5275bbf 100644 --- a/src/components/quiz/study/ConfidenceTap.tsx +++ b/src/components/quiz/study/ConfidenceTap.tsx @@ -40,7 +40,7 @@ const OPTIONS: ConfidenceOption[] = [ label: 'Unsure', shortcut: 'Q', selectedClass: 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300 dark:border-red-500', - idleClass: 'border-slate-200 text-slate-600 hover:border-red-300 hover:bg-red-50/50 dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-700', + idleClass: 'border-slate-200 text-slate-700 hover:border-red-300 hover:bg-red-50/50 dark:border-slate-700 dark:text-slate-300 dark:hover:border-red-700', description: 'I guessed or am not confident', }, { @@ -48,7 +48,7 @@ const OPTIONS: ConfidenceOption[] = [ label: 'Fairly sure', shortcut: 'W', selectedClass: 'border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-500', - idleClass: 'border-slate-200 text-slate-600 hover:border-amber-300 hover:bg-amber-50/50 dark:border-slate-700 dark:text-slate-400 dark:hover:border-amber-700', + idleClass: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50/50 dark:border-slate-700 dark:text-slate-300 dark:hover:border-amber-700', description: 'I am reasonably confident', }, { @@ -56,7 +56,7 @@ const OPTIONS: ConfidenceOption[] = [ label: 'Certain', shortcut: 'E', selectedClass: 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-500', - idleClass: 'border-slate-200 text-slate-600 hover:border-emerald-300 hover:bg-emerald-50/50 dark:border-slate-700 dark:text-slate-400 dark:hover:border-emerald-700', + idleClass: 'border-slate-200 text-slate-700 hover:border-emerald-300 hover:bg-emerald-50/50 dark:border-slate-700 dark:text-slate-300 dark:hover:border-emerald-700', description: 'I am very confident in this answer', }, ]; diff --git a/src/components/tools/ToolsHub.tsx b/src/components/tools/ToolsHub.tsx index e6e54cd..e830457 100644 --- a/src/components/tools/ToolsHub.tsx +++ b/src/components/tools/ToolsHub.tsx @@ -64,14 +64,7 @@ export function ToolsHub() { return (
-
-

Tools

-

- Calculators for the bench: SG by hydrostatic weighing, RI lookup, birefringence, critical angle, carat estimation, and unit conversion. Also includes dichroscope, polariscope, and Chelsea-filter references. -

-
- -
+
{tools.map((tool) => ( diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index b99adbc..d4ec0a4 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -27,7 +27,7 @@ export function SearchInput({ className, value, onClear, ...props }: SearchInput className={twMerge( clsx( 'w-full rounded-lg border border-slate-300 bg-white py-2 pl-10 pr-10', - 'text-slate-900 placeholder-slate-400', + 'text-slate-900 placeholder-slate-500', 'focus:border-crystal-500 focus:outline-none focus:ring-2 focus:ring-crystal-500/20', 'transition-colors' ), diff --git a/src/hooks/useExam.test.ts b/src/hooks/useExam.test.ts new file mode 100644 index 0000000..2867a08 --- /dev/null +++ b/src/hooks/useExam.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import type { Question, QuizConfig } from '../lib/quiz'; + +// ── Mock the study store singleton so SM-2 wiring is observable. ─────────── +const mockStore = { + appendResponse: vi.fn().mockResolvedValue(undefined), + getResponsesFor: vi.fn().mockResolvedValue([]), + getRecentResponses: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockResolvedValue(null), + updateSchedule: vi.fn().mockResolvedValue(undefined), + getDueItems: vi.fn().mockResolvedValue([]), + getProgress: vi.fn().mockResolvedValue({ + completedTopics: { + fundamentals: [], equipment: [], species: [], identification: [], + phenomena: [], origin: [], market: [], care: [], + }, + bestScores: { + fundamentals: 0, equipment: 0, species: 0, identification: 0, + phenomena: 0, origin: 0, market: 0, care: 0, + }, + totalQuizzes: 0, + totalCorrect: 0, + totalAttempted: 0, + lastActivity: 0, + }), + updateProgress: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn(), + updateSettings: vi.fn(), + exportAll: vi.fn(), + importAll: vi.fn(), +}; + +vi.mock('../lib/quiz/store', () => ({ + getStudyStore: () => mockStore, +})); + +const { useExam } = await import('./useExam'); + +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, + }; +} + +function makeConfig(overrides: Partial = {}): QuizConfig { + return { + categories: ['fundamentals'], + questionCount: 1, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'exam', + ...overrides, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + mockStore.appendResponse.mockResolvedValue(undefined); + mockStore.getSchedule.mockResolvedValue(null); + mockStore.updateSchedule.mockResolvedValue(undefined); +}); + +describe('useExam — SM-2 wiring (A4a)', () => { + it('submitExam updates the schedule for every answered question', async () => { + const q1 = makeQuestion({ id: 'q1', correctAnswer: 'Cubic' }); + const q2 = makeQuestion({ id: 'q2', correctAnswer: 'Trigonal' }); + + const { result } = renderHook(() => + useExam({ config: makeConfig(), questions: [q1, q2], timeLimit: 600, autoSubmitOnTimeout: false }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); // q1, correct + }); + act(() => { + result.current.nextQuestion(); + }); + act(() => { + result.current.selectAnswer('Monoclinic'); // q2, incorrect + }); + + act(() => { + result.current.submitExam(new Map([ + ['q1', 'certain'], + ['q2', 'unsure'], + ])); + }); + + await waitFor(() => { + expect(mockStore.updateSchedule).toHaveBeenCalledTimes(2); + }); + + const writtenIds = mockStore.updateSchedule.mock.calls.map(([entry]) => entry.questionId).sort(); + expect(writtenIds).toEqual(['q1', 'q2']); + }); + + it('does not throw when updateSchedule rejects (fire-and-forget)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockStore.updateSchedule.mockRejectedValue(new Error('boom')); + + const q1 = makeQuestion({ id: 'q1' }); + const { result } = renderHook(() => + useExam({ config: makeConfig(), questions: [q1], timeLimit: 600, autoSubmitOnTimeout: false }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); + }); + + expect(() => { + act(() => { + result.current.submitExam(); + }); + }).not.toThrow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + '[useExam] updateSchedule failed:', + expect.any(Error) + ); + }); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useExam.ts b/src/hooks/useExam.ts index 9777f6b..eff2f8b 100644 --- a/src/hooks/useExam.ts +++ b/src/hooks/useExam.ts @@ -24,6 +24,8 @@ import { import { useLocalStorage, STORAGE_KEYS } from './useLocalStorage'; import { getStudyStore } from '../lib/quiz/store'; import { updateProgress } from '../lib/quiz/progress-tracker'; +import { qualityOf, applySM2 } from '../lib/quiz/scheduler'; +import { newScheduleEntry } from '../lib/quiz/study-types'; import type { Confidence } from '../lib/quiz/study-types'; interface UseExamOptions { @@ -89,6 +91,8 @@ interface UseExamReturn { pauseTimer: () => void; /** Resume the timer */ resumeTimer: () => void; + /** Mark the current question as skipped (not scored) and advance if possible */ + skipQuestion: () => void; } interface SerializedExamState { @@ -128,7 +132,11 @@ export function useExam({ if (savedState) { try { const parsed: SerializedExamState = JSON.parse(savedState); - return deserializeQuizState(JSON.parse(parsed.quizState)); + const restored = deserializeQuizState(JSON.parse(parsed.quizState)); + // Never restore an already-submitted exam as the current session. + if (!restored.submitted) { + return restored; + } } catch { // Ignore parse errors } @@ -141,6 +149,7 @@ export function useExam({ startTime: Date.now(), flaggedQuestions: new Set(), submitted: false, + skippedQuestions: new Set(), }; }); @@ -303,6 +312,26 @@ export function useExam({ return state.answers.has(questionId); }, [state.answers]); + /** + * Mark the current question as skipped (e.g. structurally unrenderable). + * Skipped questions are excluded from scoring but remain visible in results. + * Advances to the next question when one is available. + */ + const skipQuestion = useCallback(() => { + if (!currentQuestion) return; + + setState(prev => { + const newSkipped = new Set(prev.skippedQuestions); + newSkipped.add(currentQuestion.id); + const hasNext = prev.currentIndex < prev.questions.length - 1; + return { + ...prev, + skippedQuestions: newSkipped, + currentIndex: hasNext ? prev.currentIndex + 1 : prev.currentIndex, + }; + }); + }, [currentQuestion]); + /** * Submit the exam. * @@ -356,6 +385,16 @@ export function useExam({ }).catch((err: unknown) => { console.warn('[useExam] appendResponse failed:', err); }); + + // --- Study v1: update the SM-2 schedule for this question --- + store.getSchedule(questionId).then(existing => { + const previous = existing ?? newScheduleEntry(questionId); + const quality = qualityOf(correct, confidence); + const next = applySM2(previous, quality); + return store.updateSchedule(next); + }).catch((err: unknown) => { + console.warn('[useExam] updateSchedule failed:', err); + }); } // --- Study v1: update progress --- @@ -383,6 +422,7 @@ export function useExam({ startTime: Date.now(), flaggedQuestions: new Set(), submitted: false, + skippedQuestions: new Set(), }); }, [questions, timeLimit, clearSavedState]); @@ -419,5 +459,6 @@ export function useExam({ resetExam, pauseTimer, resumeTimer, + skipQuestion, }; } diff --git a/src/hooks/useQuiz.test.ts b/src/hooks/useQuiz.test.ts new file mode 100644 index 0000000..3e6e212 --- /dev/null +++ b/src/hooks/useQuiz.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import type { Question, QuizConfig } from '../lib/quiz'; +import { newScheduleEntry } from '../lib/quiz/study-types'; +import { STORAGE_KEYS } from './useLocalStorage'; + +// ── Mock the study store singleton so SM-2 wiring is observable without +// touching real localStorage-backed schedule state. ───────────────────── +const mockStore = { + appendResponse: vi.fn().mockResolvedValue(undefined), + getResponsesFor: vi.fn().mockResolvedValue([]), + getRecentResponses: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockResolvedValue(null), + updateSchedule: vi.fn().mockResolvedValue(undefined), + getDueItems: vi.fn().mockResolvedValue([]), + getProgress: vi.fn().mockResolvedValue({ + completedTopics: { + fundamentals: [], equipment: [], species: [], identification: [], + phenomena: [], origin: [], market: [], care: [], + }, + bestScores: { + fundamentals: 0, equipment: 0, species: 0, identification: 0, + phenomena: 0, origin: 0, market: 0, care: 0, + }, + totalQuizzes: 0, + totalCorrect: 0, + totalAttempted: 0, + lastActivity: 0, + }), + updateProgress: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn(), + updateSettings: vi.fn(), + exportAll: vi.fn(), + importAll: vi.fn(), +}; + +vi.mock('../lib/quiz/store', () => ({ + getStudyStore: () => mockStore, +})); + +// Import after the mock so useQuiz picks up the mocked store module. +const { useQuiz } = await import('./useQuiz'); + +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, + }; +} + +function makeConfig(overrides: Partial = {}): QuizConfig { + return { + categories: ['fundamentals'], + questionCount: 1, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'practice', + ...overrides, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + mockStore.appendResponse.mockResolvedValue(undefined); + mockStore.getSchedule.mockResolvedValue(null); + mockStore.updateSchedule.mockResolvedValue(undefined); +}); + +describe('useQuiz — SM-2 wiring (A4a)', () => { + it('submitAnswer reads the existing schedule and writes back an SM-2-updated entry', async () => { + const question = makeQuestion(); + const { result } = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question] }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); + }); + act(() => { + result.current.submitAnswer('certain'); + }); + + await waitFor(() => { + expect(mockStore.getSchedule).toHaveBeenCalledWith('q1'); + expect(mockStore.updateSchedule).toHaveBeenCalledTimes(1); + }); + + const written = mockStore.updateSchedule.mock.calls[0][0]; + expect(written.questionId).toBe('q1'); + // Correct + 'certain' => SM-2 quality 5 => first repetition => intervalDays 1. + expect(written.repetitions).toBe(1); + expect(written.intervalDays).toBe(1); + expect(written.lapses).toBe(0); + }); + + it('falls back to a fresh schedule entry when none exists yet', async () => { + mockStore.getSchedule.mockResolvedValue(null); + const question = makeQuestion(); + const { result } = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question] }) + ); + + act(() => { + result.current.selectAnswer('Trigonal'); // incorrect + }); + act(() => { + result.current.submitAnswer('unsure'); + }); + + await waitFor(() => { + expect(mockStore.updateSchedule).toHaveBeenCalledTimes(1); + }); + + const written = mockStore.updateSchedule.mock.calls[0][0]; + // Incorrect + 'unsure' => SM-2 quality 2 => lapse. + expect(written.repetitions).toBe(0); + expect(written.intervalDays).toBe(1); + expect(written.lapses).toBe(1); + }); + + it('builds the next schedule from an existing entry rather than starting fresh', async () => { + const existing = { ...newScheduleEntry('q1'), repetitions: 1, intervalDays: 1, easeFactor: 2.5 }; + mockStore.getSchedule.mockResolvedValue(existing); + + const question = makeQuestion(); + const { result } = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question] }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); + }); + act(() => { + result.current.submitAnswer('certain'); + }); + + await waitFor(() => { + expect(mockStore.updateSchedule).toHaveBeenCalledTimes(1); + }); + + const written = mockStore.updateSchedule.mock.calls[0][0]; + // Second successful repetition => intervalDays jumps to 6 per SM-2. + expect(written.repetitions).toBe(2); + expect(written.intervalDays).toBe(6); + }); + + it('does not block on a rejected updateSchedule call (fire-and-forget)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockStore.updateSchedule.mockRejectedValueOnce(new Error('boom')); + + const question = makeQuestion(); + const { result } = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question] }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); + }); + expect(() => { + act(() => { + result.current.submitAnswer('certain'); + }); + }).not.toThrow(); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + '[useQuiz] updateSchedule failed:', + expect.any(Error) + ); + }); + + warnSpy.mockRestore(); + }); +}); + +describe('useQuiz — persisted-state restore guard', () => { + it('restores a persisted in-progress (unsubmitted) session', () => { + const question = makeQuestion(); + const first = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question], persist: true }) + ); + act(() => { + first.result.current.selectAnswer('Cubic'); + }); + first.unmount(); + + const second = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question], persist: true }) + ); + expect(second.result.current.state.answers.get('q1')).toBe('Cubic'); + }); + + it('never restores an already-submitted session as the current one', () => { + const question = makeQuestion(); + const first = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question], persist: true }) + ); + act(() => { + first.result.current.selectAnswer('Cubic'); + first.result.current.submitAnswer(); + }); + act(() => { + first.result.current.submitQuiz(); + }); + // useLocalStorage JSON-encodes the (already serialized) state string. + const raw = window.localStorage.getItem(STORAGE_KEYS.QUIZ_STATE)!; + expect(JSON.parse(JSON.parse(raw)).submitted).toBe(true); + first.unmount(); + + const fresh = makeQuestion({ id: 'q2', questionText: 'A different question?' }); + const second = renderHook(() => + useQuiz({ config: makeConfig(), questions: [fresh], persist: true }) + ); + expect(second.result.current.state.submitted).toBe(false); + expect(second.result.current.state.questions[0].id).toBe('q2'); + expect(second.result.current.state.answers.size).toBe(0); + }); +}); + +describe('useQuiz — resetQuiz storage clearing', () => { + it('clears the persisted quiz-state localStorage key', () => { + const question = makeQuestion(); + const { result } = renderHook(() => + useQuiz({ config: makeConfig(), questions: [question], persist: true }) + ); + + act(() => { + result.current.selectAnswer('Cubic'); + }); + expect(window.localStorage.getItem(STORAGE_KEYS.QUIZ_STATE)).not.toBeNull(); + + act(() => { + result.current.resetQuiz(); + }); + + expect(window.localStorage.getItem(STORAGE_KEYS.QUIZ_STATE)).toBeNull(); + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.answers.size).toBe(0); + }); +}); diff --git a/src/hooks/useQuiz.ts b/src/hooks/useQuiz.ts index c0838d0..7c28af1 100644 --- a/src/hooks/useQuiz.ts +++ b/src/hooks/useQuiz.ts @@ -24,6 +24,8 @@ import { import { useLocalStorage, STORAGE_KEYS } from './useLocalStorage'; import { getStudyStore } from '../lib/quiz/store'; import { updateProgress } from '../lib/quiz/progress-tracker'; +import { qualityOf, applySM2 } from '../lib/quiz/scheduler'; +import { newScheduleEntry } from '../lib/quiz/study-types'; import type { Confidence } from '../lib/quiz/study-types'; interface UseQuizOptions { @@ -66,6 +68,8 @@ interface UseQuizReturn { submitQuiz: () => void; /** Reset the quiz */ resetQuiz: () => void; + /** Mark the current question as skipped (not scored) and advance if possible */ + skipQuestion: () => void; } /** Generate a random session identifier. */ @@ -98,10 +102,15 @@ export function useQuiz({ // Initialize quiz state const [state, setState] = useState(() => { - // Try to restore from localStorage if persisting + // Try to restore from localStorage if persisting. Never restore an + // already-submitted session — a completed quiz must not resurrect as + // the current session when a new one starts. if (persist && savedState) { try { - return deserializeQuizState(JSON.parse(savedState)); + const restored = deserializeQuizState(JSON.parse(savedState)); + if (!restored.submitted) { + return restored; + } } catch { // Ignore parse errors } @@ -114,6 +123,7 @@ export function useQuiz({ startTime: Date.now(), flaggedQuestions: new Set(), submitted: false, + skippedQuestions: new Set(), }; }); @@ -203,6 +213,17 @@ export function useQuiz({ }).catch((err: unknown) => { console.warn('[useQuiz] appendResponse failed:', err); }); + + // --- Study v1: update the SM-2 schedule for this question --- + const questionId = currentQuestion.id; + store.getSchedule(questionId).then(existing => { + const previous = existing ?? newScheduleEntry(questionId); + const quality = qualityOf(correct, confidence); + const next = applySM2(previous, quality); + return store.updateSchedule(next); + }).catch((err: unknown) => { + console.warn('[useQuiz] updateSchedule failed:', err); + }); }, [currentQuestion, hasAnswer, state.answers, store, sessionId]); // Navigate to next question; reset the per-question timer. @@ -254,6 +275,29 @@ export function useQuiz({ }); }, [currentQuestion, state, saveState]); + /** + * Mark the current question as skipped (e.g. structurally unrenderable). + * Skipped questions are excluded from scoring but remain visible in results. + * Advances to the next question when one is available. + */ + const skipQuestion = useCallback(() => { + if (!currentQuestion) return; + + const newSkipped = new Set(state.skippedQuestions); + newSkipped.add(currentQuestion.id); + + const hasNext = state.currentIndex < state.questions.length - 1; + if (hasNext) { + questionStartTime.current = Date.now(); + } + + saveState({ + ...state, + skippedQuestions: newSkipped, + currentIndex: hasNext ? state.currentIndex + 1 : state.currentIndex, + }); + }, [currentQuestion, state, saveState]); + /** * Submit the entire quiz. * After state update, wires progress-tracker and persists via the store. @@ -289,6 +333,7 @@ export function useQuiz({ startTime: Date.now(), flaggedQuestions: new Set(), submitted: false, + skippedQuestions: new Set(), }); }, [questions, clearSavedState]); @@ -308,5 +353,6 @@ export function useQuiz({ toggleFlag, submitQuiz, resetQuiz, + skipQuestion, }; } diff --git a/src/hooks/useQuizKeyboard.test.ts b/src/hooks/useQuizKeyboard.test.ts new file mode 100644 index 0000000..45f2488 --- /dev/null +++ b/src/hooks/useQuizKeyboard.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { classifyKeyEvent } from './useQuizKeyboard'; +import type { ClassifiableKeyEvent } from './useQuizKeyboard'; + +function makeEvent(overrides: Partial = {}): ClassifiableKeyEvent { + return { + key: '1', + target: document.createElement('div'), + ctrlKey: false, + metaKey: false, + altKey: false, + ...overrides, + }; +} + +describe('classifyKeyEvent (A8)', () => { + it('classifies digits 1-4 as select-index with 0-based indices', () => { + expect(classifyKeyEvent(makeEvent({ key: '1' }), true)).toEqual({ type: 'select-index', index: 0 }); + expect(classifyKeyEvent(makeEvent({ key: '2' }), true)).toEqual({ type: 'select-index', index: 1 }); + expect(classifyKeyEvent(makeEvent({ key: '3' }), true)).toEqual({ type: 'select-index', index: 2 }); + expect(classifyKeyEvent(makeEvent({ key: '4' }), true)).toEqual({ type: 'select-index', index: 3 }); + }); + + it('ignores digits outside 1-4', () => { + expect(classifyKeyEvent(makeEvent({ key: '5' }), true)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: '0' }), true)).toEqual({ type: 'none' }); + }); + + it('classifies Enter as "enter"', () => { + expect(classifyKeyEvent(makeEvent({ key: 'Enter' }), true)).toEqual({ type: 'enter' }); + }); + + it('suppresses digit selection when focus is in an input, textarea, or select', () => { + for (const tagName of ['input', 'textarea', 'select']) { + const target = document.createElement(tagName); + expect(classifyKeyEvent(makeEvent({ key: '1', target }), true)).toEqual({ type: 'none' }); + } + }); + + it('does NOT suppress Enter when focus is in an input (fill-blank safe)', () => { + const target = document.createElement('input'); + expect(classifyKeyEvent(makeEvent({ key: 'Enter', target }), true)).toEqual({ type: 'enter' }); + }); + + it('ignores everything when enabled is false', () => { + expect(classifyKeyEvent(makeEvent({ key: '1' }), false)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: 'Enter' }), false)).toEqual({ type: 'none' }); + }); + + it('ignores keys pressed with modifiers (Ctrl/Cmd/Alt)', () => { + expect(classifyKeyEvent(makeEvent({ key: '1', ctrlKey: true }), true)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: '1', metaKey: true }), true)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: '1', altKey: true }), true)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: 'Enter', ctrlKey: true }), true)).toEqual({ type: 'none' }); + }); + + it('ignores unrelated keys', () => { + expect(classifyKeyEvent(makeEvent({ key: 'a' }), true)).toEqual({ type: 'none' }); + expect(classifyKeyEvent(makeEvent({ key: 'Escape' }), true)).toEqual({ type: 'none' }); + }); +}); diff --git a/src/hooks/useQuizKeyboard.ts b/src/hooks/useQuizKeyboard.ts new file mode 100644 index 0000000..44e085f --- /dev/null +++ b/src/hooks/useQuizKeyboard.ts @@ -0,0 +1,96 @@ +/** + * Keyboard essentials for quiz/exam navigation (A8). + * + * `classifyKeyEvent` is a pure function so the key-handling logic can be + * tested without mounting a component or a real DOM keydown listener. + * `useQuizKeyboard` wires it to a document-level `keydown` listener, + * mirroring the pattern used by ConfidenceTap's q/w/e shortcuts (separate + * listener, disjoint keys — no conflict). + */ + +import { useEffect, useCallback } from 'react'; + +export type QuizKeyAction = + | { type: 'select-index'; index: number } + | { type: 'enter' } + | { type: 'none' }; + +/** Minimal shape needed from a KeyboardEvent — kept narrow for easy testing. */ +export interface ClassifiableKeyEvent { + key: string; + target: EventTarget | null; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; +} + +const DIGIT_KEYS = ['1', '2', '3', '4']; + +/** + * Classifies a keydown event into a quiz keyboard action. + * + * - Digits 1-4 → `select-index` (0-based), suppressed when focus is inside an + * input/textarea/select (keeps fill-blank text entry and matching selects + * safe from accidental option selection). + * - Enter → `enter` (submit/advance — the caller decides which, based on its + * own state), NOT suppressed inside inputs so fill-blank can be completed + * with Enter. + * - Any key combined with a modifier (Ctrl/Cmd/Alt), or `enabled: false` + * (e.g. a confirm dialog is open), is ignored. + */ +export function classifyKeyEvent( + event: ClassifiableKeyEvent, + enabled: boolean +): QuizKeyAction { + if (!enabled) return { type: 'none' }; + if (event.ctrlKey || event.metaKey || event.altKey) return { type: 'none' }; + + if (event.key === 'Enter') { + return { type: 'enter' }; + } + + const tag = (event.target as Element | null)?.tagName?.toLowerCase() ?? ''; + const isInInput = tag === 'input' || tag === 'textarea' || tag === 'select'; + if (isInInput) return { type: 'none' }; + + if (DIGIT_KEYS.includes(event.key)) { + return { type: 'select-index', index: DIGIT_KEYS.indexOf(event.key) }; + } + + return { type: 'none' }; +} + +export interface UseQuizKeyboardOptions { + /** When false, all shortcuts are inert (e.g. a confirm dialog is open). */ + enabled: boolean; + /** Called for digit keys 1-4, with a 0-based option index. */ + onSelectIndex?: (index: number) => void; + /** Called for Enter. The caller decides whether that means submit or advance. */ + onEnter?: () => void; +} + +/** Wires `classifyKeyEvent` to a document-level keydown listener. */ +export function useQuizKeyboard({ + enabled, + onSelectIndex, + onEnter, +}: UseQuizKeyboardOptions): void { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const action = classifyKeyEvent(e, enabled); + if (action.type === 'none') return; + if (action.type === 'enter') { + e.preventDefault(); + onEnter?.(); + } else if (action.type === 'select-index') { + onSelectIndex?.(action.index); + } + }, + [enabled, onSelectIndex, onEnter] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} diff --git a/src/lib/knowledge-version.ts b/src/lib/knowledge-version.ts index da25bc8..52aa10c 100644 --- a/src/lib/knowledge-version.ts +++ b/src/lib/knowledge-version.ts @@ -1,4 +1,4 @@ -export const KNOWLEDGE_VERSION = 'v1.4.3'; +export const KNOWLEDGE_VERSION = 'v1.4.4'; export const KNOWLEDGE_REPO_URL = 'https://github.com/gemmology-dev/gemmology-knowledge'; diff --git a/src/lib/quiz/answer-normalize.test.ts b/src/lib/quiz/answer-normalize.test.ts new file mode 100644 index 0000000..07901a8 --- /dev/null +++ b/src/lib/quiz/answer-normalize.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeAnswer } from './answer-normalize'; + +describe('normalizeAnswer', () => { + it('trims leading/trailing whitespace', () => { + expect(normalizeAnswer(' ruby ')).toBe('ruby'); + }); + + it('lowercases the value', () => { + expect(normalizeAnswer('Ruby')).toBe('ruby'); + expect(normalizeAnswer('RUBY')).toBe('ruby'); + }); + + it('collapses internal whitespace runs to a single space', () => { + expect(normalizeAnswer('star sapphire')).toBe('star sapphire'); + expect(normalizeAnswer('star\tsapphire')).toBe('star sapphire'); + expect(normalizeAnswer(' star sapphire ')).toBe('star sapphire'); + }); + + it('strips combining diacritics after NFKD decomposition', () => { + expect(normalizeAnswer('café')).toBe('cafe'); + expect(normalizeAnswer('naïve')).toBe('naive'); + expect(normalizeAnswer('crème')).toBe('creme'); + }); + + it('treats accented and unaccented forms as equal', () => { + expect(normalizeAnswer('café')).toBe(normalizeAnswer('cafe')); + }); + + it('does not perform numeric-word equivalence (documented non-goal)', () => { + expect(normalizeAnswer('10')).not.toBe(normalizeAnswer('ten')); + }); + + it('combines all rules together', () => { + expect(normalizeAnswer(' Crème Brûlée ')).toBe('creme brulee'); + }); +}); diff --git a/src/lib/quiz/answer-normalize.ts b/src/lib/quiz/answer-normalize.ts new file mode 100644 index 0000000..f4a1202 --- /dev/null +++ b/src/lib/quiz/answer-normalize.ts @@ -0,0 +1,25 @@ +/** + * Shared answer-normalization for free-text comparisons (fill-blank, and the + * string branches of matching/mcq correctness checks). + * + * Pipeline: trim -> NFKD-decompose + strip combining diacritics -> lowercase + * -> collapse internal whitespace runs to a single space -> trim again. + * + * Deliberately does NOT implement numeric-word equivalence (e.g. "10" vs + * "ten"): curators are expected to list every acceptable surface form in + * `acceptedAnswers` (see docs/authoring-questions.md). + */ + +// Combining diacritical marks block (U+0300-U+036F), reached after NFKD +// decomposition splits accented characters into base + combining mark. +const COMBINING_MARKS_RE = /[̀-ͯ]/g; + +export function normalizeAnswer(value: string): string { + return value + .trim() + .normalize('NFKD') + .replace(COMBINING_MARKS_RE, '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/quiz/curated-questions.test.ts b/src/lib/quiz/curated-questions.test.ts new file mode 100644 index 0000000..c847f5c --- /dev/null +++ b/src/lib/quiz/curated-questions.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { + mapCuratedQuestion, + mapCuratedQuestions, + difficultyFromNumber, + type CuratedQuestionSource, +} from './curated-questions'; + +function mcqSource(overrides: Partial = {}): CuratedQuestionSource { + return { + id: 'corundum-ri-001', + stem: 'A red stone gives RI 1.762-1.770, SG 4.00, uniaxial negative. What is it?', + type: 'mcq', + options: [ + { text: 'Ruby', isCorrect: true, rationale: 'Matches RI/SG/optic character exactly.' }, + { text: 'Spinel', isCorrect: false, rationale: 'Spinel is isotropic.' }, + { text: 'Pyrope', isCorrect: false, rationale: 'Pyrope is isotropic.' }, + ], + rationaleCorrect: 'Optic character first, then SG.', + difficulty: 3, + category: 'species', + sourceArticle: 'species/corundum', + unvetted: false, + deprecated: false, + ...overrides, + }; +} + +describe('difficultyFromNumber', () => { + it('maps 1-2 to beginner', () => { + expect(difficultyFromNumber(1)).toBe('beginner'); + expect(difficultyFromNumber(2)).toBe('beginner'); + }); + + it('maps 3 to intermediate', () => { + expect(difficultyFromNumber(3)).toBe('intermediate'); + }); + + it('maps 4-5 to advanced', () => { + expect(difficultyFromNumber(4)).toBe('advanced'); + expect(difficultyFromNumber(5)).toBe('advanced'); + }); +}); + +describe('mapCuratedQuestion — mcq', () => { + it('maps options, correctAnswer, and per-option rationales', () => { + const q = mapCuratedQuestion(mcqSource()); + expect(q).not.toBeNull(); + expect(q!.type).toBe('multiple-choice'); + expect(q!.options).toEqual(['Ruby', 'Spinel', 'Pyrope']); + expect(q!.correctAnswer).toBe('Ruby'); + expect(q!.optionRationales).toHaveLength(3); + expect(q!.optionRationales![0]).toEqual({ + text: 'Ruby', + isCorrect: true, + rationale: 'Matches RI/SG/optic character exactly.', + }); + expect(q!.rationaleCorrect).toBe('Optic character first, then SG.'); + expect(q!.unvetted).toBe(false); + expect(q!.sourceRef).toBe('/learn/species/corundum'); + expect(q!.difficulty).toBe('intermediate'); + }); + + it('defaults unvetted to false when absent from the source', () => { + const { unvetted: _unvetted, ...rest } = mcqSource(); + const q = mapCuratedQuestion(rest as CuratedQuestionSource); + expect(q!.unvetted).toBe(false); + }); + + it('leaves sourceRef undefined when sourceArticle is absent', () => { + const { sourceArticle: _sourceArticle, ...rest } = mcqSource(); + const q = mapCuratedQuestion(rest as CuratedQuestionSource); + expect(q!.sourceRef).toBeUndefined(); + expect(q!.topic).toBe('species'); + }); +}); + +describe('mapCuratedQuestion — true-false', () => { + it('maps to the true-false question type', () => { + const q = mapCuratedQuestion( + mcqSource({ + id: 'tf-1', + type: 'true-false', + options: [ + { text: 'True', isCorrect: false }, + { text: 'False', isCorrect: true }, + ], + }) + ); + expect(q!.type).toBe('true-false'); + expect(q!.correctAnswer).toBe('False'); + }); +}); + +describe('mapCuratedQuestion — image-mcq', () => { + it('renders as a plain multiple-choice question in v1, carrying imageRef unused', () => { + const q = mapCuratedQuestion( + mcqSource({ id: 'img-1', type: 'image-mcq', imageRef: '/images/crystal.svg' }) + ); + expect(q!.type).toBe('multiple-choice'); + expect(q!.options).toEqual(['Ruby', 'Spinel', 'Pyrope']); + }); +}); + +describe('mapCuratedQuestion — fill-blank', () => { + it('maps acceptedAnswers to correctAnswer', () => { + const q = mapCuratedQuestion( + mcqSource({ + id: 'fb-1', + type: 'fill-blank', + stem: 'Diamond has a Mohs hardness of ___.', + acceptedAnswers: ['10', 'ten'], + options: undefined, + }) + ); + expect(q!.type).toBe('fill-blank'); + expect(q!.correctAnswer).toEqual(['10', 'ten']); + expect(q!.options).toBeUndefined(); + }); + + it('returns null when acceptedAnswers is empty (fails isRenderable)', () => { + const q = mapCuratedQuestion( + mcqSource({ id: 'fb-bad', type: 'fill-blank', acceptedAnswers: [], options: undefined }) + ); + expect(q).toBeNull(); + }); +}); + +describe('mapCuratedQuestion — matching', () => { + it('maps pairs to matchingPairs and correctAnswer as left:right strings', () => { + const q = mapCuratedQuestion( + mcqSource({ + id: 'match-1', + type: 'matching', + pairs: [ + { left: 'Diamond', right: 'Cubic' }, + { left: 'Quartz', right: 'Trigonal' }, + ], + options: undefined, + }) + ); + expect(q!.type).toBe('matching'); + expect(q!.matchingPairs).toEqual([ + { left: 'Diamond', right: 'Cubic' }, + { left: 'Quartz', right: 'Trigonal' }, + ]); + expect(q!.correctAnswer).toEqual(['Diamond:Cubic', 'Quartz:Trigonal']); + }); + + it('returns null when fewer than 2 pairs (fails isRenderable)', () => { + const q = mapCuratedQuestion( + mcqSource({ + id: 'match-bad', + type: 'matching', + pairs: [{ left: 'Diamond', right: 'Cubic' }], + options: undefined, + }) + ); + expect(q).toBeNull(); + }); +}); + +describe('mapCuratedQuestion — deprecated', () => { + it('returns null for deprecated entries regardless of otherwise-valid data', () => { + const q = mapCuratedQuestion(mcqSource({ deprecated: true })); + expect(q).toBeNull(); + }); +}); + +describe('mapCuratedQuestions', () => { + it('drops deprecated and malformed entries, keeping valid ones in order', () => { + const sources: CuratedQuestionSource[] = [ + mcqSource({ id: 'good-1' }), + mcqSource({ id: 'deprecated-1', deprecated: true }), + mcqSource({ id: 'bad-fill', type: 'fill-blank', acceptedAnswers: [], options: undefined }), + mcqSource({ id: 'good-2' }), + ]; + const result = mapCuratedQuestions(sources); + expect(result.map(q => q.id)).toEqual(['good-1', 'good-2']); + }); +}); diff --git a/src/lib/quiz/curated-questions.ts b/src/lib/quiz/curated-questions.ts new file mode 100644 index 0000000..eba83b3 --- /dev/null +++ b/src/lib/quiz/curated-questions.ts @@ -0,0 +1,129 @@ +/** + * Maps curated question-bank entries (src/content/questions/*.yaml, validated + * by the Zod schema in src/content/config.ts) into the runtime `Question` + * shape used by the quiz engine. + * + * Pure module: no `astro:content` import, so it can be unit tested with plain + * fixture objects. The caller (src/pages/quiz/index.astro) is responsible for + * calling `getCollection('questions')` and passing the mapped `.data` shape + * in; this module generalizes the inline mapper already used by the learn + * pretest widget (`src/pages/learn/[...slug].astro`) to cover all five + * curated question types instead of just plain MCQ. + */ + +import type { + Question, + Difficulty, + Category, + QuestionType, + OptionRationale, +} from './question-types'; +import { isRenderable } from './question-validity'; + +/** + * Minimal shape of a single curated question-bank entry, matching the Zod + * schema in src/content/config.ts (`questionsCollection`). Declared locally + * so this module has no dependency on `astro:content`. + */ +export interface CuratedQuestionSource { + id: string; + stem: string; + type: 'mcq' | 'true-false' | 'fill-blank' | 'matching' | 'image-mcq'; + options?: Array<{ text: string; isCorrect: boolean; rationale?: string }>; + acceptedAnswers?: string[]; + pairs?: Array<{ left: string; right: string }>; + rationaleCorrect: string; + difficulty: number; + category: Category; + sourceArticle?: string; + /** Carried on the source but intentionally unused in v1 (image-mcq renders as plain MCQ). */ + imageRef?: string; + unvetted?: boolean; + deprecated?: boolean; +} + +const TYPE_MAP: Record = { + mcq: 'multiple-choice', + 'true-false': 'true-false', + // v1: image-mcq renders as a plain MCQ; imageRef is carried on the source + // but not surfaced in the UI yet. + 'image-mcq': 'multiple-choice', + 'fill-blank': 'fill-blank', + matching: 'matching', +}; + +/** Maps a curated 1-5 difficulty number onto the quiz engine's 3-level scale. */ +export function difficultyFromNumber(n: number): Difficulty { + if (n <= 2) return 'beginner'; + if (n === 3) return 'intermediate'; + return 'advanced'; +} + +/** + * Maps one curated question-bank source entry into the runtime `Question` + * shape. Returns `null` for `deprecated` entries or entries that fail + * `isRenderable` after mapping — defensive, so malformed curated content can + * never occupy a quiz session slot or crash the UI. + */ +export function mapCuratedQuestion(source: CuratedQuestionSource): Question | null { + if (source.deprecated) return null; + + const type = TYPE_MAP[source.type]; + const topic = source.sourceArticle ?? source.category; + const sourceRef = source.sourceArticle ? `/learn/${source.sourceArticle}` : undefined; + + let options: string[] | undefined; + let correctAnswer: string | string[]; + let optionRationales: OptionRationale[] | undefined; + let matchingPairs: Array<{ left: string; right: string }> | undefined; + + if (type === 'multiple-choice' || type === 'true-false') { + const opts = source.options ?? []; + options = opts.map(o => o.text); + const correct = opts.find(o => o.isCorrect); + correctAnswer = correct?.text ?? opts[0]?.text ?? ''; + optionRationales = opts.map(o => ({ + text: o.text, + isCorrect: o.isCorrect, + rationale: o.rationale ?? '', + })); + } else if (type === 'fill-blank') { + correctAnswer = source.acceptedAnswers ?? []; + } else { + // matching + matchingPairs = source.pairs ?? []; + correctAnswer = matchingPairs.map(p => `${p.left}:${p.right}`); + } + + const question: Question = { + id: source.id, + type, + difficulty: difficultyFromNumber(source.difficulty), + category: source.category, + topic, + questionText: source.stem, + options, + correctAnswer, + sourceRef, + matchingPairs, + rationaleCorrect: source.rationaleCorrect, + optionRationales, + unvetted: source.unvetted ?? false, + }; + + return isRenderable(question) ? question : null; +} + +/** + * Maps a list of curated source entries, dropping any that don't map to a + * valid, renderable `Question` (deprecated, malformed, or structurally + * invalid after mapping). + */ +export function mapCuratedQuestions(sources: CuratedQuestionSource[]): Question[] { + const mapped: Question[] = []; + for (const source of sources) { + const question = mapCuratedQuestion(source); + if (question) mapped.push(question); + } + return mapped; +} diff --git a/src/lib/quiz/index.ts b/src/lib/quiz/index.ts index a980e1a..0f6f1d2 100644 --- a/src/lib/quiz/index.ts +++ b/src/lib/quiz/index.ts @@ -8,6 +8,7 @@ export type { Difficulty, Category, Question, + OptionRationale, QuizConfig, QuizState, QuestionResult, @@ -35,6 +36,20 @@ export { getQuestionStats, } from './question-generator'; +// Question validity +export { isRenderable } from './question-validity'; + +// Answer normalization +export { normalizeAnswer } from './answer-normalize'; + +// Curated question-bank mapping +export type { CuratedQuestionSource } from './curated-questions'; +export { + mapCuratedQuestion, + mapCuratedQuestions, + difficultyFromNumber, +} from './curated-questions'; + // Scoring export { calculateResults, @@ -97,3 +112,7 @@ export type { SelectionResult, ScheduleLookup } from './selector'; export { interleaveNearMisses } from './interleaver'; export { LocalStudyStore, getStudyStore } from './store'; + +// Due-aware session selection (A4c) +export { selectSessionQuestions } from './question-pool'; +export type { QuestionPoolStore } from './question-pool'; diff --git a/src/lib/quiz/question-generator.test.ts b/src/lib/quiz/question-generator.test.ts new file mode 100644 index 0000000..6140830 --- /dev/null +++ b/src/lib/quiz/question-generator.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { selectQuestions, checkAnswer } from './question-generator'; +import type { Question, QuizConfig } from './question-types'; + +function makeQuestion(overrides: Partial): Question { + return { + id: 'q1', + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: 'Sample?', + options: ['A', 'B', 'C'], + correctAnswer: 'A', + ...overrides, + }; +} + +function makeConfig(overrides: Partial = {}): QuizConfig { + return { + categories: ['fundamentals'], + questionCount: 10, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'practice', + ...overrides, + }; +} + +describe('selectQuestions', () => { + it('filters out structurally unrenderable questions before selecting', () => { + const good = makeQuestion({ id: 'good', options: ['A', 'B'] }); + const badMcq = makeQuestion({ id: 'bad-mcq', options: ['A'] }); + const badMatching = makeQuestion({ + id: 'bad-matching', + type: 'matching', + matchingPairs: [{ left: 'a', right: '1' }], + correctAnswer: ['a:1'], + }); + + const result = selectQuestions([good, badMcq, badMatching], makeConfig({ questionCount: 10 })); + + expect(result.map(q => q.id)).toEqual(['good']); + }); + + it('respects category and questionCount filters', () => { + const q1 = makeQuestion({ id: 'q1', category: 'fundamentals' }); + const q2 = makeQuestion({ id: 'q2', category: 'equipment' }); + const q3 = makeQuestion({ id: 'q3', category: 'fundamentals' }); + + const result = selectQuestions( + [q1, q2, q3], + makeConfig({ categories: ['fundamentals'], questionCount: 1 }) + ); + + expect(result).toHaveLength(1); + expect(result[0].category).toBe('fundamentals'); + }); +}); + +describe('checkAnswer', () => { + it('matches a simple string answer case-insensitively, trimmed', () => { + const q = makeQuestion({ correctAnswer: 'Garnet' }); + expect(checkAnswer(q, ' garnet ')).toBe(true); + expect(checkAnswer(q, 'topaz')).toBe(false); + }); + + it('matches fill-blank questions with an array of accepted answers', () => { + const q = makeQuestion({ type: 'fill-blank', correctAnswer: ['ruby', 'sapphire'] }); + expect(checkAnswer(q, 'Sapphire')).toBe(true); + expect(checkAnswer(q, 'emerald')).toBe(false); + }); + + it('matches matching questions by unordered pair-array comparison', () => { + const q = makeQuestion({ + type: 'matching', + matchingPairs: [ + { left: 'a', right: '1' }, + { left: 'b', right: '2' }, + ], + correctAnswer: ['a:1', 'b:2'], + }); + expect(checkAnswer(q, ['b:2', 'a:1'])).toBe(true); + expect(checkAnswer(q, ['a:1'])).toBe(false); + expect(checkAnswer(q, ['a:2', 'b:1'])).toBe(false); + }); + + it('returns false for mismatched answer shapes', () => { + const q = makeQuestion({ correctAnswer: 'A' }); + expect(checkAnswer(q, ['A'])).toBe(false); + }); +}); diff --git a/src/lib/quiz/question-generator.ts b/src/lib/quiz/question-generator.ts index f0f5bc4..ca97bb2 100644 --- a/src/lib/quiz/question-generator.ts +++ b/src/lib/quiz/question-generator.ts @@ -11,6 +11,8 @@ import type { QuizConfig, } from './question-types'; import { shuffle, pickRandom, generateWrongAnswers } from './shuffle'; +import { isRenderable } from './question-validity'; +import { normalizeAnswer } from './answer-normalize'; /** Section structure from learn YAML content */ interface LearnSection { @@ -419,6 +421,11 @@ export function selectQuestions( ): Question[] { let filtered = allQuestions; + // Filter out structurally unrenderable questions (e.g. a matching question + // that lost its pairs, or an MCQ with fewer than 2 options) before anything + // else so they never occupy a slot in the selected session. + filtered = filtered.filter(isRenderable); + // Filter by categories if (config.categories.length > 0) { filtered = filtered.filter(q => config.categories.includes(q.category)); @@ -449,9 +456,8 @@ export function checkAnswer( // For fill-blank with multiple valid answers if (Array.isArray(correct) && typeof userAnswer === 'string') { - return correct.some(c => - c.toLowerCase().trim() === userAnswer.toLowerCase().trim() - ); + const normalizedUser = normalizeAnswer(userAnswer); + return correct.some(c => normalizeAnswer(c) === normalizedUser); } // For matching questions @@ -462,7 +468,7 @@ export function checkAnswer( // Standard comparison if (typeof correct === 'string' && typeof userAnswer === 'string') { - return correct.toLowerCase().trim() === userAnswer.toLowerCase().trim(); + return normalizeAnswer(correct) === normalizeAnswer(userAnswer); } return false; diff --git a/src/lib/quiz/question-pool.test.ts b/src/lib/quiz/question-pool.test.ts new file mode 100644 index 0000000..727eac2 --- /dev/null +++ b/src/lib/quiz/question-pool.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from 'vitest'; +import { selectSessionQuestions } from './question-pool'; +import { selectQuestions } from './question-generator'; +import type { Question, QuizConfig } from './question-types'; +import type { QuestionPoolStore } from './question-pool'; +import { DEFAULT_STUDY_SETTINGS, newScheduleEntry } from './study-types'; + +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, + }; +} + +function makeConfig(overrides: Partial = {}): QuizConfig { + return { + categories: [], + questionCount: 3, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'practice', + ...overrides, + }; +} + +/** A store with no history: everything is "never seen". */ +function makeEmptyStore(): QuestionPoolStore { + return { + getSchedule: vi.fn().mockResolvedValue(null), + getDueItems: vi.fn().mockResolvedValue([]), + }; +} + +describe('selectSessionQuestions (A4c)', () => { + it('returns the requested number of questions from a healthy store (fresh-fill path)', async () => { + const pool = [ + makeQuestion({ id: 'q1' }), + makeQuestion({ id: 'q2' }), + makeQuestion({ id: 'q3' }), + makeQuestion({ id: 'q4' }), + ]; + const store = makeEmptyStore(); + + const result = await selectSessionQuestions( + pool, + makeConfig({ questionCount: 3 }), + store, + DEFAULT_STUDY_SETTINGS + ); + + expect(result).toHaveLength(3); + // Every returned question must come from the pool. + const poolIds = new Set(pool.map(q => q.id)); + for (const q of result) { + expect(poolIds.has(q.id)).toBe(true); + } + // No duplicates. + expect(new Set(result.map(q => q.id)).size).toBe(3); + }); + + it('filters out structurally unrenderable questions before selecting', async () => { + const pool = [ + makeQuestion({ id: 'bad', options: ['OnlyOne'] }), + makeQuestion({ id: 'good1' }), + makeQuestion({ id: 'good2' }), + ]; + const store = makeEmptyStore(); + + const result = await selectSessionQuestions( + pool, + makeConfig({ questionCount: 5 }), + store, + DEFAULT_STUDY_SETTINGS + ); + + expect(result.map(q => q.id)).not.toContain('bad'); + expect(result).toHaveLength(2); + }); + + it('respects config.categories filtering', async () => { + const pool = [ + makeQuestion({ id: 'f1', category: 'fundamentals' }), + makeQuestion({ id: 'e1', category: 'equipment' }), + ]; + const store = makeEmptyStore(); + + const result = await selectSessionQuestions( + pool, + makeConfig({ categories: ['equipment'], questionCount: 5 }), + store, + DEFAULT_STUDY_SETTINGS + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('e1'); + }); + + it('prioritises due items (per selectQuestionsV2) ahead of never-seen items', async () => { + const pool = [ + makeQuestion({ id: 'due1' }), + makeQuestion({ id: 'fresh1' }), + makeQuestion({ id: 'fresh2' }), + ]; + const dueEntry = { ...newScheduleEntry('due1'), totalReviews: 1, nextDue: Date.now() - 1000 }; + const store: QuestionPoolStore = { + getDueItems: vi.fn().mockResolvedValue(['due1']), + getSchedule: vi.fn().mockImplementation(async (id: string) => + id === 'due1' ? dueEntry : null + ), + }; + + const result = await selectSessionQuestions( + pool, + makeConfig({ questionCount: 2 }), + store, + DEFAULT_STUDY_SETTINGS + ); + + expect(result).toHaveLength(2); + // Due items are selected ahead of never-seen ones (subject to the + // reviewMixRatio budget), so 'due1' leads. + expect(result[0].id).toBe('due1'); + expect(result.map(q => q.id)).toContain('due1'); + }); + + it('falls back to the plain shuffle().slice() selection if the store throws', async () => { + const pool = [ + makeQuestion({ id: 'q1' }), + makeQuestion({ id: 'q2' }), + makeQuestion({ id: 'q3' }), + ]; + const config = makeConfig({ questionCount: 2, shuffleQuestions: false }); + const brokenStore: QuestionPoolStore = { + getDueItems: vi.fn().mockRejectedValue(new Error('storage unavailable')), + getSchedule: vi.fn().mockRejectedValue(new Error('storage unavailable')), + }; + + const result = await selectSessionQuestions(pool, config, brokenStore, DEFAULT_STUDY_SETTINGS); + + // Deterministic because shuffleQuestions is false — matches the legacy + // selectQuestions() output exactly. + expect(result).toEqual(selectQuestions(pool, config)); + }); + + it('never blocks (always resolves) even if getSchedule throws only sometimes', async () => { + const pool = [makeQuestion({ id: 'q1' }), makeQuestion({ id: 'q2' })]; + const flakyStore: QuestionPoolStore = { + getDueItems: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockRejectedValue(new Error('boom')), + }; + + await expect( + selectSessionQuestions(pool, makeConfig({ questionCount: 2 }), flakyStore, DEFAULT_STUDY_SETTINGS) + ).resolves.toHaveLength(2); + }); + + it('returns an empty array when the pool has no renderable questions', async () => { + const pool = [makeQuestion({ id: 'bad', options: [] })]; + const store = makeEmptyStore(); + + const result = await selectSessionQuestions( + pool, + makeConfig({ questionCount: 5 }), + store, + DEFAULT_STUDY_SETTINGS + ); + + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/quiz/question-pool.ts b/src/lib/quiz/question-pool.ts new file mode 100644 index 0000000..5ef7adb --- /dev/null +++ b/src/lib/quiz/question-pool.ts @@ -0,0 +1,119 @@ +/** + * Due-aware session question selection (A4c — highest risk, landed last). + * + * Wraps selector.ts's `selectQuestionsV2` (the pure, store-driven due/new/ + * stale algorithm) so the main quiz entry point can offer spaced-repetition + * aware sessions while preserving two hard guarantees: + * + * 1. Curated-first pool order is preserved — `selectQuestionsV2` returns + * question ids; we project them back to `Question` objects through a + * map built from the (curated-first-ordered) filtered pool, and any + * shortfall is backfilled from the same pool in its original order. + * 2. A broken store (rejected promise, thrown error, corrupted data) can + * never block quiz start — `selectSessionQuestions` always resolves, + * falling back to the existing `shuffle().slice()` behaviour + * (`selectQuestions` from question-generator.ts) on any failure. + */ + +import type { Question, QuizConfig, Category } from './question-types'; +import type { StudyStore, StudySettings, CategoryBudget, SelectionRequest } from './study-types'; +import { selectQuestionsV2 } from './selector'; +import { selectQuestions } from './question-generator'; +import { isRenderable } from './question-validity'; + +/** Store dependency actually used by the selector (kept narrow for testing). */ +export type QuestionPoolStore = Pick; + +/** + * Select the questions for a session, preferring due-aware selection and + * falling back to the current shuffle-and-slice behaviour if anything throws + * (e.g. a corrupted or unavailable store) so quiz start is never blocked. + */ +export async function selectSessionQuestions( + pool: Question[], + config: QuizConfig, + store: QuestionPoolStore, + settings: Pick +): Promise { + try { + return await selectDueAware(pool, config, store, settings); + } catch (err) { + console.warn( + '[question-pool] due-aware selection failed, falling back to shuffle().slice():', + err + ); + return selectQuestions(pool, config); + } +} + +/** Evenly split `totalCount` across `categories`, extras going to the first ones. Sums exactly to totalCount. */ +function distributeBudget(totalCount: number, categories: Category[]): CategoryBudget[] { + const n = categories.length; + if (n === 0) return []; + const base = Math.floor(totalCount / n); + let extra = totalCount - base * n; + return categories.map(category => { + const count = base + (extra > 0 ? 1 : 0); + if (extra > 0) extra--; + return { category, count }; + }); +} + +async function selectDueAware( + pool: Question[], + config: QuizConfig, + store: QuestionPoolStore, + settings: Pick +): Promise { + // Same pre-filtering as the legacy path: renderable, category, difficulty. + let filtered = pool.filter(isRenderable); + if (config.categories.length > 0) { + filtered = filtered.filter(q => config.categories.includes(q.category)); + } + if (config.difficulty && config.difficulty.length > 0) { + filtered = filtered.filter(q => config.difficulty!.includes(q.difficulty)); + } + + if (filtered.length === 0) return []; + + const totalCount = Math.min(config.questionCount, filtered.length); + const categoriesInPool = Array.from(new Set(filtered.map(q => q.category))); + const budgets = distributeBudget(totalCount, categoriesInPool); + + const request: SelectionRequest = { + pool: filtered.map(q => ({ id: q.id, category: q.category })), + budgets, + totalCount, + settings, + now: Date.now(), + }; + + const ids = await selectQuestionsV2(request, store); + + // Project ids back to Question objects, de-duplicating defensively. + const byId = new Map(filtered.map(q => [q.id, q])); + const selected: Question[] = []; + const seen = new Set(); + for (const id of ids) { + const q = byId.get(id); + if (q && !seen.has(id)) { + selected.push(q); + seen.add(id); + } + } + + // Backfill from the remaining pool (curated-first order preserved) if the + // selector came up short for any reason — never leave a session smaller + // than requested when enough renderable questions exist. + if (selected.length < totalCount) { + for (const q of filtered) { + if (selected.length >= totalCount) break; + if (!seen.has(q.id)) { + selected.push(q); + seen.add(q.id); + } + } + } + + return selected; +} diff --git a/src/lib/quiz/question-types.test.ts b/src/lib/quiz/question-types.test.ts new file mode 100644 index 0000000..00a2ed6 --- /dev/null +++ b/src/lib/quiz/question-types.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { + serializeQuizState, + deserializeQuizState, + type QuizState, + type SerializedQuizState, + type Question, +} from './question-types'; + +function makeQuestion(id: string): Question { + return { + id, + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: `Question ${id}?`, + options: ['A', 'B', 'C'], + correctAnswer: 'A', + }; +} + +describe('serializeQuizState / deserializeQuizState', () => { + it('round-trips a state with skippedQuestions', () => { + const state: QuizState = { + questions: [makeQuestion('q1'), makeQuestion('q2')], + currentIndex: 1, + answers: new Map([['q1', 'A']]), + startTime: 1000, + endTime: 2000, + flaggedQuestions: new Set(['q2']), + submitted: true, + skippedQuestions: new Set(['q2']), + }; + + const serialized = serializeQuizState(state); + expect(serialized.skippedQuestions).toEqual(['q2']); + + const roundTripped = deserializeQuizState(serialized); + expect(roundTripped.skippedQuestions).toEqual(new Set(['q2'])); + expect(roundTripped.answers).toEqual(new Map([['q1', 'A']])); + expect(roundTripped.flaggedQuestions).toEqual(new Set(['q2'])); + expect(roundTripped.submitted).toBe(true); + }); + + it('defaults skippedQuestions to an empty Set for pre-v1 localStorage blobs', () => { + // Hand-written old-shape fixture: no `skippedQuestions` key at all, + // matching what was persisted before Study v1 shipped this field. + const legacyBlob = { + questions: [makeQuestion('q1')], + currentIndex: 0, + answers: [['q1', 'A']], + startTime: 1000, + flaggedQuestions: [], + submitted: false, + // no skippedQuestions field + } as unknown as SerializedQuizState; + + expect(() => deserializeQuizState(legacyBlob)).not.toThrow(); + + const state = deserializeQuizState(legacyBlob); + expect(state.skippedQuestions).toEqual(new Set()); + expect(state.questions).toHaveLength(1); + expect(state.answers.get('q1')).toBe('A'); + }); +}); diff --git a/src/lib/quiz/question-types.ts b/src/lib/quiz/question-types.ts index 2b430fe..5e3d4a7 100644 --- a/src/lib/quiz/question-types.ts +++ b/src/lib/quiz/question-types.ts @@ -48,6 +48,16 @@ export const CATEGORY_LABELS: Record = { care: 'Care & Durability', }; +/** Per-option rationale, shown in the post-submit RationalePanel. */ +export interface OptionRationale { + /** Display text of this option. */ + text: string; + /** Whether this is the correct answer. */ + isCorrect: boolean; + /** Per-distractor or correct-answer rationale. */ + rationale: string; +} + /** A single quiz question */ export interface Question { /** Unique identifier for this question */ @@ -72,6 +82,12 @@ export interface Question { sourceRef?: string; /** For matching questions: items to match */ matchingPairs?: Array<{ left: string; right: string }>; + /** Overall rationale for the correct answer (Study v1 — curated questions). */ + rationaleCorrect?: string; + /** Per-option rationales (Study v1 — curated questions). */ + optionRationales?: OptionRationale[]; + /** Whether this question is auto-generated and not yet expert-reviewed. */ + unvetted?: boolean; } /** Configuration for starting a quiz */ @@ -117,6 +133,8 @@ export interface QuizState { flaggedQuestions: Set; /** Whether the quiz has been submitted */ submitted: boolean; + /** Questions the user explicitly skipped (unrenderable or user choice). Not scored. */ + skippedQuestions: Set; } /** Result for a single answered question */ @@ -131,6 +149,8 @@ export interface QuestionResult { timeTaken?: number; /** Whether the question was flagged for review (exam mode) */ wasFlagged?: boolean; + /** Whether the question was skipped (unrenderable or user choice). Excluded from scoring. */ + skipped?: boolean; } /** Final results after completing a quiz */ @@ -214,6 +234,8 @@ export interface SerializedQuizState { endTime?: number; flaggedQuestions: string[]; submitted: boolean; + /** New in Study v1. Absent in pre-v1 blobs — defaulted to [] on deserialize. */ + skippedQuestions?: string[]; } /** Convert QuizState to serializable format */ @@ -226,6 +248,7 @@ export function serializeQuizState(state: QuizState): SerializedQuizState { endTime: state.endTime, flaggedQuestions: Array.from(state.flaggedQuestions), submitted: state.submitted, + skippedQuestions: Array.from(state.skippedQuestions), }; } @@ -239,5 +262,7 @@ export function deserializeQuizState(data: SerializedQuizState): QuizState { endTime: data.endTime, flaggedQuestions: new Set(data.flaggedQuestions), submitted: data.submitted, + // Backward-compatible: old localStorage blobs predate this field. + skippedQuestions: new Set(data.skippedQuestions ?? []), }; } diff --git a/src/lib/quiz/question-validity.test.ts b/src/lib/quiz/question-validity.test.ts new file mode 100644 index 0000000..50e615e --- /dev/null +++ b/src/lib/quiz/question-validity.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { isRenderable } from './question-validity'; +import type { Question } from './question-types'; + +function base(overrides: Partial): Question { + return { + id: 'q1', + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: 'Sample?', + correctAnswer: 'A', + ...overrides, + }; +} + +describe('isRenderable', () => { + it('accepts multiple-choice with 2+ options', () => { + expect(isRenderable(base({ type: 'multiple-choice', options: ['A', 'B'] }))).toBe(true); + }); + + it('rejects multiple-choice with fewer than 2 options', () => { + expect(isRenderable(base({ type: 'multiple-choice', options: ['A'] }))).toBe(false); + expect(isRenderable(base({ type: 'multiple-choice', options: undefined }))).toBe(false); + }); + + it('accepts true-false with 2 options', () => { + expect(isRenderable(base({ type: 'true-false', options: ['True', 'False'] }))).toBe(true); + }); + + it('accepts fill-blank with a non-empty string answer', () => { + expect(isRenderable(base({ type: 'fill-blank', correctAnswer: 'garnet' }))).toBe(true); + }); + + it('rejects fill-blank with an empty string answer', () => { + expect(isRenderable(base({ type: 'fill-blank', correctAnswer: ' ' }))).toBe(false); + }); + + it('accepts fill-blank with a non-empty array of answers', () => { + expect(isRenderable(base({ type: 'fill-blank', correctAnswer: ['a', 'b'] }))).toBe(true); + }); + + it('rejects fill-blank with an empty array', () => { + expect(isRenderable(base({ type: 'fill-blank', correctAnswer: [] }))).toBe(false); + }); + + it('accepts matching with 2+ pairs', () => { + expect( + isRenderable( + base({ + type: 'matching', + matchingPairs: [ + { left: 'a', right: '1' }, + { left: 'b', right: '2' }, + ], + }) + ) + ).toBe(true); + }); + + it('rejects matching with fewer than 2 pairs', () => { + expect( + isRenderable(base({ type: 'matching', matchingPairs: [{ left: 'a', right: '1' }] })) + ).toBe(false); + expect(isRenderable(base({ type: 'matching', matchingPairs: undefined }))).toBe(false); + }); +}); diff --git a/src/lib/quiz/question-validity.ts b/src/lib/quiz/question-validity.ts new file mode 100644 index 0000000..23f26f9 --- /dev/null +++ b/src/lib/quiz/question-validity.ts @@ -0,0 +1,31 @@ +/** + * Structural validity checks for quiz questions. + * + * Auto-generated questions can occasionally end up malformed (e.g. a + * matching question with fewer than 2 pairs after upstream YAML edits). + * `isRenderable` is the single source of truth for "can this question be + * shown to a user at all" — used both to pre-filter question pools + * (question-generator.ts's `selectQuestions`) and to decide whether a + * question that already made it into a session should be swapped for the + * `UnrenderableQuestionCard` fallback + skip affordance. + */ + +import type { Question } from './question-types'; + +/** Returns true when `question` has enough data to render its UI for its type. */ +export function isRenderable(question: Question): boolean { + switch (question.type) { + case 'multiple-choice': + case 'true-false': + return Array.isArray(question.options) && question.options.length >= 2; + case 'fill-blank': + if (Array.isArray(question.correctAnswer)) { + return question.correctAnswer.length > 0; + } + return typeof question.correctAnswer === 'string' && question.correctAnswer.trim().length > 0; + case 'matching': + return Array.isArray(question.matchingPairs) && question.matchingPairs.length >= 2; + default: + return false; + } +} diff --git a/src/lib/quiz/scoring.test.ts b/src/lib/quiz/scoring.test.ts new file mode 100644 index 0000000..4e39eae --- /dev/null +++ b/src/lib/quiz/scoring.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { calculateResults, getWrongAnswerStats } from './scoring'; +import type { Question, QuizConfig, QuizState } from './question-types'; + +function makeQuestion(id: string, overrides: Partial = {}): Question { + return { + id, + type: 'multiple-choice', + difficulty: 'beginner', + category: 'fundamentals', + topic: 'crystal-systems', + questionText: `Question ${id}?`, + options: ['A', 'B', 'C'], + correctAnswer: 'A', + ...overrides, + }; +} + +function makeConfig(): QuizConfig { + return { + categories: ['fundamentals'], + questionCount: 3, + shuffleQuestions: false, + shuffleOptions: false, + mode: 'practice', + }; +} + +function makeState(overrides: Partial = {}): QuizState { + return { + questions: [makeQuestion('q1'), makeQuestion('q2'), makeQuestion('q3')], + currentIndex: 0, + answers: new Map([ + ['q1', 'A'], + ['q2', 'B'], + ['q3', 'A'], + ]), + startTime: 1000, + endTime: 2000, + flaggedQuestions: new Set(), + submitted: true, + skippedQuestions: new Set(), + ...overrides, + }; +} + +describe('calculateResults', () => { + it('with zero skips, produces the same score/percentage/totalQuestions as before skip support existed', () => { + const state = makeState(); + const result = calculateResults(state, makeConfig()); + + // q1: A === A correct, q2: B !== A incorrect, q3: A === A correct => 2/3 + expect(result.totalQuestions).toBe(3); + expect(result.score).toBe(2); + expect(result.percentage).toBe(67); + expect(result.results).toHaveLength(3); + expect(result.results.every(r => r.skipped === false)).toBe(true); + expect(result.breakdown).toEqual([ + { category: 'fundamentals', correct: 2, total: 3, percentage: 67 }, + ]); + }); + + it('excludes skipped questions from totalQuestions/percentage but keeps them in results', () => { + const state = makeState({ skippedQuestions: new Set(['q2']) }); + const result = calculateResults(state, makeConfig()); + + // q2 is skipped: only q1 (correct) and q3 (correct) are scored => 2/2 = 100% + expect(result.totalQuestions).toBe(2); + expect(result.score).toBe(2); + expect(result.percentage).toBe(100); + expect(result.results).toHaveLength(3); + + const q2Result = result.results.find(r => r.question.id === 'q2'); + expect(q2Result?.skipped).toBe(true); + expect(q2Result?.isCorrect).toBe(false); + + expect(result.breakdown).toEqual([ + { category: 'fundamentals', correct: 2, total: 2, percentage: 100 }, + ]); + }); + + it('does not count skipped questions as wrong answers', () => { + const state = makeState({ + answers: new Map([ + ['q1', 'A'], + ['q2', 'B'], + ]), + skippedQuestions: new Set(['q2']), + }); + const result = calculateResults(state, makeConfig()); + const stats = getWrongAnswerStats(result.results); + + // q3 unanswered => wrong; q2 skipped => excluded even though its answer was wrong. + expect(stats.categories.get('fundamentals')).toBe(1); + }); +}); diff --git a/src/lib/quiz/scoring.ts b/src/lib/quiz/scoring.ts index ba1eb31..968bcad 100644 --- a/src/lib/quiz/scoring.ts +++ b/src/lib/quiz/scoring.ts @@ -22,28 +22,37 @@ export function calculateResults( ): QuizResult { const results: QuestionResult[] = []; let score = 0; + let scoredCount = 0; for (const question of state.questions) { + const isSkipped = state.skippedQuestions.has(question.id); const userAnswer = state.answers.get(question.id) || null; - const isCorrect = userAnswer !== null && checkAnswer(question, userAnswer); + // Skipped questions are never scored, regardless of a stray answer. + const isCorrect = !isSkipped && userAnswer !== null && checkAnswer(question, userAnswer); const wasFlagged = state.flaggedQuestions.has(question.id); - if (isCorrect) score++; + if (!isSkipped) { + scoredCount++; + if (isCorrect) score++; + } results.push({ question, userAnswer, isCorrect, wasFlagged, + skipped: isSkipped, }); } - const totalQuestions = state.questions.length; + // totalQuestions/percentage exclude skipped questions from the denominator; + // with no skips this is identical to state.questions.length (regression-tested). + const totalQuestions = scoredCount; const percentage = totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 0; const timeTaken = (state.endTime || Date.now()) - state.startTime; - // Calculate breakdown by category - const breakdown = calculateCategoryBreakdown(results); + // Calculate breakdown by category (skipped questions excluded) + const breakdown = calculateCategoryBreakdown(results.filter(r => !r.skipped)); return { results, @@ -148,6 +157,7 @@ export function getWrongAnswerStats(results: QuestionResult[]): { const difficulties = new Map(); for (const result of results) { + if (result.skipped) continue; if (!result.isCorrect) { const { topic, category, difficulty } = result.question; diff --git a/src/pages/index.astro b/src/pages/index.astro index 65bd45a..8be46a1 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -195,7 +195,7 @@ const iconPaths: Record = {
cubic[m3m]:{'{'}111{'}'}@1.0 + {'{'}100{'}'}@1.3
-

Truncated octahedron with cube faces

+

Truncated octahedron with cube faces

@@ -274,7 +274,7 @@ const iconPaths: Record = { -
+
@@ -321,7 +321,7 @@ const iconPaths: Record = { -
+
diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro index e8ae66b..6992e5e 100644 --- a/src/pages/learn/[...slug].astro +++ b/src/pages/learn/[...slug].astro @@ -41,7 +41,10 @@ const { data } = entry; // Resolve dateModified for SEO + visible "Last updated" line. // Order of preference: explicit reviewedAt frontmatter → file mtime fallback. -function resolveDateModified(): string { +// No further fallback: when neither resolves, we return null rather than +// fabricate a "last updated today" claim (the sync script stamps git commit +// times via stampGitMtime, so mtime is normally available for synced content). +function resolveDateModified(): string | null { if (data.reviewedAt) return data.reviewedAt; try { const filePath = (entry as unknown as { filePath?: string }).filePath; @@ -52,18 +55,20 @@ function resolveDateModified(): string { } catch { // fall through } - return new Date().toISOString(); + return null; } const dateModified = resolveDateModified(); // datePublished: use explicit publishedAt frontmatter, fall back to dateModified. -const datePublished = data.publishedAt ?? dateModified; +const datePublished = data.publishedAt ?? dateModified ?? undefined; -const dateModifiedDisplay = new Date(dateModified).toLocaleDateString('en-GB', { - year: 'numeric', - month: 'long', - day: 'numeric', -}); +const dateModifiedDisplay = dateModified + ? new Date(dateModified).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : null; // Helper to format category/subcategory names for display function formatCategoryName(name: string): string { @@ -212,7 +217,7 @@ const orderedRefs = hasCitations difficulty={data.difficulty} slug={entry.id} datePublished={datePublished} - dateModified={dateModified} + dateModified={dateModified ?? undefined} pretestQuestionCount={pretestQuestions.length} pretestQuestions={pretestQuestions} articleAuthor={data.author} @@ -230,11 +235,13 @@ const orderedRefs = hasCitations
By {data.author || 'Fabian Moor'} - - Last updated - - {data.reviewedBy && by {data.reviewedBy}} - + {dateModified && dateModifiedDisplay && ( + + Last updated + + {data.reviewedBy && by {data.reviewedBy}} + + )}
{data.tags && data.tags.length > 0 && ( @@ -259,7 +266,8 @@ const orderedRefs = hasCitations
{data.sections.map(section => ( diff --git a/src/pages/quiz/index.astro b/src/pages/quiz/index.astro index a3ab539..7960c7b 100644 --- a/src/pages/quiz/index.astro +++ b/src/pages/quiz/index.astro @@ -6,6 +6,7 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { QuizPage } from '../../components/quiz/QuizPage'; import type { LearnEntry } from '../../lib/quiz'; +import { mapCuratedQuestions, type CuratedQuestionSource } from '../../lib/quiz'; const learnEntries = await getCollection('learn'); @@ -20,6 +21,26 @@ const entries: LearnEntry[] = learnEntries.map(entry => ({ }, })); +// Curated, expert-vetted (or explicitly unvetted-flagged) question bank — +// loaded curated-first ahead of the auto-generated pool in QuizPage. +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, +})); +const curatedQuestions = mapCuratedQuestions(curatedSources); + const pageDescription = 'Practice and exam-mode quizzes aligned with the FGA diploma curriculum, covering all eight gemmology topic areas with SM-2 spaced-repetition scheduling.'; @@ -131,7 +152,7 @@ const quizSchema = { - +
diff --git a/src/pages/tools/measurement.astro b/src/pages/tools/measurement.astro index 40694ac..5728312 100644 --- a/src/pages/tools/measurement.astro +++ b/src/pages/tools/measurement.astro @@ -12,6 +12,7 @@ const tools = [ { name: "Birefringence Calculator", description: "Maximum minus minimum RI for doubly refractive stones" }, { name: "Critical Angle Calculator", description: "Total-internal-reflection angle from RI" }, { name: "Carat Estimator", description: "Weight from length, width, depth, shape factor, and girdle correction" }, + { name: "Density Estimator", description: "Alternative SG calculation for irregular shapes via displacement volume" }, { name: "Hanneman/Hodgkinson Shortcut", description: "Over-the-limit RI inference from liquid-relief band position" }, ]; --- diff --git a/src/styles/global.css b/src/styles/global.css index ac3fab4..a1cf5c2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -71,7 +71,7 @@ } .input { - @apply rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 placeholder-slate-400 focus:border-crystal-500 focus:outline-none focus:ring-2 focus:ring-crystal-500/20; + @apply rounded-lg border border-slate-300 bg-white px-4 py-2 text-slate-900 placeholder-slate-500 focus:border-crystal-500 focus:outline-none focus:ring-2 focus:ring-crystal-500/20; } .badge {