feat: write quarto assignment with encryption

This commit is contained in:
2026-08-10 03:25:15 -04:00
parent 327ac371e4
commit 1cc8137be9
15 changed files with 1678 additions and 53 deletions
+976
View File
@@ -0,0 +1,976 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering an assessment for a Quarto course website, with solutions gated
//! behind a per-page password.
//!
//! This is the path that publishes to the web rather than to Canvas or a printed
//! exam. It produces three things for one assessment:
//!
//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
//! empty, hidden `.qsol` slot per item. A page includes it with
//! `{{< include _questions.qmd >}}`. It carries no answers.
//! 2. A `<id>-solutions.json` bundle: every solution rendered to an HTML fragment,
//! then encrypted. The ciphertext ships in the page, but the plaintext never
//! does, so a student cannot read answers from the source or the network tab.
//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
//! It is printed for the instructor and stored nowhere, so it cannot be
//! recovered from the files. Hand it out, and rotate it after a due date.
//!
//! The browser side is [`assets`]: `questions.css` styles the questions and the
//! unlocked solutions, and `solutions.js` derives the key from the typed password,
//! decrypts, and injects each fragment. The crypto here matches that script byte
//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
//! appended to the ciphertext. Get any parameter wrong and a correct password
//! would fail to authenticate.
//!
//! Two rules carry over from the other exporters. A question paper never contains
//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
//! the encrypted bundle. And option order comes from the form's seed, so the
//! printed letters in the questions and the letters the solution refers to are the
//! same order.
use crate::assessment::{AssessmentFile, Form, Placement};
use crate::catalog::Catalog;
use crate::course::{CourseFile, Reference};
use crate::error::{Error, Result};
use crate::item::{Choice, Citation, Item, Solution};
use crate::markup;
use crate::select;
use crate::taxonomy::Format;
use aes_gcm::Aes256Gcm;
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::{Aead, KeyInit};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
const ITERATIONS: u32 = 250_000;
/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
/// means five bits each, so sixteen of them carry eighty bits of entropy.
const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// The bundled browser assets, installed once per site (not per page).
///
/// # Returns
///
/// Pairs of file name and verbatim contents: the stylesheet and the unlock
/// script. Write them into the site's static directory and wire them in
/// `_quarto.yml`.
pub fn assets() -> [(&'static str, &'static str); 2] {
[
(
"questions.css",
include_str!("../assets/site/questions.css"),
),
("solutions.js", include_str!("../assets/site/solutions.js")),
]
}
/// How to render one assessment for the site.
#[derive(Debug, Clone)]
pub struct Options {
/// The form whose option order to print.
pub form: Form,
/// A password to encrypt with. `None` generates a fresh random one, which is
/// the intended path; pass `Some` only to re-encrypt a bundle with a known
/// password.
pub password: Option<String>,
}
impl Options {
/// Builds options for a form, generating the password.
///
/// # Arguments
///
/// * `form` - the form whose option order to print.
///
/// # Returns
///
/// Options that will generate a fresh password at render time.
pub fn new(form: Form) -> Options {
Options {
form,
password: None,
}
}
}
/// Everything one render produces, ready to write.
#[derive(Debug, Clone)]
pub struct Rendered {
/// The assessment id, used for the bundle file name and the gate's
/// `data-bundle` attribute.
pub page: String,
/// The `_questions.qmd` partial.
pub questions_qmd: String,
/// The `<id>-solutions.json` bundle, serialized and newline-terminated.
pub solutions_json: String,
/// The password that decrypts the bundle. Print it; it is stored nowhere.
pub password: String,
}
/// Renders an assessment into the questions partial and the encrypted bundle.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for items, objectives, and references.
/// * `record` - the assembled assessment.
/// * `opts` - the form and an optional password.
///
/// # Returns
///
/// The partial, the serialized bundle, and the password.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
/// failure.
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result<Rendered> {
let page = record.assessment.id.clone();
let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
let fragments = solution_fragments(catalog, record, &opts.form)?;
let password = match opts.password {
Some(p) => p,
None => gen_password()?,
};
let bundle = build_bundle(&page, &password, &fragments)?;
let json = serde_json::to_string_pretty(&bundle)
.map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
Ok(Rendered {
page,
questions_qmd,
solutions_json: format!("{json}\n"),
password,
})
}
/// Builds the `_questions.qmd` partial.
fn questions_qmd(
catalog: &Catalog,
record: &AssessmentFile,
form: &Form,
page: &str,
) -> Result<String> {
let mut out = String::new();
out.push_str(
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
\x20 matter, no title, no prose. Include from the page with:\n\
\x20 {{< include _questions.qmd >}} -->\n\n",
);
out.push_str(&format!(
"::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
));
let mut number = 0usize;
let default_points = catalog.course.policy.points_per_item;
let layout = select::layout(record, form);
for placement in layout.iter().filter(|p| !p.dropped) {
let item = &catalog.require(&placement.item)?.item;
number += 1;
out.push_str(&question_block(
number,
placement,
item,
form,
default_points,
));
out.push('\n');
}
// Leave exactly one trailing newline.
while out.ends_with("\n\n") {
out.pop();
}
Ok(out)
}
/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
fn question_block(
number: usize,
placement: &Placement,
item: &Item,
form: &Form,
default_points: f64,
) -> String {
let id = &item.id;
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
let unit = if (points - 1.0).abs() < f64::EPSILON {
"point"
} else {
"points"
};
let mut b = String::new();
b.push_str(&format!("::: {{.q #{id}}}\n"));
b.push_str(":::: {.q-head}\n");
b.push_str(&format!(
"[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
kind_label(item.format),
trim_number(points),
));
b.push_str("::::\n\n");
b.push_str(":::: {.q-stem}\n");
b.push_str(&markup::to_markdown(&item.stem));
b.push_str("\n::::\n\n");
if item.has_options() {
b.push_str(":::: {.q-choices}\n");
let order = select::option_order(form, &placement.item, item.options.len());
for (position, &source) in order.iter().enumerate() {
b.push_str(&format!(
"{}. {}\n",
position + 1,
markup::to_markdown(&item.options[source].text)
));
}
b.push_str("::::\n\n");
} else {
b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
}
b.push_str(&format!(
":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
));
b.push_str(":::\n");
b
}
/// The human label for a format, shown in the question head.
fn kind_label(format: Format) -> &'static str {
match format {
Format::SingleBestAnswer => "Single best answer",
Format::MultipleResponse => "Multiple response",
Format::TrueFalse => "True or false",
Format::OpenResponse => "Open response",
}
}
// --- the solution fragments ---
/// Renders each item's solution to an HTML fragment, in printed order.
///
/// An item with nothing to show (an open-response question whose solution is
/// empty) is left out, so its slot simply never unlocks.
fn solution_fragments(
catalog: &Catalog,
record: &AssessmentFile,
form: &Form,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
let layout = select::layout(record, form);
for placement in layout.iter().filter(|p| !p.dropped) {
let item = &catalog.require(&placement.item)?.item;
if let Some(html) = fragment(&catalog.course, placement, item, form) {
out.push((item.id.clone(), html));
}
}
Ok(out)
}
/// The fragment for one item, or `None` when there is nothing to show.
fn fragment(
course: &CourseFile,
placement: &Placement,
item: &Item,
form: &Form,
) -> Option<String> {
if item.has_options() {
Some(choice_fragment(course, placement, item, form))
} else {
open_fragment(course, item)
}
}
/// A single-best-answer or multiple-response fragment: the key, the model answer,
/// the explanation, then per-distractor feedback.
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
let order = select::option_order(form, &placement.item, item.options.len());
let printed: Vec<(usize, &Choice)> = order
.iter()
.enumerate()
.map(|(position, &source)| (position, &item.options[source]))
.collect();
let mut out = String::new();
let keyed: Vec<String> = printed
.iter()
.filter(|(_, c)| c.correct)
.map(|(position, c)| {
format!(
"<b>{}</b> &mdash; {}",
letter(*position),
inline_html(&c.text)
)
})
.collect();
out.push_str(
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
);
out.push_str(&keyed.join("; "));
out.push_str("</p>\n");
if let Some(solution) = item.solution.as_ref() {
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&format!(
"<p class=\"sol-explain\">{}</p>\n",
inline_html(explanation)
));
}
}
let distractors: Vec<(usize, &Choice)> = printed
.iter()
.copied()
.filter(|(_, c)| !c.correct)
.collect();
if distractors
.iter()
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
{
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
out.push_str("<ul class=\"sol-feedback\">\n");
for (position, c) in &distractors {
if c.misconception.is_none() && why_wrong(c).is_none() {
continue;
}
out.push_str(&format!(
" <li><span class=\"opt\">{}</span>\n",
letter(*position)
));
out.push_str(" <div class=\"opt-body\">\n");
if let Some(mis) = &c.misconception {
out.push_str(&format!(
" <span class=\"opt-mis\">{}</span>\n",
inline_html(mis)
));
}
if let Some(why) = why_wrong(c) {
out.push_str(&format!(
" <span class=\"opt-why\">{}</span>\n",
inline_html(why)
));
}
out.push_str(" </div></li>\n");
}
out.push_str("</ul>\n");
}
push_reference(&mut out, course, item.solution.as_ref());
out
}
/// An open-response fragment: the model answer, the rubric, accepted variants.
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
let mut out = String::new();
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if !solution.rubric.is_empty() {
let caption = match solution.rubric_points() {
Some(total) => {
let unit = if (total - 1.0).abs() < f64::EPSILON {
"point"
} else {
"points"
};
format!("Rubric &mdash; {} {unit}", trim_number(total))
}
None => "Rubric".to_string(),
};
out.push_str("<table class=\"sol-rubric\">\n");
out.push_str(&format!(" <caption>{caption}</caption>\n"));
out.push_str(
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
);
out.push_str(" <tbody>\n");
for criterion in &solution.rubric {
let pts = criterion.points.map(trim_number).unwrap_or_default();
out.push_str(&format!(
" <tr><td>{pts}</td><td>{}</td></tr>\n",
inline_html(&criterion.description)
));
}
out.push_str(" </tbody>\n</table>\n");
}
if !solution.accepted.is_empty() {
let joined = solution
.accepted
.iter()
.map(|a| inline_html(a))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!(
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\n"
));
}
push_reference(&mut out, course, Some(solution));
Some(out)
}
/// The student-facing reason a distractor is wrong: the instructor explanation
/// first, then any student feedback.
fn why_wrong(choice: &Choice) -> Option<&str> {
choice
.explanation
.as_deref()
.or(choice.feedback_student.as_deref())
}
/// Appends the `Source:` line when the solution carries review citations.
fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
let Some(solution) = solution else { return };
if solution.review.is_empty() {
return;
}
let sources = solution
.review
.iter()
.map(|c| cite_html(course, c))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\n"));
}
/// Renders one citation to HTML, linking it when a URL resolves.
fn cite_html(course: &CourseFile, citation: &Citation) -> String {
if let Some(text) = &citation.text {
if citation.reference.is_none() {
return markup::escape_html(text);
}
}
let Some(key) = &citation.reference else {
return markup::escape_html(&citation.display());
};
let Some(reference) = course.references.get(key) else {
return markup::escape_html(&citation.display());
};
let label = reference.label.as_deref().unwrap_or(key);
let locator = citation.locator.as_deref().unwrap_or("");
let body = if locator.is_empty() {
markup::escape_html(label)
} else {
format!(
"{} {}",
markup::escape_html(label),
markup::escape_html(locator)
)
};
match resolve_url(citation, reference) {
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
None => body,
}
}
/// Resolves a citation's link, from an explicit URL or a path joined to the
/// reference's base URL.
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
if let Some(url) = &citation.url {
return Some(url.clone());
}
let path = citation.path.as_deref()?;
let base = reference.base_url.as_deref()?;
Some(match (base.ends_with('/'), path.starts_with('/')) {
(true, true) => format!("{base}{}", &path[1..]),
(false, false) => format!("{base}/{path}"),
_ => format!("{base}{path}"),
})
}
// --- math-aware markup ---
/// One run of source text, split on math delimiters.
enum Segment<'a> {
/// Prose, formatted with the shared escape, symbol, and inline rules.
Text(&'a str),
/// A math span, passed through with its interior escaped for the browser.
Math { inner: &'a str, display: bool },
}
/// Splits source into prose and `$…$` or `$$…$$` math runs.
///
/// The split runs on raw source so a formatting rule can never reach inside math:
/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
fn split_math(src: &str) -> Vec<Segment<'_>> {
let mut segments = Vec::new();
let mut rest = src;
while let Some(at) = rest.find('$') {
if at > 0 {
segments.push(Segment::Text(&rest[..at]));
}
let after = &rest[at..];
if let Some(display) = after.strip_prefix("$$") {
if let Some(end) = display.find("$$") {
segments.push(Segment::Math {
inner: &display[..end],
display: true,
});
rest = &display[end + 2..];
continue;
}
}
let inline = &after[1..];
match inline.find('$') {
Some(end) => {
segments.push(Segment::Math {
inner: &inline[..end],
display: false,
});
rest = &inline[end + 1..];
}
None => {
// An unterminated `$` is treated as ordinary text.
segments.push(Segment::Text(after));
rest = "";
}
}
}
if !rest.is_empty() {
segments.push(Segment::Text(rest));
}
segments
}
/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
fn format_prose(text: &str) -> String {
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
}
/// Emits a math run with its delimiters, escaping the interior so the browser
/// hands MathJax clean text (a `<` inside math becomes `&lt;`, which the DOM
/// decodes back before MathJax reads it).
fn render_math(inner: &str, display: bool) -> String {
let delim = if display { "$$" } else { "$" };
format!("{delim}{}{delim}", markup::escape_html(inner))
}
/// Converts authoring markup to an inline HTML string, preserving math.
///
/// No paragraph wrapping: the caller supplies the surrounding element.
fn inline_html(src: &str) -> String {
let mut out = String::new();
for segment in split_math(src.trim()) {
match segment {
Segment::Text(text) => out.push_str(&format_prose(text)),
Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
}
}
out
}
/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `<p>`.
fn block_html(src: &str) -> String {
src.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p>{}</p>", inline_html(p)))
.collect::<Vec<_>>()
.join("")
}
// --- the encrypted bundle -----
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
#[derive(Debug, Clone, Serialize)]
pub struct Bundle {
v: u8,
page: String,
kdf: Kdf,
cipher: &'static str,
items: OrderedItems,
}
#[derive(Debug, Clone, Serialize)]
struct Kdf {
name: &'static str,
hash: &'static str,
iterations: u32,
salt: String,
}
#[derive(Debug, Clone, Serialize)]
struct Enc {
iv: String,
ct: String,
}
/// Item entries serialized as a JSON object in insertion order, so the bundle
/// lists solutions in the order the questions appear rather than sorted by id.
#[derive(Debug, Clone)]
struct OrderedItems(Vec<(String, Enc)>);
impl Serialize for OrderedItems {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (id, enc) in &self.0 {
map.serialize_entry(id, enc)?;
}
map.end()
}
}
/// Generates a random per-bundle password.
///
/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
///
/// # Returns
///
/// The password, to print for the instructor.
///
/// # Errors
///
/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
pub fn gen_password() -> Result<String> {
let mut raw = [0u8; 16];
csprng(&mut raw)?;
// Thirty-two divides 256, so the modulo is unbiased.
let symbols: Vec<u8> = raw
.iter()
.map(|b| PW_ALPHABET[(*b % 32) as usize])
.collect();
let groups: Vec<String> = symbols
.chunks(4)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect();
Ok(groups.join("-"))
}
/// Encrypts rendered fragments into a bundle.
///
/// # Arguments
///
/// * `page` - the assessment id, stored as `page` and echoed by the script.
/// * `password` - the password to derive the key from.
/// * `fragments` - item id and solution HTML, in the order to list them.
///
/// # Returns
///
/// The bundle, ready to serialize as `<page>-solutions.json`.
///
/// # Errors
///
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
let mut salt = [0u8; 16];
csprng(&mut salt)?;
let key = derive_key(password, &salt);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|_| Error::other("the derived AES key was the wrong length"))?;
let mut items = Vec::with_capacity(fragments.len());
for (id, html) in fragments {
let mut iv = [0u8; 12];
csprng(&mut iv)?;
let nonce = GenericArray::from_slice(&iv);
let ct = cipher
.encrypt(nonce, html.as_bytes())
.map_err(|_| Error::other("AES-GCM encryption failed"))?;
items.push((
id.clone(),
Enc {
iv: B64.encode(iv),
ct: B64.encode(ct),
},
));
}
Ok(Bundle {
v: 1,
page: page.to_string(),
kdf: Kdf {
name: "PBKDF2",
hash: "SHA-256",
iterations: ITERATIONS,
salt: B64.encode(salt),
},
cipher: "AES-GCM",
items: OrderedItems(items),
})
}
/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, ITERATIONS, &mut key);
key
}
/// Fills a buffer with cryptographically secure random bytes.
fn csprng(buf: &mut [u8]) -> Result<()> {
getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
}
// --- small helpers -------
/// Formats a point value: no decimal when whole, at most two places otherwise.
fn trim_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
let s = format!("{value:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
/// The printed letter for a position: 0 is A, 1 is B, and so on.
fn letter(position: usize) -> char {
char::from(b'A' + (position % 26) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
fn form() -> Form {
Form {
id: "A".into(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
}
}
fn catalog(tag: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
r#"
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
references:
kkw:
label: KKW
title: The molecules of life
base_url: https://example.org/kkw/
lectures:
L1.1: { title: Enthalpy }
learning_objectives:
lo-enthalpy:
text: Define enthalpy and explain the constant-pressure result.
lectures: [L1.1]
order: 1
"#,
)
.unwrap();
std::fs::write(
dir.join("banks").join("b.yaml"),
r#"
bank: { id: b, title: Bank }
items:
- id: q-mcq
status: draft
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
solution:
model_answer: "The change in enthalpy, $\\Delta H$."
review:
- { ref: kkw, locator: "§6.3" }
- id: q-open
status: draft
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
rubric:
- { description: "States $H = U + PV$.", points: 1 }
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
"#,
)
.unwrap();
Catalog::load(&dir).expect("catalog loads")
}
fn record() -> AssessmentFile {
use crate::assessment::{Assessment, Kind, Platform};
AssessmentFile {
schema_version: "1.0".into(),
assessment: Assessment {
id: "a1.1".into(),
title: "Homework 1".into(),
term: None,
kind: Kind::Homework,
date: None,
platform: Platform::Other,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: None,
forms: Vec::new(),
items: vec![
Placement {
number: 1,
item: "b::q-mcq".into(),
version: None,
fingerprint: None,
points: Some(1.0),
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
Placement {
number: 2,
item: "b::q-open".into(),
version: None,
fingerprint: None,
points: Some(2.0),
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
],
}
}
#[test]
fn questions_partial_has_no_front_matter_and_no_answers() {
let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
assert!(!out.starts_with("---"), "a partial carries no front matter");
assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
assert!(out.contains("::: {.q #q-mcq}"));
assert!(
out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
);
// Choices are printed without letters; the correct flag never appears.
assert!(out.contains("1. Enthalpy, $\\Delta H$"));
assert!(!out.contains("correct"));
// The open-response question gets a writing box, both get an empty slot.
assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
assert!(out.contains("[2 points]{.q-points}"));
// No model answer leaks into the questions.
assert!(!out.contains("sol-model"));
}
#[test]
fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
let cat = catalog("choice");
let rec = record();
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
assert!(mcq.contains("<span class=\"badge badge-correct\">Correct answer</span>"));
assert!(mcq.contains("<b>A</b> &mdash; Enthalpy, $\\Delta H$"));
assert!(mcq.contains(
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
));
assert!(mcq.contains("<span class=\"opt\">B</span>"));
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
}
#[test]
fn an_open_fragment_has_a_rubric_table_with_a_total() {
let cat = catalog("open");
let rec = record();
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
assert!(open.contains("<caption>Rubric &mdash; 2 points</caption>"));
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
}
#[test]
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
// A subscript inside math survives; angle brackets outside math are escaped.
assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ &lt; 5");
// Bold outside math becomes a tag; a dollar-math run is passed through.
assert_eq!(
inline_html("**H** is $H = U + PV$"),
"<strong>H</strong> is $H = U + PV$"
);
}
#[test]
fn the_bundle_round_trips_through_the_kdf_and_cipher() {
use aes_gcm::aead::generic_array::GenericArray;
let fragments = vec![
("q-mcq".to_string(), "<p>alpha</p>".to_string()),
("q-open".to_string(), "<p>beta</p>".to_string()),
];
let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
assert_eq!(bundle.v, 1);
assert_eq!(bundle.page, "a1.1");
assert_eq!(bundle.cipher, "AES-GCM");
assert_eq!(bundle.kdf.iterations, ITERATIONS);
// Insertion order is preserved in the serialized object.
let json = serde_json::to_string(&bundle).unwrap();
assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
// Decrypt the first item the way the browser would and check the plaintext.
let salt = B64.decode(&bundle.kdf.salt).unwrap();
let key = derive_key("enthalpy2026", &salt);
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
let (_, enc) = &bundle.items.0[0];
let iv = B64.decode(&enc.iv).unwrap();
let ct = B64.decode(&enc.ct).unwrap();
let pt = cipher
.decrypt(GenericArray::from_slice(&iv), ct.as_ref())
.unwrap();
assert_eq!(String::from_utf8(pt).unwrap(), "<p>alpha</p>");
}
#[test]
fn a_generated_password_has_the_expected_shape() {
let pw = gen_password().unwrap();
assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
assert_eq!(pw.matches('-').count(), 3);
assert!(
pw.chars()
.all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
);
}
#[test]
fn the_two_browser_assets_are_bundled() {
let assets = assets();
assert_eq!(assets[0].0, "questions.css");
assert_eq!(assets[1].0, "solutions.js");
assert!(assets[0].1.contains(".qsol"));
assert!(assets[1].1.contains("AES-GCM"));
}
}