diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 67f80ff..e1b1d67 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -9,7 +9,7 @@ import { expect, test, type Page } from '@playwright/test'; test.use({ reducedMotion: 'reduce' }); const LESSONS = [...readFileSync('src/content/manifest.ts', 'utf8').matchAll(/id: '(\d\d-\d)'/g)].map((m) => m[1]); -const PAGES = ['./', './workspace', './which-model', './sample-size', './avatar', ...LESSONS.map((id) => `./lesson/${id}`)]; +const PAGES = ['./', './workspace', './which-model', './sample-size', './review', './avatar', ...LESSONS.map((id) => `./lesson/${id}`)]; async function violations(page: Page) { const results = await new AxeBuilder({ page }) diff --git a/src/App.tsx b/src/App.tsx index d216ae8..5e1318f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Lesson from './pages/Lesson'; import RWorkspace from './pages/RWorkspace'; import ModelChooser from './pages/ModelChooser'; import SampleSize from './pages/SampleSize'; +import Review from './pages/Review'; import AvatarStudio from './pages/AvatarStudio'; import { fetchDataset, prepareSession } from './r/session'; import { getWebR } from './r/webrClient'; @@ -112,6 +113,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* The page was called "which test" until the curriculum settled on teaching one model under many names. Kept so links already shared diff --git a/src/components/ChoiceBlock.tsx b/src/components/ChoiceBlock.tsx index 7d2df55..ba184ea 100644 --- a/src/components/ChoiceBlock.tsx +++ b/src/components/ChoiceBlock.tsx @@ -18,6 +18,8 @@ type Props = { kind: ChoiceKind; question: string; choices: Choice[]; + /** Called once, with whether the pick was right. */ + onAnswer?: (correct: boolean) => void; }; const LABELS: Record = { @@ -28,7 +30,7 @@ const LABELS: Record = { const ICONS: Record = { predict: '🔮', quiz: '💡', interpret: '📝' }; -export default function ChoiceBlock({ id, kind, question, choices }: Props) { +export default function ChoiceBlock({ id, kind, question, choices, onAnswer }: Props) { const { lessonId } = useLesson(); const [chosen, setChosen] = useState(null); @@ -37,6 +39,7 @@ export default function ChoiceBlock({ id, kind, question, choices }: Props) { setChosen(index); // Predictions are deliberately not scored: commitment, not assessment. if (kind !== 'predict') markQuiz(lessonId, id, choices[index].correct === true); + onAnswer?.(choices[index].correct === true); } const selection = chosen === null ? null : choices[chosen]; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index db24dea..04edd1b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -120,6 +120,7 @@ export default function Sidebar({ open = false }: Props) {
  • Which model should I use?
  • How many participants?
  • +
  • Review quiz
  • R Workspace
diff --git a/src/content/guideIndex.ts b/src/content/guideIndex.ts index 72fd05b..ba783d9 100644 --- a/src/content/guideIndex.ts +++ b/src/content/guideIndex.ts @@ -85,6 +85,12 @@ export const PAGE_ENTRIES: { path: string; title: string; what: string; keywords what: 'Work out the sample size for your study, step by step.', keywords: 'sample size how many participants people power analysis gpower g*power n ab test a/b 2x2 factorial', }, + { + path: '/review', + title: 'Review quiz', + what: 'Ten mixed questions from the lessons you have opened.', + keywords: 'review quiz practice revise revision exam test yourself questions repeat remember study', + }, { path: '/workspace', title: 'R Workspace', diff --git a/src/content/quizBank.test.ts b/src/content/quizBank.test.ts new file mode 100644 index 0000000..596e005 --- /dev/null +++ b/src/content/quizBank.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { ALL_LESSONS } from './manifest'; +import { loadQuizzes, pickRound, type BankQuiz } from './quizBank'; + +describe('quiz bank', () => { + it('finds every quiz in the course, each with exactly one right answer', async () => { + const quizzes = await loadQuizzes(ALL_LESSONS.map((lesson) => lesson.id)); + expect(quizzes.length).toBeGreaterThanOrEqual(60); + expect(new Set(quizzes.map((quiz) => `${quiz.lessonId}/${quiz.id}`)).size).toBe(quizzes.length); + for (const quiz of quizzes) { + expect(quiz.question, quiz.id).toBeTruthy(); + expect(quiz.choices.filter((choice) => choice.correct).length, quiz.id).toBe(1); + } + }); + + it('only loads the lessons asked for', async () => { + const quizzes = await loadQuizzes(['06-1']); + expect(quizzes.map((quiz) => quiz.id)).toContain('q-6-1'); + expect(quizzes.every((quiz) => quiz.lessonId === '06-1')).toBe(true); + }); + + it('puts wrong answers first in line, then unanswered, then right', () => { + const quiz = (id: string): BankQuiz => ({ lessonId: '06-1', id, question: id, choices: [{ text: 'x', correct: true, response: '' }] }); + const bank = ['right', 'wrong', 'new'].map(quiz); + const progress = { version: 2 as const, lessons: { '06-1': { exercises: {}, drafts: {}, quizzes: { right: true, wrong: false } } } }; + expect(pickRound(bank, progress, 1).map((q) => q.id)).toEqual(['wrong']); + expect(pickRound(bank, progress, 2).map((q) => q.id).sort()).toEqual(['new', 'wrong']); + expect(pickRound(bank, progress, 10)).toHaveLength(3); + }); +}); diff --git a/src/content/quizBank.ts b/src/content/quizBank.ts new file mode 100644 index 0000000..c53c97c --- /dev/null +++ b/src/content/quizBank.ts @@ -0,0 +1,69 @@ +import { isValidElement, type ReactNode } from 'react'; +import type { Choice } from '../components/ChoiceBlock'; +import type { Progress } from '../state/progress'; +import { ALL_LESSONS } from './manifest'; +import { mdxComponents } from './mdxComponents'; + +export type BankQuiz = { lessonId: string; id: string; question: string; choices: Choice[] }; + +type Content = (props: { components?: unknown }) => ReactNode; +const loaders = import.meta.glob<{ default: Content }>('./lessons/*.mdx'); + +function Marker() { + return null; +} + +/** + * The quizzes a lesson contains, read from its compiled MDX rather than kept in + * a second copy: the lesson is called with a stand-in for Quiz, and the element + * tree it returns is searched for that stand-in. Nothing is rendered. + */ +export function quizzesIn(content: Content, lessonId: string): BankQuiz[] { + const found: BankQuiz[] = []; + const walk = (node: ReactNode): void => { + if (Array.isArray(node)) node.forEach(walk); + else if (isValidElement<{ children?: ReactNode; id: string; question: string; choices: Choice[] }>(node)) { + const { children, id, question, choices } = node.props; + if (node.type === Marker) found.push({ lessonId, id, question, choices }); + else walk(children); + } + }; + walk(content({ components: { ...mdxComponents, Quiz: Marker } })); + return found; +} + +export async function loadQuizzes(lessonIds: string[]): Promise { + const lessons = ALL_LESSONS.filter((lesson) => lessonIds.includes(lesson.id)); + const lists = await Promise.all( + lessons.map(async (lesson) => { + const loader = loaders[`./lessons/${lesson.file}.mdx`]; + return loader ? quizzesIn((await loader()).default, lesson.id) : []; + }), + ); + return lists.flat(); +} + +function shuffle(items: T[], random: () => number): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +/** + * A mixed round: questions first answered wrong come first in line, then ones + * never answered, then the rest, and the round is shuffled so topics + * interleave. Choices are shuffled too, so an answer can't be remembered by + * its letter. + */ +export function pickRound(quizzes: BankQuiz[], progress: Progress, size = 10, random = Math.random): BankQuiz[] { + const state = (quiz: BankQuiz) => progress.lessons[quiz.lessonId]?.quizzes[quiz.id]; + const line = [ + ...shuffle(quizzes.filter((quiz) => state(quiz) === false), random), + ...shuffle(quizzes.filter((quiz) => state(quiz) === undefined), random), + ...shuffle(quizzes.filter((quiz) => state(quiz) === true), random), + ]; + return shuffle(line.slice(0, size), random).map((quiz) => ({ ...quiz, choices: shuffle(quiz.choices, random) })); +} diff --git a/src/pages/Review.test.tsx b/src/pages/Review.test.tsx new file mode 100644 index 0000000..6e84a00 --- /dev/null +++ b/src/pages/Review.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getProgress, markQuiz, STORAGE_KEY, touchLesson } from '../state/progress'; +import Review from './Review'; + +const show = () => render().container; + +describe('Review quiz', () => { + beforeEach(() => localStorage.removeItem(STORAGE_KEY)); + + it('points to a first lesson when nothing has been opened', async () => { + show(); + expect(await screen.findByText('Nothing to review yet')).toBeTruthy(); + }); + + it('asks questions from opened lessons and scores the round', async () => { + touchLesson('06-1'); + const page = show(); + expect(await screen.findByText(/Question 1 of \d+/)).toBeTruthy(); + expect(screen.getByRole('link', { name: /Lesson 6-1/ })).toBeTruthy(); + const total = Number(screen.getByText(/Question 1 of/).textContent!.match(/of (\d+)/)![1]); + for (let i = 0; i < total; i++) { + fireEvent.click(page.querySelector('.choice-options button')!); + fireEvent.click(screen.getByRole('button', { name: /Next question|See your score/ })); + } + expect(screen.getByText(new RegExp(`of ${total} right`))).toBeTruthy(); + expect(screen.getByRole('button', { name: 'New round' })).toBeTruthy(); + }); +}); + +describe('markQuiz', () => { + beforeEach(() => localStorage.removeItem(STORAGE_KEY)); + + it('keeps a right answer when the question is later answered wrong', () => { + markQuiz('06-1', 'q1', true); + markQuiz('06-1', 'q1', false); + expect(getProgress().lessons['06-1'].quizzes.q1).toBe(true); + }); +}); diff --git a/src/pages/Review.tsx b/src/pages/Review.tsx new file mode 100644 index 0000000..f9acefd --- /dev/null +++ b/src/pages/Review.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import AvatarTip from '../components/AvatarTip'; +import ChoiceBlock from '../components/ChoiceBlock'; +import { LessonProvider } from '../content/LessonContext'; +import { ALL_LESSONS, findLesson } from '../content/manifest'; +import { loadQuizzes, pickRound, type BankQuiz } from '../content/quizBank'; +import { getProgress } from '../state/progress'; +import { firstUnfinished } from '../state/stats'; +import './ModelChooser.css'; + +const label = (lessonId: string) => `Lesson ${lessonId.replace(/^0/, '')}`; + +export default function Review() { + const [round, setRound] = useState(null); + const [at, setAt] = useState(0); + const [answered, setAnswered] = useState(false); + const [missed, setMissed] = useState([]); + const [roundNumber, setRoundNumber] = useState(0); + + useEffect(() => { + let live = true; + const progress = getProgress(); + void loadQuizzes(Object.keys(progress.lessons)).then((quizzes) => { + if (!live) return; + setRound(pickRound(quizzes, progress)); + setAt(0); + setAnswered(false); + setMissed([]); + }); + return () => { + live = false; + }; + }, [roundNumber]); + + const quiz = round?.[at]; + const start = firstUnfinished(getProgress()) ?? ALL_LESSONS[0]; + + return ( +
+
+

Review quiz

+

+ Ten questions, mixed, from the lessons you have opened. Pulling an answer from memory, with topics mixed + together, is one of the best-tested ways to make it stick. Questions you got wrong in a lesson come up first. +

+
+ + {round && round.length === 0 && ( + +

+ Questions come from the lessons you have opened. Start with{' '} + {label(start.id)}: {start.title}, then come back here. +

+
+ )} + + {quiz && ( +
+

Question {at + 1} of {round.length}

+

+ From {label(quiz.lessonId)}: {findLesson(quiz.lessonId)?.title} +

+ + { + setAnswered(true); + if (!correct) setMissed((list) => [...list, quiz]); + }} + /> + + {answered && ( + + )} +
+ )} + + {round && round.length > 0 && at >= round.length && ( +
+

Round done

+

+ You got {round.length - missed.length} of {round.length} right. +

+ {missed.length === 0 ? ( +

A clean sweep. Open a few more lessons to widen the pool.

+ ) : ( + <> +

Worth another look:

+
    + {missed.map((item) => ( +
  • + {label(item.lessonId)}: {findLesson(item.lessonId)?.title} +
  • + ))} +
+ + )} + +
+ )} +
+ ); +} diff --git a/src/state/progress.ts b/src/state/progress.ts index f859723..147269c 100644 --- a/src/state/progress.ts +++ b/src/state/progress.ts @@ -214,6 +214,9 @@ export function markExercise(lessonId: string, exerciseId: string, status: Exerc export function markQuiz(lessonId: string, quizId: string, correct: boolean): void { update(lessonId, (lesson) => { + // Like an exercise pass, a right answer is permanent: answering again in a + // review round must not take back points already earned. + if (lesson.quizzes[quizId]) return; lesson.quizzes[quizId] = correct; }); }