// ---------------------------------------------------------------------------
// THE QUIZ APPLICATION
// ---------------------------------------------------------------------------
// This single Observable JS cell builds the entire interactive quiz as one
// self-contained DOM component and returns it for Quarto to display.
//
// WHY ONE BIG CELL INSTEAD OF MANY REACTIVE OJS CELLS?
// A quiz is inherently *stateful* — it has a current question, a running score,
// and answers that lock once chosen. Observable's reactive model is designed for
// dataflow (recompute when inputs change), which is awkward for imperative,
// step-by-step state. So we let OJS do what it is good at (loading the CSV,
// theming, embedding) and manage the quiz state ourselves with a plain-JavaScript
// closure. The cell depends only on `questions`, so it runs exactly once.
quizApp = {
// -- Fixed taxonomies -----------------------------------------------------
// The four difficulty levels, in the order they should appear.
const LEVELS = ["Beginner", "Intermediate", "Expert"];
// A one-line description of each level, shown to help users choose.
const LEVEL_HINTS = {
"Beginner": "Foundational definitions — “what is X”.",
"Intermediate": "How things work and how pieces connect.",
"Expert": "Nuanced distinctions, standards, comparing similar tools."
};
// The topic areas, in display order. These MUST match the `area` values in the
// CSV exactly. Any area found in the CSV but missing here would be dropped, so
// we also warn (in the console) if that happens, to catch typos when editing.
const AREA_ORDER = [
"Persistent Identifiers",
"Research Data Management",
"Open Access",
"Research Assessment & Metrics",
"Research Integrity",
"Reproducibility & Open Methods",
"Scholarly Infrastructure & Organizations",
"Preprints & Peer Review",
"Open Source & Software",
"Policy & Funder Mandates"
];
// Maximum number of questions in a single session. If more questions match the
// chosen filters, we randomly sample this many so sessions stay a reasonable
// length. The user is told how many of the matching pool are being asked.
const MAX_QUESTIONS = 15;
// The five option columns, in order.
const OPTION_LETTERS = ["a", "b", "c", "d", "e"];
// The two answering modes, in display order.
const MODES = [
{
id: "mcq",
title: "Multiple choice",
hint: "Pick the best answer from five options. Scored automatically."
},
{
id: "short",
title: "Short answer (self-graded)",
hint: "Write your answer from memory, then compare it with the model answer and mark yourself. Harder — it tests recall, not recognition."
}
];
// A few questions only make sense alongside their options (e.g. "which of
// these is a correctly formed DOI?"), so they are excluded from short-answer
// mode via the `mcq_only` column in the CSV. Mark a question by putting "yes"
// in that column — no code change needed.
const isMcqOnly = q => String(q.mcq_only || "").trim().toLowerCase() === "yes";
// -- Sanity check: warn if the CSV contains an area we don't know about -----
const knownAreas = new Set(AREA_ORDER);
const unknownAreas = [...new Set(questions.map(q => q.area))].filter(a => !knownAreas.has(a));
if (unknownAreas.length) {
console.warn("Questions.csv contains area(s) not in AREA_ORDER (they will be hidden):", unknownAreas);
}
// Only offer areas that actually have at least one question.
const areasPresent = AREA_ORDER.filter(a => questions.some(q => q.area === a));
// -- Application state ------------------------------------------------------
const state = {
screen: "landing", // "landing" | "quiz" | "summary"
mode: "mcq", // "mcq" (pick an option) | "short" (write, then self-grade)
level: null, // chosen difficulty (string)
areas: new Set(), // chosen topic areas
deck: [], // the questions for this session, in order
idx: 0, // index of the current question in the deck
// One entry per answered question. MCQ entries carry {chosen}; short-answer
// entries carry {typed} instead, and their isCorrect is self-reported.
responses: []
};
// The root element we return to OJS. Everything is re-rendered inside it.
const root = document.createElement("div");
root.className = "quiz-app";
// -- Small helpers ----------------------------------------------------------
// Fisher–Yates shuffle, returns a new array (does not mutate the input).
function shuffle(array) {
const a = array.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Is this question usable in the currently selected mode? Every question works
// as multiple choice; short-answer mode drops the option-dependent ones.
function usableInMode(q) {
return state.mode === "short" ? !isMcqOnly(q) : true;
}
// How many questions match the current mode + level + area selection?
function matchingQuestions() {
if (!state.level || state.areas.size === 0) return [];
return questions.filter(q =>
q.level === state.level && state.areas.has(q.area) && usableInMode(q)
);
}
// Create an element with optional class, text, and attributes. Keeps the
// render functions below concise and readable.
function el(tag, { className, text, html, attrs } = {}) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
if (html != null) node.innerHTML = html;
if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
return node;
}
// -- Screen 1: Landing / filter selection -----------------------------------
function renderLanding() {
root.innerHTML = "";
// A short stats strip. Counts are derived from the CSV rather than written
// out, so they cannot go stale as questions are added.
const stats = el("p", { className: "qa-stats" });
for (const bit of [
`${questions.length} questions`,
`${areasPresent.length} topic areas`,
`${LEVELS.length} difficulty levels`,
"nothing is saved"
]) {
stats.append(el("span", { className: "qa-stat", text: bit }));
}
root.append(stats);
// --- Answering mode (single-select via radio buttons) ---
const modeField = el("fieldset", { className: "qa-fieldset" });
modeField.append(el("legend", { text: "1. Choose how you want to answer" }));
for (const mode of MODES) {
const id = "mode-" + mode.id;
const wrap = el("label", { className: "qa-choice", attrs: { for: id } });
const input = el("input", {
attrs: { type: "radio", name: "mode", id, value: mode.id }
});
if (state.mode === mode.id) input.checked = true;
input.addEventListener("change", () => {
state.mode = mode.id;
// Re-render: the per-area counts and the matching count both depend on
// the mode, because short-answer mode uses a slightly smaller pool.
renderLanding();
});
const textWrap = el("span", { className: "qa-choice-text" });
textWrap.append(el("span", { className: "qa-choice-title", text: mode.title }));
textWrap.append(el("span", { className: "qa-choice-hint", text: mode.hint }));
wrap.append(input, textWrap);
modeField.append(wrap);
}
root.append(modeField);
// --- Difficulty level (single-select via radio buttons) ---
const levelField = el("fieldset", { className: "qa-fieldset" });
levelField.append(el("legend", { text: "2. Choose a difficulty level" }));
for (const level of LEVELS) {
const id = "level-" + level;
const wrap = el("label", { className: "qa-choice", attrs: { for: id } });
const input = el("input", {
attrs: { type: "radio", name: "level", id, value: level }
});
if (state.level === level) input.checked = true;
input.addEventListener("change", () => {
state.level = level;
// Re-render the landing screen so the per-area question counts update to
// reflect the newly chosen level (and the Start button state refreshes).
renderLanding();
});
const textWrap = el("span", { className: "qa-choice-text" });
textWrap.append(el("span", { className: "qa-choice-title", text: level }));
textWrap.append(el("span", { className: "qa-choice-hint", text: LEVEL_HINTS[level] }));
wrap.append(input, textWrap);
levelField.append(wrap);
}
root.append(levelField);
// --- Topic areas (multi-select via checkboxes) ---
const areaField = el("fieldset", { className: "qa-fieldset" });
areaField.append(el("legend", { text: "3. Choose one or more topic areas" }));
// Convenience: select-all / clear-all controls.
const areaControls = el("div", { className: "qa-area-controls" });
const selectAll = el("button", { className: "qa-link-btn", text: "Select all", attrs: { type: "button" } });
const clearAll = el("button", { className: "qa-link-btn", text: "Clear all", attrs: { type: "button" } });
selectAll.addEventListener("click", () => { state.areas = new Set(areasPresent); renderLanding(); });
clearAll.addEventListener("click", () => { state.areas.clear(); renderLanding(); });
areaControls.append(selectAll, clearAll);
areaField.append(areaControls);
for (const area of areasPresent) {
const id = "area-" + area.replace(/\W+/g, "-");
const wrap = el("label", { className: "qa-choice", attrs: { for: id } });
const input = el("input", {
attrs: { type: "checkbox", name: "area", id, value: area }
});
if (state.areas.has(area)) input.checked = true;
input.addEventListener("change", () => {
if (input.checked) state.areas.add(area);
else state.areas.delete(area);
updateStartability();
});
const textWrap = el("span", { className: "qa-choice-text" });
textWrap.append(el("span", { className: "qa-choice-title", text: area }));
// Show how many questions exist for this area at the chosen level (if any).
const count = state.level
? questions.filter(q => q.area === area && q.level === state.level && usableInMode(q)).length
: questions.filter(q => q.area === area && usableInMode(q)).length;
textWrap.append(el("span", {
className: "qa-choice-hint",
text: state.level ? `${count} ${state.level} question${count === 1 ? "" : "s"}` : `${count} questions total`
}));
wrap.append(input, textWrap);
areaField.append(wrap);
}
root.append(areaField);
// --- Start button + live matching count ---
const startRow = el("div", { className: "qa-start-row" });
const startBtn = el("button", { className: "qa-btn qa-btn-primary", text: "Start quiz", attrs: { type: "button" } });
const countMsg = el("p", { className: "qa-count", attrs: { "aria-live": "polite" } });
startBtn.addEventListener("click", startQuiz);
startRow.append(startBtn, countMsg);
root.append(startRow);
// Enable/disable the Start button and update the count message.
function updateStartability() {
const matches = matchingQuestions();
const n = matches.length;
if (!state.level) {
countMsg.textContent = "Choose a difficulty level to begin.";
startBtn.disabled = true;
} else if (state.areas.size === 0) {
countMsg.textContent = "Choose at least one topic area.";
startBtn.disabled = true;
} else if (n === 0) {
countMsg.textContent = "No questions match this combination yet — try other areas or a different level.";
startBtn.disabled = true;
} else {
const asked = Math.min(n, MAX_QUESTIONS);
countMsg.textContent = asked < n
? `${n} questions match. You'll be asked a random ${asked}.`
: `${n} question${n === 1 ? "" : "s"} match — you'll be asked all of them.`;
startBtn.disabled = false;
}
}
updateStartability();
}
// -- Begin a session from the current selection -----------------------------
function startQuiz() {
const pool = shuffle(matchingQuestions());
state.deck = pool.slice(0, MAX_QUESTIONS);
state.idx = 0;
state.responses = [];
state.screen = "quiz";
render();
}
// -- Screen 2: One question at a time ---------------------------------------
function renderQuiz() {
root.innerHTML = "";
const q = state.deck[state.idx];
const total = state.deck.length;
// Progress indicator (text + a simple bar).
const progress = el("div", { className: "qa-progress" });
progress.append(el("span", {
className: "qa-progress-text",
text: `Question ${state.idx + 1} of ${total}`
}));
const bar = el("div", { className: "qa-progress-bar", attrs: { "aria-hidden": "true" } });
const fill = el("div", { className: "qa-progress-fill" });
fill.style.width = `${(state.idx / total) * 100}%`;
bar.append(fill);
progress.append(bar);
root.append(progress);
// Metadata line: level + area, so users always know the context.
const meta = el("p", { className: "qa-qmeta" });
meta.append(el("span", { className: "qa-tag", text: q.level }));
meta.append(el("span", { className: "qa-tag", text: q.area }));
root.append(meta);
// The question text.
root.append(el("h2", { className: "qa-question", text: q.question }));
// A live region where feedback appears after the user answers. Both modes
// write into it, so it is created before the mode-specific body.
const feedback = el("div", { className: "qa-feedback", attrs: { role: "status", "aria-live": "polite" } });
root._feedback = feedback;
if (state.mode === "short") {
renderShortAnswerBody(q, feedback);
return;
}
// The five options, as buttons in a group for keyboard accessibility.
const optionsGroup = el("div", { className: "qa-options", attrs: { role: "group", "aria-label": "Answer options" } });
const optionButtons = [];
for (const letter of OPTION_LETTERS) {
const optText = q["option_" + letter];
if (optText == null || optText === "") continue; // skip if fewer than 5 (defensive)
const btn = el("button", { className: "qa-option", attrs: { type: "button" } });
btn.append(el("span", { className: "qa-option-letter", text: letter.toUpperCase() }));
btn.append(el("span", { className: "qa-option-body", text: optText }));
btn.addEventListener("click", () => chooseAnswer(letter, optionButtons, q));
optionButtons.push({ letter, btn });
optionsGroup.append(btn);
}
root.append(optionsGroup);
root.append(feedback);
root._optionsGroup = optionsGroup;
}
// -- Screen 2b: Short-answer body -------------------------------------------
// The user writes an answer from memory, reveals the model answer, then grades
// themselves. There is no backend and no language model here, so honest
// self-assessment is the only grading available — and it is a good fit for a
// no-stakes tool whose point is to surface what you do not yet know.
function renderShortAnswerBody(q, feedback) {
const form = el("div", { className: "qa-short" });
const label = el("label", {
className: "qa-short-label",
text: "Your answer (from memory — a sentence or two is plenty)",
attrs: { for: "qa-short-input" }
});
const box = el("textarea", {
className: "qa-short-input",
attrs: { id: "qa-short-input", rows: "4", placeholder: "Type what you know…" }
});
form.append(label, box);
const revealBtn = el("button", {
className: "qa-btn qa-btn-primary",
text: "Reveal model answer",
attrs: { type: "button" }
});
const skipBtn = el("button", {
className: "qa-btn",
text: "I don't know",
attrs: { type: "button" }
});
const btnRow = el("div", { className: "qa-start-row" });
btnRow.append(revealBtn, skipBtn);
form.append(btnRow);
root.append(form);
root.append(feedback);
box.focus();
// Ctrl/Cmd+Enter reveals, so a keyboard user never has to leave the textarea.
box.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
reveal(false);
}
});
revealBtn.addEventListener("click", () => reveal(false));
skipBtn.addEventListener("click", () => reveal(true));
// Show the model answer and ask the user to grade themselves. `gaveUp` skips
// straight to recording an incorrect response.
function reveal(gaveUp) {
box.disabled = true;
revealBtn.disabled = true;
skipBtn.disabled = true;
feedback.innerHTML = "";
feedback.className = "qa-feedback qa-feedback-reveal";
feedback.append(el("p", { className: "qa-feedback-heading", text: "Model answer" }));
feedback.append(el("p", {
className: "qa-model-answer",
text: q["option_" + q.correct_option]
}));
feedback.append(el("p", { className: "qa-explanation", text: q.explanation_correct }));
if (q.source_url && q.source_url.trim()) {
const src = el("p", { className: "qa-source" });
src.append(el("a", {
text: "Further reading ↗",
attrs: { href: q.source_url.trim(), target: "_blank", rel: "noopener" }
}));
feedback.append(src);
}
if (gaveUp) {
recordShortAnswer(q, box.value, false);
return;
}
// Self-grading controls. Deliberately two plain buttons rather than a
// slider or partial credit: the finer the scale, the more the user has to
// adjudicate their own answer instead of reading the model one.
feedback.append(el("p", {
className: "qa-selfgrade-prompt",
text: "Compared with the model answer, did you have the substance of it?"
}));
const gradeRow = el("div", { className: "qa-start-row" });
const gotIt = el("button", { className: "qa-btn qa-btn-correct", text: "✓ I had it", attrs: { type: "button" } });
const missed = el("button", { className: "qa-btn qa-btn-wrong", text: "✗ I missed it", attrs: { type: "button" } });
gotIt.addEventListener("click", () => recordShortAnswer(q, box.value, true));
missed.addEventListener("click", () => recordShortAnswer(q, box.value, false));
gradeRow.append(gotIt, missed);
feedback.append(gradeRow);
feedback.setAttribute("tabindex", "-1");
feedback.focus();
}
}
// Record a self-graded response and offer the advance control.
function recordShortAnswer(q, typed, isCorrect) {
state.responses.push({ question: q, typed: typed.trim(), isCorrect });
const feedback = root._feedback;
// Drop the self-grading controls, keep the model answer visible.
for (const node of [...feedback.querySelectorAll(".qa-start-row, .qa-selfgrade-prompt")]) {
node.remove();
}
feedback.classList.add(isCorrect ? "qa-feedback-correct" : "qa-feedback-wrong");
const verdict = el("p", { className: "qa-selfgrade-verdict" });
verdict.append(el("span", { className: "qa-feedback-icon", attrs: { "aria-hidden": "true" }, text: isCorrect ? "✓" : "✗" }));
verdict.append(el("span", { text: isCorrect ? "Marked as known" : "Marked as not known" }));
feedback.append(verdict);
feedback.append(makeAdvanceButton());
feedback.setAttribute("tabindex", "-1");
feedback.focus();
}
// The "Next question" / "See results" control, shared by both modes.
function makeAdvanceButton() {
const isLast = state.idx === state.deck.length - 1;
const nextBtn = el("button", {
className: "qa-btn qa-btn-primary",
text: isLast ? "See results" : "Next question",
attrs: { type: "button" }
});
nextBtn.addEventListener("click", () => {
if (isLast) {
state.screen = "summary";
} else {
state.idx += 1;
}
render();
});
return nextBtn;
}
// Handle the user selecting an answer for the current question.
function chooseAnswer(chosenLetter, optionButtons, q) {
const isCorrect = chosenLetter === q.correct_option;
// Record the response (only once per question — buttons are disabled after).
state.responses.push({ question: q, chosen: chosenLetter, isCorrect });
// Lock and visually mark every option: highlight the correct one, and mark
// the chosen one if it was wrong. We use icons + text, never colour alone.
for (const { letter, btn } of optionButtons) {
btn.disabled = true;
if (letter === q.correct_option) {
btn.classList.add("qa-option-correct");
btn.append(el("span", { className: "qa-option-mark", html: "✓ <span class='qa-visually-hidden'>Correct answer</span>" }));
} else if (letter === chosenLetter) {
btn.classList.add("qa-option-wrong");
btn.append(el("span", { className: "qa-option-mark", html: "✗ <span class='qa-visually-hidden'>Your answer, incorrect</span>" }));
}
}
// Build the feedback block.
const feedback = root._feedback;
feedback.innerHTML = "";
feedback.classList.add(isCorrect ? "qa-feedback-correct" : "qa-feedback-wrong");
const heading = el("p", { className: "qa-feedback-heading" });
// Icon + word so the result is not conveyed by colour alone.
heading.append(el("span", { className: "qa-feedback-icon", attrs: { "aria-hidden": "true" }, text: isCorrect ? "✓" : "✗" }));
heading.append(el("span", { text: isCorrect ? "Correct" : "Incorrect" }));
feedback.append(heading);
// Always explain why the correct answer is correct.
feedback.append(el("p", { className: "qa-explanation", text: q.explanation_correct }));
// If the user was wrong, add the note on why common wrong answers are wrong.
if (!isCorrect && q.explanation_incorrect) {
feedback.append(el("p", { className: "qa-explanation qa-explanation-why", text: q.explanation_incorrect }));
}
// Optional link to further reading.
if (q.source_url && q.source_url.trim()) {
const src = el("p", { className: "qa-source" });
const link = el("a", { text: "Further reading ↗", attrs: { href: q.source_url.trim(), target: "_blank", rel: "noopener" } });
src.append(link);
feedback.append(src);
}
// Next / Finish button.
feedback.append(makeAdvanceButton());
// Move keyboard focus to the feedback so screen-reader users hear the result
// and can Tab straight to the Next button.
feedback.setAttribute("tabindex", "-1");
feedback.focus();
}
// -- Screen 3: Score summary ------------------------------------------------
function renderSummary() {
root.innerHTML = "";
const total = state.responses.length;
const correct = state.responses.filter(r => r.isCorrect).length;
const pct = total ? Math.round((correct / total) * 100) : 0;
root.append(el("h2", { text: "Your results" }));
// Score ring. The SVG is decorative (aria-hidden) because the same numbers
// are in the adjacent text, which is what a screen reader should read.
// stroke-dasharray draws `pct` percent of the circumference; the -90deg
// rotation in CSS moves the start point from 3 o'clock to 12 o'clock.
const R = 52;
const CIRC = 2 * Math.PI * R;
const ring = el("div", { className: "qa-score" });
ring.innerHTML = `
<svg class="qa-ring" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<circle class="qa-ring-track" cx="60" cy="60" r="${R}"></circle>
<circle class="qa-ring-fill" cx="60" cy="60" r="${R}"
stroke-dasharray="${(CIRC * pct / 100).toFixed(1)} ${CIRC.toFixed(1)}"></circle>
</svg>`;
const scoreText = el("div", { className: "qa-score-text" });
scoreText.append(el("span", { className: "qa-score-big", text: `${correct} / ${total}` }));
scoreText.append(el("span", { className: "qa-score-pct", text: `${pct}% correct` }));
ring.append(scoreText);
root.append(ring);
if (state.mode === "short") {
root.append(el("p", {
className: "qa-count",
text: "Short-answer scores are self-reported — this is your own judgement of your recall, not a graded result."
}));
}
// Per-area breakdown, shown when more than one area was in play.
const areasInDeck = [...new Set(state.responses.map(r => r.question.area))];
if (areasInDeck.length > 1) {
root.append(el("h3", { text: "Breakdown by topic area" }));
const table = el("table", { className: "qa-breakdown" });
const thead = el("thead");
thead.innerHTML = "<tr><th scope='col'>Area</th><th scope='col'>Score</th></tr>";
table.append(thead);
const tbody = el("tbody");
for (const area of areasInDeck.sort()) {
const rs = state.responses.filter(r => r.question.area === area);
const c = rs.filter(r => r.isCorrect).length;
const tr = el("tr");
tr.append(el("th", { text: area, attrs: { scope: "row" } }));
// The cell keeps its plain "3 / 5" text for screen readers and adds a
// purely decorative bar beside it, so the table stays a real table.
const td = el("td");
const meter = el("div", { className: "qa-bar", attrs: { "aria-hidden": "true" } });
const meterFill = el("div", { className: "qa-bar-fill" });
meterFill.style.width = `${rs.length ? (c / rs.length) * 100 : 0}%`;
meter.append(meterFill);
td.append(meter, el("span", { className: "qa-bar-label", text: `${c} / ${rs.length}` }));
tr.append(td);
tbody.append(tr);
}
table.append(tbody);
root.append(table);
}
// Collapsible review of every question, useful as a learning aid.
const details = el("details", { className: "qa-review" });
details.append(el("summary", { text: "Review all questions and answers" }));
for (const r of state.responses) {
const q = r.question;
const item = el("div", { className: "qa-review-item" });
const mark = r.isCorrect ? "✓" : "✗";
item.append(el("p", {
className: "qa-review-q " + (r.isCorrect ? "qa-review-ok" : "qa-review-no"),
text: `${mark} ${q.question}`
}));
item.append(el("p", {
className: "qa-review-a",
text: `Correct answer: ${q.correct_option.toUpperCase()}. ${q["option_" + q.correct_option]}`
}));
// Short-answer responses carry the text the user typed; MCQ responses
// carry the letter they picked. Show whichever applies.
if (r.typed !== undefined) {
item.append(el("p", {
className: "qa-review-a qa-review-yours",
text: r.typed ? `You wrote: ${r.typed}` : "You left this blank."
}));
} else if (!r.isCorrect) {
item.append(el("p", {
className: "qa-review-a qa-review-yours",
text: `You chose: ${r.chosen.toUpperCase()}. ${q["option_" + r.chosen]}`
}));
}
item.append(el("p", { className: "qa-review-exp", text: q.explanation_correct }));
details.append(item);
}
root.append(details);
// Restart controls: play the same filters again, or go back to change them.
const btnRow = el("div", { className: "qa-start-row" });
const againBtn = el("button", { className: "qa-btn qa-btn-primary", text: "Try these settings again", attrs: { type: "button" } });
const newBtn = el("button", { className: "qa-btn", text: "Change level or areas", attrs: { type: "button" } });
againBtn.addEventListener("click", startQuiz); // reuses current state.level / state.areas
newBtn.addEventListener("click", () => { state.screen = "landing"; render(); });
btnRow.append(againBtn, newBtn);
root.append(btnRow);
}
// -- Master render switch ---------------------------------------------------
function render() {
if (state.screen === "quiz") renderQuiz();
else if (state.screen === "summary") renderSummary();
else renderLanding();
// Scroll the component into view on screen changes for a smoother flow.
root.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
render();
return root;
}Pick how you want to answer, a difficulty level, and the topics you care about. You’ll get one question at a time, with an explanation and a link to further reading after each one. Nothing is recorded, and you can retake it as often as you like.
For where the questions come from, why this exists, and how it was built, see About.