feat: improve worksheet
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering an assessment as a Quarto worksheet a student can work through, and
|
||||
//! a matching solutions document they can learn from.
|
||||
//!
|
||||
//! This is the path that does not go through Canvas. You assemble a homework,
|
||||
//! quiz, or practice set the same way you assemble an exam, then render it as two
|
||||
//! `.qmd` files: [`Variant::Worksheet`] holds the questions and nothing else, and
|
||||
//! [`Variant::Solutions`] holds the same questions with the key marked, the worked
|
||||
//! reasoning, the rubric for anything open-ended, and where to read again. A
|
||||
//! student with neither the Canvas quiz nor the printed exam can still practice
|
||||
//! from the worksheet and check themselves against the solutions.
|
||||
//!
|
||||
//! A worksheet never contains the answer. It is built only from stems and
|
||||
//! options, and the option letters are the printed positions, so the document has
|
||||
//! nothing in it to leak: not a `correct` flag, not a solution, not a rationale.
|
||||
//! [`Variant::Solutions`] is a separate render from the same input.
|
||||
//!
|
||||
//! Option order comes from the form's seed. When a form shuffles, both
|
||||
//! documents relabel to the printed order through
|
||||
//! [`select::option_order`], so a worksheet handed to
|
||||
//! a student who saw form B agrees with the form B solutions.
|
||||
//!
|
||||
//! Everything a solution shows is authored: the model answer, the explanation, the
|
||||
//! per-option notes, the rubric, and the review citations. Nothing is invented
|
||||
//! here. A question with an empty [`crate::item::Solution`] renders its key and
|
||||
//! stops, which is a visible cue to go finish writing it.
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::{CourseFile, Reference};
|
||||
use crate::error::Result;
|
||||
use crate::item::{Choice, Citation, Item};
|
||||
use crate::markup;
|
||||
use crate::select;
|
||||
|
||||
/// Which of the two documents to render.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Variant {
|
||||
/// Questions only, for a student to work through.
|
||||
#[default]
|
||||
Worksheet,
|
||||
/// Questions with the key, worked solutions, rubric, and readings.
|
||||
Solutions,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// Both documents, in the order they are usually written.
|
||||
pub const ALL: [Variant; 2] = [Variant::Worksheet, Variant::Solutions];
|
||||
|
||||
/// The token used on the command line and in a file name.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "worksheet",
|
||||
Variant::Solutions => "solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// The suffix a generated file name carries, e.g. `-solutions`.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "",
|
||||
Variant::Solutions => "-solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// The word for this document in a title.
|
||||
fn title_word(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Worksheet => "Questions",
|
||||
Variant::Solutions => "Solutions",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a `--variant` value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - the token, case insensitive; `questions` is accepted for the
|
||||
/// worksheet and `key` for the solutions, since those are what people type.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Usage`] naming the valid tokens.
|
||||
pub fn parse(name: &str) -> Result<Variant> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"worksheet" | "questions" | "q" => Ok(Variant::Worksheet),
|
||||
"solutions" | "solution" | "key" => Ok(Variant::Solutions),
|
||||
other => Err(crate::error::Error::usage(format!(
|
||||
"unknown practice document `{other}`; use worksheet or solutions"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to render.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// Which form's ordering to use. Defaults to an unshuffled form.
|
||||
pub form: Form,
|
||||
/// Which document.
|
||||
pub variant: Variant,
|
||||
/// Leave vertical space after each question on the worksheet for a written
|
||||
/// answer. Ignored for the solutions document.
|
||||
pub answer_space: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Options {
|
||||
Options {
|
||||
form: Form {
|
||||
id: "A".to_string(),
|
||||
seed: 0,
|
||||
shuffle_items: false,
|
||||
shuffle_options: false,
|
||||
},
|
||||
variant: Variant::Worksheet,
|
||||
answer_space: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// Options for one variant on one form.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - which document.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The options, with the answer space on.
|
||||
pub fn new(variant: Variant, form: Form) -> Options {
|
||||
Options {
|
||||
form,
|
||||
variant,
|
||||
answer_space: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the questions-only worksheet.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
|
||||
/// missing item.
|
||||
pub fn worksheet(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
|
||||
render(
|
||||
catalog,
|
||||
record,
|
||||
&Options::new(Variant::Worksheet, form.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the solutions document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `form` - the form whose ordering to use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// As [`worksheet`].
|
||||
pub fn solutions(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
|
||||
render(
|
||||
catalog,
|
||||
record,
|
||||
&Options::new(Variant::Solutions, form.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders one document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `opts` - what to render.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Quarto Markdown, ending in a newline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
|
||||
/// missing item.
|
||||
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
|
||||
let course = &catalog.course;
|
||||
let mut out = front_matter(course, record, opts.variant);
|
||||
|
||||
if let Some(instructions) = &record.assessment.instructions {
|
||||
out.push_str(&markup::to_markdown(instructions));
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
// One shared stimulus is printed once, above the first question that uses it,
|
||||
// so a testlet reads as a block rather than repeating the vignette per item.
|
||||
let mut printed_stimulus: Option<String> = None;
|
||||
|
||||
let layout = select::layout(record, &opts.form);
|
||||
for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let number = position + 1;
|
||||
|
||||
if let Some(stimulus_id) = &item.stimulus {
|
||||
if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) {
|
||||
if let Some(stimulus) = course.stimuli.get(stimulus_id) {
|
||||
out.push_str("::: {.stimulus}\n\n");
|
||||
out.push_str(&markup::to_markdown(&stimulus.body));
|
||||
out.push_str("\n\n:::\n\n");
|
||||
}
|
||||
printed_stimulus = Some(stimulus_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match opts.variant {
|
||||
Variant::Worksheet => worksheet_question(
|
||||
&mut out,
|
||||
number,
|
||||
placement,
|
||||
item,
|
||||
&opts.form,
|
||||
opts.answer_space,
|
||||
),
|
||||
Variant::Solutions => {
|
||||
solution_question(&mut out, number, placement, item, &opts.form, course)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Quarto YAML front matter.
|
||||
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
|
||||
let title = format!("{}: {}", record.assessment.title, variant.title_word());
|
||||
let subtitle = format!("{} · {}", course.course.code, course.course.title);
|
||||
let mut out = String::from("---\n");
|
||||
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
|
||||
out.push_str(&format!("subtitle: \"{}\"\n", yaml_quote(&subtitle)));
|
||||
if let Some(date) = record.assessment.date {
|
||||
out.push_str(&format!("date: \"{date}\"\n"));
|
||||
}
|
||||
out.push_str("format:\n html:\n toc: false\n number-sections: false\n");
|
||||
out.push_str("---\n\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// One question on the worksheet: stem, options in printed order, no answer.
|
||||
fn worksheet_question(
|
||||
out: &mut String,
|
||||
number: usize,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
answer_space: bool,
|
||||
) {
|
||||
out.push_str(&heading(number, placement));
|
||||
out.push_str(&markup::to_markdown(&item.stem));
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"{}. {}\n",
|
||||
letter(position),
|
||||
markup::to_markdown(&source.text)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
} else if answer_space {
|
||||
// A place to write, sized by the theme, present only when asked for.
|
||||
out.push_str("::: {.answer-space}\n:::\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// One question in the solutions document: stem, key, worked reasoning, rubric,
|
||||
/// and where to look again.
|
||||
fn solution_question(
|
||||
out: &mut String,
|
||||
number: usize,
|
||||
placement: &Placement,
|
||||
item: &Item,
|
||||
form: &Form,
|
||||
course: &CourseFile,
|
||||
) {
|
||||
out.push_str(&heading(number, placement));
|
||||
out.push_str(&meta_line(placement, item));
|
||||
out.push_str(&markup::to_markdown(&item.stem));
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
let mark = if source.correct { " ✓" } else { "" };
|
||||
let note = source
|
||||
.student_text()
|
||||
.map(|t| format!(": {}", markup::to_markdown(t)))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"{}. {}{mark}{note}\n",
|
||||
letter(position),
|
||||
markup::to_markdown(&source.text)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
solution_body(out, item);
|
||||
objectives_line(out, item, course);
|
||||
review_line(out, item, course);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
/// The model answer, explanation, rubric, and accepted answers, when present.
|
||||
fn solution_body(out: &mut String, item: &Item) {
|
||||
let Some(solution) = item.solution.as_ref().filter(|s| !s.is_empty()) else {
|
||||
if !item.has_options() {
|
||||
// An open-response question with no written solution is unfinished, and
|
||||
// saying so in the document is more useful than a silent blank.
|
||||
out.push_str("_No solution written yet._\n\n");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(answer) = &solution.model_answer {
|
||||
out.push_str(&format!(
|
||||
"**Model answer.** {}\n\n",
|
||||
markup::to_markdown(answer)
|
||||
));
|
||||
}
|
||||
if let Some(explanation) = &solution.explanation {
|
||||
out.push_str(&markup::to_markdown(explanation));
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
if !solution.rubric.is_empty() {
|
||||
out.push_str("**Rubric**\n\n");
|
||||
for criterion in &solution.rubric {
|
||||
let points = criterion
|
||||
.points
|
||||
.map(|p| format!(" ({} pt)", trim_number(p)))
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"- {}{points}\n",
|
||||
markup::to_markdown(&criterion.description)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if !solution.accepted.is_empty() {
|
||||
let joined: Vec<String> = solution
|
||||
.accepted
|
||||
.iter()
|
||||
.map(|a| markup::to_markdown(a))
|
||||
.collect();
|
||||
out.push_str(&format!("**Accepted answers:** {}\n\n", joined.join("; ")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Tests:` line naming the objectives this item measures.
|
||||
fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
if item.learning_objectives.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texts: Vec<String> = item
|
||||
.learning_objectives
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.collect();
|
||||
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
|
||||
}
|
||||
|
||||
/// The `Review:` line, resolving each citation to a short label, linked when a URL
|
||||
/// resolves.
|
||||
fn review_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
let Some(solution) = item.solution.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if solution.review.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cites: Vec<String> = solution.review.iter().map(|c| cite(course, c)).collect();
|
||||
out.push_str(&format!("**Review:** {}\n\n", cites.join("; ")));
|
||||
}
|
||||
|
||||
/// Resolves one citation to Markdown, mirroring the lecture reading style
|
||||
/// `` `KKW` [§6.1](url) ``.
|
||||
fn cite(course: &CourseFile, citation: &Citation) -> String {
|
||||
if let Some(text) = &citation.text {
|
||||
if citation.reference.is_none() {
|
||||
return text.clone();
|
||||
}
|
||||
}
|
||||
let Some(key) = &citation.reference else {
|
||||
return citation.display();
|
||||
};
|
||||
let Some(reference) = course.references.get(key) else {
|
||||
return citation.display();
|
||||
};
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let locator = citation.locator.as_deref().unwrap_or("");
|
||||
match resolve_url(citation, reference) {
|
||||
Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"),
|
||||
Some(url) => format!("`{label}` [{}]({url})", reference.title),
|
||||
None if !locator.is_empty() => format!("`{label}` {locator}"),
|
||||
None => format!("`{label}`"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL for a citation: its own `url`, else the reference `base_url` joined with
|
||||
/// the citation `path`.
|
||||
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}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `## Question N` heading, marking a bonus item.
|
||||
fn heading(number: usize, placement: &Placement) -> String {
|
||||
let bonus = if placement.bonus { " (bonus)" } else { "" };
|
||||
format!("## Question {number}{bonus}\n\n")
|
||||
}
|
||||
|
||||
/// The italic level-and-points line under a solutions heading.
|
||||
fn meta_line(placement: &Placement, item: &Item) -> String {
|
||||
let level = placement.level.unwrap_or(item.level);
|
||||
let mut parts = vec![format!("Level {} ({})", level.code(), level.name())];
|
||||
if let Some(points) = placement.points {
|
||||
parts.push(format!("{} point(s)", trim_number(points)));
|
||||
}
|
||||
format!("_{}_\n\n", parts.join(" · "))
|
||||
}
|
||||
|
||||
/// The options in the order the form prints them.
|
||||
///
|
||||
/// Salted with the item's global id, the same value the Typst and QTI exports use,
|
||||
/// so a worksheet built for form B lists options in the order that form's paper and
|
||||
/// its Canvas quiz do.
|
||||
fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> {
|
||||
select::option_order(form, uid, item.options.len())
|
||||
.into_iter()
|
||||
.map(|i| &item.options[i])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The printed letter for a zero-based position.
|
||||
fn letter(position: usize) -> char {
|
||||
(b'A' + (position as u8 % 26)) as char
|
||||
}
|
||||
|
||||
/// Formats a point value without a trailing `.0`.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a double quote for a YAML double-quoted scalar.
|
||||
fn yaml_quote(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assessment::{Assessment, Kind, Platform};
|
||||
|
||||
/// Writes a course and a bank to a temp directory and loads them, the same way
|
||||
/// the catalog tests do, so this exercises only public API. The `tag` keeps each
|
||||
/// test in its own directory, so tests running in parallel do not clobber a
|
||||
/// shared `course.yaml`.
|
||||
fn catalog(tag: &str) -> Catalog {
|
||||
let dir = std::env::temp_dir().join(format!("cb-practice-{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("l11.yaml"),
|
||||
r#"
|
||||
bank: { id: l11, title: L1.1 }
|
||||
items:
|
||||
- id: q-enthalpy-001
|
||||
status: draft
|
||||
level: 1
|
||||
stem: At constant pressure, the heat exchanged equals which quantity?
|
||||
learning_objectives: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
|
||||
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
|
||||
- { id: C, text: "zero" }
|
||||
solution:
|
||||
explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H."
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
- id: q-enthalpy-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
|
||||
learning_objectives: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
|
||||
rubric:
|
||||
- { description: "states H = U + PV", points: 1 }
|
||||
- { description: "identifies q_p with the enthalpy change", points: 1 }
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Catalog::load(&dir).expect("catalog loads")
|
||||
}
|
||||
|
||||
fn record() -> AssessmentFile {
|
||||
AssessmentFile {
|
||||
schema_version: "1.0".into(),
|
||||
assessment: Assessment {
|
||||
id: "hw-1".into(),
|
||||
title: "Homework 1".into(),
|
||||
term: None,
|
||||
kind: Kind::Homework,
|
||||
date: None,
|
||||
platform: Platform::Canvas,
|
||||
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: "l11::q-enthalpy-001".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: "l11::q-enthalpy-op-001".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 worksheet_withholds_the_answer() {
|
||||
let md =
|
||||
worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("## Question 1"));
|
||||
assert!(md.contains("A. the enthalpy change"));
|
||||
// Nothing that reveals the key or the reasoning.
|
||||
assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}");
|
||||
assert!(!md.contains("Model answer"), "no model answer:\n{md}");
|
||||
assert!(!md.contains("P dV"), "no explanation:\n{md}");
|
||||
assert!(!md.contains("Rubric"));
|
||||
// The open-response question leaves room to write.
|
||||
assert!(md.contains("answer-space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solutions_show_key_reasoning_rubric_and_review() {
|
||||
let md =
|
||||
solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("A. the enthalpy change ✓"));
|
||||
assert!(md.contains("the internal energy change: ignores expansion work"));
|
||||
assert!(md.contains("**Model answer.**"));
|
||||
assert!(md.contains("H = U + PV"));
|
||||
assert!(md.contains("**Rubric**"));
|
||||
assert!(md.contains("states H = U + PV (1 pt)"));
|
||||
assert!(md.contains("Tests:** Define enthalpy"));
|
||||
// The review citation resolves to the label and a link.
|
||||
assert!(
|
||||
md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_titles_each_document() {
|
||||
let ws = worksheet(
|
||||
&catalog("front-matter-ws"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(ws.contains("title: \"Homework 1: Questions\""));
|
||||
let sol = solutions(
|
||||
&catalog("front-matter-sol"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(sol.contains("title: \"Homework 1: Solutions\""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user