// 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 `-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, } 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 `-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 { 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 { let mut out = String::new(); out.push_str( "\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> { 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 { 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 = printed .iter() .filter(|(_, c)| c.correct) .map(|(position, c)| { format!( "{} — {}", letter(*position), inline_html(&c.text) ) }) .collect(); out.push_str( "

Correct answer ", ); out.push_str(&keyed.join("; ")); out.push_str("

\n"); if let Some(solution) = item.solution.as_ref() { if let Some(model) = &solution.model_answer { out.push_str(&format!( "
{}
\n", block_html(model) )); } if let Some(explanation) = &solution.explanation { out.push_str(&explain_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("\n"); out.push_str("
    \n"); for (position, c) in &distractors { if c.misconception.is_none() && why_wrong(c).is_none() { continue; } out.push_str(&format!( "
  • {}\n", letter(*position) )); out.push_str("
    \n"); if let Some(mis) = &c.misconception { out.push_str(&format!( " {}\n", inline_html(mis) )); } if let Some(why) = why_wrong(c) { out.push_str(&format!( " {}\n", inline_html(why) )); } out.push_str("
  • \n"); } out.push_str("
\n"); } push_reference(&mut out, course, item.solution.as_ref()); out } /// An open-response fragment: the model answer, the explanation, the rubric, and /// the accepted variants. fn open_fragment(course: &CourseFile, item: &Item) -> Option { 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!( "
{}
\n", block_html(model) )); } if let Some(explanation) = &solution.explanation { out.push_str(&explain_html(explanation)); } 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 — {} {unit}", trim_number(total)) } None => "Rubric".to_string(), }; out.push_str("\n"); out.push_str(&format!(" \n")); out.push_str( " \n", ); out.push_str(" \n"); for criterion in &solution.rubric { let pts = criterion.points.map(trim_number).unwrap_or_default(); out.push_str(&format!( " \n", inline_html(&criterion.description) )); } out.push_str(" \n
{caption}
PtsCriterion
{pts}{}
\n"); } if !solution.accepted.is_empty() { let joined = solution .accepted .iter() .map(|a| inline_html(a)) .collect::>() .join("; "); out.push_str(&format!( "

Also accepted {joined}

\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::>() .join("; "); out.push_str(&format!("

Source: {sources}

\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!("{body}", 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 { 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> { 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 `<`, 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 `

`. fn block_html(src: &str) -> String { src.split("\n\n") .map(str::trim) .filter(|p| !p.is_empty()) .map(|p| format!("

{}

", inline_html(p))) .collect::>() .join("") } /// Renders a solution explanation as one `

` per blank-line- /// separated paragraph. A single-paragraph explanation emits exactly one such /// paragraph, unchanged from before; a multi-paragraph one keeps its breaks, and /// every paragraph carries the class `questions.css` already styles. fn explain_html(src: &str) -> String { src.split("\n\n") .map(str::trim) .filter(|p| !p.is_empty()) .map(|p| format!("

{}

\n", inline_html(p))) .collect() } // --- 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(&self, serializer: S) -> std::result::Result { 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 { let mut raw = [0u8; 16]; csprng(&mut raw)?; // Thirty-two divides 256, so the modulo is unbiased. let symbols: Vec = raw .iter() .map(|b| PW_ALPHABET[(*b % 32) as usize]) .collect(); let groups: Vec = 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 `-solutions.json`. /// /// # Errors /// /// Returns [`Error::Other`] on a CSPRNG or encryption failure. pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result { 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::(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_targets: [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_targets: [lo-enthalpy] solution: model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$." explanation: | At constant pressure the pressure-volume work is folded into H, so the heat equals the change in H. That is why a calorimeter run at constant pressure reads the enthalpy change directly. 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_targets: Vec::new(), credit_overrides: Default::default(), dropped: false, dropped_as: None, }, Placement { number: 2, item: "b::q-open".into(), version: None, fingerprint: None, points: Some(2.0), bonus: false, key: Vec::new(), level: None, learning_targets: Vec::new(), credit_overrides: Default::default(), dropped: false, dropped_as: None, }, ], } } #[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("Correct answer")); assert!(mcq.contains("A — Enthalpy, $\\Delta H$")); assert!(mcq.contains( "

The change in enthalpy, $\\Delta H$.

" )); assert!(mcq.contains("B")); assert!(mcq.contains("Uses the constant-volume result.")); assert!(mcq.contains("That holds only at constant volume.")); assert!(mcq.contains("

Source: KKW §6.3

")); } #[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("Rubric — 2 points")); assert!(open.contains("1States $H = U + PV$.")); assert!(open.contains("Also accepted")); } #[test] fn an_open_fragment_shows_the_model_answer_and_the_explanation() { // Both fields render, in that order, so an open-response solution reads as // the answer followed by the reasoning, the same as a choice fragment. A // multi-paragraph explanation keeps its breaks as separate paragraphs. let cat = catalog("open-explain"); 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("
"), "model answer shown:\n{open}" ); assert_eq!( open.matches("

").count(), 2, "each explanation paragraph is its own styled

:\n{open}" ); assert!( open.contains("folded into H"), "first paragraph present:\n{open}" ); assert!( open.contains("reads the enthalpy change directly"), "second paragraph present:\n{open}" ); let model_at = open.find("sol-model").unwrap(); let explain_at = open.find("sol-explain").unwrap(); assert!( model_at < explain_at, "model answer comes before the explanation" ); } #[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$ < 5"); // Bold outside math becomes a tag; a dollar-math run is passed through. assert_eq!( inline_html("**H** is $H = U + PV$"), "H 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(), "

alpha

".to_string()), ("q-open".to_string(), "

beta

".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(), "

alpha

"); } #[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")); } }