feat: improve worksheet
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
preview
|
preview
|
||||||
|
scratch
|
||||||
|
|
||||||
/dist/
|
/dist/
|
||||||
/THIRD-PARTY-LICENSES.txt
|
/THIRD-PARTY-LICENSES.txt
|
||||||
|
|||||||
+4
-4
@@ -444,7 +444,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
for iteration in 0..opts.max_iterations {
|
for iteration in 0..opts.max_iterations {
|
||||||
iterations = iteration + 1;
|
iterations = iteration + 1;
|
||||||
|
|
||||||
// ---- E step: expected counts at each quadrature point ----
|
// --- E step: expected counts at each quadrature point ----
|
||||||
// Counts are accumulated per item rather than globally, so an item
|
// Counts are accumulated per item rather than globally, so an item
|
||||||
// administered to only some examinees is not charged for the others.
|
// administered to only some examinees is not charged for the others.
|
||||||
let mut n_kj = vec![vec![0.0f64; j_count]; n_quad];
|
let mut n_kj = vec![vec![0.0f64; j_count]; n_quad];
|
||||||
@@ -473,7 +473,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- M step: one two-parameter Newton solve per item ----
|
// --- M step: one two-parameter Newton solve per item ----
|
||||||
let mut delta = 0.0f64;
|
let mut delta = 0.0f64;
|
||||||
for j in 0..j_count {
|
for j in 0..j_count {
|
||||||
let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect();
|
let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect();
|
||||||
@@ -529,7 +529,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Standard errors and per-item notes ----
|
// --- Standard errors and per-item notes ----
|
||||||
let (grid_final, weight_final) = (grid.clone(), base_weight.clone());
|
let (grid_final, weight_final) = (grid.clone(), base_weight.clone());
|
||||||
let mut p_grid = vec![vec![0.0f64; j_count]; n_quad];
|
let mut p_grid = vec![vec![0.0f64; j_count]; n_quad];
|
||||||
for k in 0..n_quad {
|
for k in 0..n_quad {
|
||||||
@@ -601,7 +601,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Abilities, expected a posteriori ----
|
// --- Abilities, expected a posteriori ----
|
||||||
let mut abilities = Vec::with_capacity(n);
|
let mut abilities = Vec::with_capacity(n);
|
||||||
let mut log_likelihood = 0.0f64;
|
let mut log_likelihood = 0.0f64;
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
|
|||||||
@@ -571,6 +571,77 @@ fn asset_schema() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The worked solution, and for an open-response item how it is graded.
|
||||||
|
fn solution_schema() -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "The answer, the reasoning, and the rubric. Rendered in the solutions \
|
||||||
|
document and the answer key, never in a question paper.",
|
||||||
|
"properties": {
|
||||||
|
"model_answer": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "For an open-response item, the response a full-credit student \
|
||||||
|
writes; for a choice item, an optional one-line statement of the key."
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The worked reasoning a student learns from. The body of the \
|
||||||
|
solutions entry."
|
||||||
|
},
|
||||||
|
"rubric": { "type": "array", "items": rubric_criterion_schema() },
|
||||||
|
"accepted": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Responses a short constructed answer is accepted as."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"type": "array",
|
||||||
|
"items": citation_schema(),
|
||||||
|
"description": "Where to look again after missing this item."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rubric line for an open-response item.
|
||||||
|
fn rubric_criterion_schema() -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"required": ["description"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"description": text("What earns the points on this line."),
|
||||||
|
"points": { "type": "number", "minimum": 0.0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A citation into the reference registry, written as an object or a bare string.
|
||||||
|
fn citation_schema() -> Value {
|
||||||
|
json!({
|
||||||
|
"oneOf": [
|
||||||
|
{ "type": "string", "description": "A citation, unparsed." },
|
||||||
|
citation_mapping_schema()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object form of a citation.
|
||||||
|
fn citation_mapping_schema() -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"ref": text("Citation key into `references`."),
|
||||||
|
"locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." },
|
||||||
|
"path": { "type": "string", "description": "Joined to the reference base_url." },
|
||||||
|
"url": { "type": "string" },
|
||||||
|
"text": { "type": "string" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The schema for authored design intent.
|
/// The schema for authored design intent.
|
||||||
fn design_schema() -> Value {
|
fn design_schema() -> Value {
|
||||||
json!({
|
json!({
|
||||||
@@ -738,9 +809,10 @@ fn item_identity_properties() -> Value {
|
|||||||
"cognitive_process": cognitive_process(),
|
"cognitive_process": cognitive_process(),
|
||||||
"format": {
|
"format": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["single_best_answer", "multiple_response", "true_false"],
|
"enum": ["single_best_answer", "multiple_response", "true_false", "open_response"],
|
||||||
"description": "single_best_answer requires exactly one keyed option; \
|
"description": "single_best_answer keys exactly one option; multiple_response keys \
|
||||||
multiple_response requires at least two."
|
two or more; open_response takes no options and is graded from its \
|
||||||
|
solution."
|
||||||
},
|
},
|
||||||
"bonus": { "type": "boolean" },
|
"bonus": { "type": "boolean" },
|
||||||
"points": { "type": "number", "exclusiveMinimum": 0.0 },
|
"points": { "type": "number", "exclusiveMinimum": 0.0 },
|
||||||
@@ -767,8 +839,10 @@ fn item_content_properties() -> Value {
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 2,
|
"minItems": 2,
|
||||||
"maxItems": 8,
|
"maxItems": 8,
|
||||||
|
"description": "Absent for an open_response item; at least two for any choice format.",
|
||||||
"items": option_schema()
|
"items": option_schema()
|
||||||
},
|
},
|
||||||
|
"solution": solution_schema(),
|
||||||
"learning_objectives": string_array(
|
"learning_objectives": string_array(
|
||||||
"Objective ids this item measures. Reports aggregate on these, so an item with none \
|
"Objective ids this item measures. Reports aggregate on these, so an item with none \
|
||||||
contributes to nothing."
|
contributes to nothing."
|
||||||
@@ -807,7 +881,7 @@ fn item_schema() -> Value {
|
|||||||
}
|
}
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "level", "stem", "options"],
|
"required": ["id", "level", "stem"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": Value::Object(properties)
|
"properties": Value::Object(properties)
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-4
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- clarity
|
// --- clarity
|
||||||
let stem = it.stem.trim();
|
let stem = it.stem.trim();
|
||||||
let stem_lower = stem.to_lowercase();
|
let stem_lower = stem.to_lowercase();
|
||||||
let words: Vec<&str> = stem.split_whitespace().collect();
|
let words: Vec<&str> = stem.split_whitespace().collect();
|
||||||
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- cueing
|
// --- cueing
|
||||||
let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect();
|
let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect();
|
||||||
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
|
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
|
||||||
|
|
||||||
@@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- completeness
|
// --- completeness
|
||||||
// These are only worth insisting on once an item is meant to be used.
|
// These are only worth insisting on once an item is meant to be used.
|
||||||
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
|
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
|
||||||
if is_ready {
|
if is_ready {
|
||||||
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- evidence
|
// --- evidence
|
||||||
if !it.calibration_is_current() {
|
if !it.calibration_is_current() {
|
||||||
push(
|
push(
|
||||||
Rule::StaleCalibration,
|
Rule::StaleCalibration,
|
||||||
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
|
|||||||
if e.item.status == Status::Retired {
|
if e.item.status == Status::Retired {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// An open-response item carries no options, so it is neither part of the
|
||||||
|
// count norm nor able to deviate from it. Leaving it out keeps a bank of
|
||||||
|
// four-option questions from reporting every essay as an odd count.
|
||||||
|
if !e.item.has_options() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
by_bank.entry(e.bank.as_str()).or_default().push(e);
|
by_bank.entry(e.bank.as_str()).or_default().push(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ pub fn select(
|
|||||||
let seed = blueprint.seed.unwrap_or(0);
|
let seed = blueprint.seed.unwrap_or(0);
|
||||||
let mut notes = Vec::new();
|
let mut notes = Vec::new();
|
||||||
|
|
||||||
// ------------------------------------------------------------------ pool
|
// --- pool
|
||||||
let eligible: Vec<&crate::catalog::Entry> = catalog
|
let eligible: Vec<&crate::catalog::Entry> = catalog
|
||||||
.assemblable()
|
.assemblable()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -101,7 +101,7 @@ pub fn select(
|
|||||||
let mut chosen: Vec<String> = Vec::new();
|
let mut chosen: Vec<String> = Vec::new();
|
||||||
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
|
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
|
||||||
// ---------------------------------------------------- objective minimums
|
// --- objective minimums
|
||||||
// Placed first, because a coverage requirement is the constraint most likely
|
// Placed first, because a coverage requirement is the constraint most likely
|
||||||
// to become unsatisfiable once the level quotas are full.
|
// to become unsatisfiable once the level quotas are full.
|
||||||
for (objective, needed) in &blueprint.objective_minimums {
|
for (objective, needed) in &blueprint.objective_minimums {
|
||||||
@@ -140,7 +140,7 @@ pub fn select(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- level quotas
|
// --- level quotas
|
||||||
let mut scored: Vec<String> = Vec::new();
|
let mut scored: Vec<String> = Vec::new();
|
||||||
for (level, want) in &blueprint.level_counts {
|
for (level, want) in &blueprint.level_counts {
|
||||||
if *want == 0 {
|
if *want == 0 {
|
||||||
@@ -175,7 +175,7 @@ pub fn select(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ bonus items
|
// --- bonus items
|
||||||
let mut bonus: Vec<String> = Vec::new();
|
let mut bonus: Vec<String> = Vec::new();
|
||||||
for (level, want) in &blueprint.bonus_counts {
|
for (level, want) in &blueprint.bonus_counts {
|
||||||
if *want == 0 {
|
if *want == 0 {
|
||||||
|
|||||||
+23
@@ -412,6 +412,29 @@ pub(crate) enum ExportCommand {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// Write a Quarto worksheet and a matching solutions document.
|
||||||
|
///
|
||||||
|
/// The worksheet holds the questions and nothing else; the solutions document
|
||||||
|
/// adds the key, the worked reasoning, the rubric, and the readings to revisit.
|
||||||
|
/// This is the path that does not go through Canvas, so a student can practice
|
||||||
|
/// from the `.qmd` and check themselves against the solutions. Render each with
|
||||||
|
/// `quarto render <file>.qmd`.
|
||||||
|
Practice {
|
||||||
|
/// Assessment id.
|
||||||
|
id: String,
|
||||||
|
/// Which form's ordering to use.
|
||||||
|
#[arg(long, default_value = "A")]
|
||||||
|
form: String,
|
||||||
|
/// Which documents to write; defaults to both. Values: worksheet, solutions.
|
||||||
|
#[arg(long, value_name = "DOC")]
|
||||||
|
variant: Vec<String>,
|
||||||
|
/// Output directory; defaults to build/.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
/// Do not leave written-answer space after open-response questions.
|
||||||
|
#[arg(long)]
|
||||||
|
no_answer_space: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
use coursebank::assessment::Form;
|
use coursebank::assessment::Form;
|
||||||
use coursebank::error::{Error, Result};
|
use coursebank::error::{Error, Result};
|
||||||
use coursebank::layout::Layout;
|
use coursebank::layout::Layout;
|
||||||
|
use coursebank::practice;
|
||||||
use coursebank::qti;
|
use coursebank::qti;
|
||||||
use coursebank::typst;
|
use coursebank::typst;
|
||||||
use coursebank::yaml;
|
use coursebank::yaml;
|
||||||
@@ -168,7 +169,63 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
|||||||
println!("wrote {}", path.display());
|
println!("wrote {}", path.display());
|
||||||
Ok(Outcome::Ok)
|
Ok(Outcome::Ok)
|
||||||
}
|
}
|
||||||
|
ExportCommand::Practice {
|
||||||
|
id,
|
||||||
|
form,
|
||||||
|
variant,
|
||||||
|
out,
|
||||||
|
no_answer_space,
|
||||||
|
} => {
|
||||||
|
let record = load_record(&catalog, id)?;
|
||||||
|
let form = pick_form(&record, form)?;
|
||||||
|
let dir = out.clone().unwrap_or(build);
|
||||||
|
for v in pick_practice_variants(variant)? {
|
||||||
|
let opts = practice::Options {
|
||||||
|
form: form.clone(),
|
||||||
|
variant: v,
|
||||||
|
answer_space: !no_answer_space,
|
||||||
|
};
|
||||||
|
let text = practice::render(&catalog, &record, &opts)?;
|
||||||
|
let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix()));
|
||||||
|
yaml::write_text(&path, &text)?;
|
||||||
|
println!("wrote {}", path.display());
|
||||||
}
|
}
|
||||||
|
if !cli.quiet {
|
||||||
|
println!("\nRender with: quarto render <file>.qmd");
|
||||||
|
}
|
||||||
|
Ok(Outcome::Ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the `--variant` flags for `export practice`, defaulting to both.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `names` - the raw flag values, possibly empty.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The documents to write, deduplicated and in canonical order (worksheet first).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||||
|
fn pick_practice_variants(names: &[String]) -> Result<Vec<practice::Variant>> {
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(practice::Variant::ALL.to_vec());
|
||||||
|
}
|
||||||
|
let mut wanted = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let variant = practice::Variant::parse(name)?;
|
||||||
|
if !wanted.contains(&variant) {
|
||||||
|
wanted.push(variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(practice::Variant::ALL
|
||||||
|
.into_iter()
|
||||||
|
.filter(|v| wanted.contains(v))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the `--variant` flags, defaulting to every document.
|
/// Resolves the `--variant` flags, defaulting to every document.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! |:--|:--|:--|
|
//! |:--|:--|:--|
|
||||||
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
|
||||||
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
|
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
|
||||||
|
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
|
||||||
//! | [`report`] | Markdown and HTML | students, and yourself |
|
//! | [`report`] | Markdown and HTML | students, and yourself |
|
||||||
//! | [`lecture`] | Markdown | the reading list on the course website |
|
//! | [`lecture`] | Markdown | the reading list on the course website |
|
||||||
//!
|
//!
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
//! The instructor report answers "what should I fix?" and holds the item statistics.
|
//! The instructor report answers "what should I fix?" and holds the item statistics.
|
||||||
|
|
||||||
pub mod lecture;
|
pub mod lecture;
|
||||||
|
pub mod practice;
|
||||||
pub mod qti;
|
pub mod qti;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
pub mod typst;
|
pub mod typst;
|
||||||
|
|||||||
@@ -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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-4
@@ -47,9 +47,9 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
|
|||||||
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
||||||
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---
|
||||||
// A very small XML tree
|
// A very small XML tree
|
||||||
// ---------------------------------------------------------------------------
|
// ---
|
||||||
|
|
||||||
/// One XML element.
|
/// One XML element.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -155,9 +155,9 @@ fn escape_attr(s: &str) -> String {
|
|||||||
escape_text(s).replace('"', """)
|
escape_text(s).replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---
|
||||||
// Package construction
|
// Package construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---
|
||||||
|
|
||||||
/// Options for a QTI export.
|
/// Options for a QTI export.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -338,6 +338,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
|||||||
///
|
///
|
||||||
/// The element.
|
/// The element.
|
||||||
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
|
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
|
||||||
|
// An open-response item is an essay in Canvas: no choices, graded by hand.
|
||||||
|
if !item.format.has_options() {
|
||||||
|
return build_essay_item(assessment_id, uid, item, points, opts);
|
||||||
|
}
|
||||||
|
|
||||||
let order = select::option_order(&opts.form, uid, item.options.len());
|
let order = select::option_order(&opts.form, uid, item.options.len());
|
||||||
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
|
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
|
||||||
|
|
||||||
@@ -499,6 +504,104 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
|
|||||||
node
|
node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a Canvas essay item for an open-response question.
|
||||||
|
///
|
||||||
|
/// An essay has no choices and no automatic score: the `<other/>` condition leaves
|
||||||
|
/// grading to the instructor. When feedback is on and a model answer exists, it
|
||||||
|
/// rides along as general feedback so a student sees it after submitting.
|
||||||
|
///
|
||||||
|
/// This mapping has not been round-tripped through a live Canvas import in this
|
||||||
|
/// build, so verify it against your instance before relying on it for a graded
|
||||||
|
/// quiz.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `assessment_id` - salts the generated ids.
|
||||||
|
/// * `uid` - the item's global id.
|
||||||
|
/// * `item` - the item.
|
||||||
|
/// * `points` - points as administered.
|
||||||
|
/// * `opts` - export options.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The element.
|
||||||
|
fn build_essay_item(
|
||||||
|
assessment_id: &str,
|
||||||
|
uid: &str,
|
||||||
|
item: &Item,
|
||||||
|
points: f64,
|
||||||
|
opts: &QtiOptions,
|
||||||
|
) -> Node {
|
||||||
|
let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![
|
||||||
|
metadata_field("question_type", item.format.qti_type()),
|
||||||
|
metadata_field("points_possible", &format!("{points:.2}")),
|
||||||
|
metadata_field("assessment_question_identifierref", &qti_id(uid)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let presentation = Node::new("presentation")
|
||||||
|
.child(mattext(&format!(
|
||||||
|
"<div>{}</div>",
|
||||||
|
markup::to_html(&item.stem)
|
||||||
|
)))
|
||||||
|
.child(
|
||||||
|
Node::new("response_str")
|
||||||
|
.attr("ident", "response1")
|
||||||
|
.attr("rcardinality", "Single")
|
||||||
|
.child(
|
||||||
|
Node::new("render_fib").child(
|
||||||
|
Node::new("response_label")
|
||||||
|
.attr("ident", "answer1")
|
||||||
|
.attr("rshuffle", "No"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The model answer, shown as general feedback after submission.
|
||||||
|
let model = item
|
||||||
|
.solution
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.model_answer.as_deref())
|
||||||
|
.filter(|_| opts.include_feedback);
|
||||||
|
|
||||||
|
let mut condition = Node::new("respcondition")
|
||||||
|
.attr("continue", "No")
|
||||||
|
.child(Node::new("conditionvar").child(Node::new("other")));
|
||||||
|
if model.is_some() {
|
||||||
|
condition = condition.child(
|
||||||
|
Node::new("displayfeedback")
|
||||||
|
.attr("feedbacktype", "Response")
|
||||||
|
.attr("linkrefid", "general_fb"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resprocessing = Node::new("resprocessing")
|
||||||
|
.child(
|
||||||
|
Node::new("outcomes").child(
|
||||||
|
Node::new("decvar")
|
||||||
|
.attr("maxvalue", "100")
|
||||||
|
.attr("minvalue", "0")
|
||||||
|
.attr("varname", "SCORE")
|
||||||
|
.attr("vartype", "Decimal"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(condition);
|
||||||
|
|
||||||
|
let mut node = Node::new("item")
|
||||||
|
.attr("ident", qti_id(&format!("{assessment_id}/{uid}")))
|
||||||
|
.attr("title", item.display_title())
|
||||||
|
.child(item_meta)
|
||||||
|
.child(presentation)
|
||||||
|
.child(resprocessing);
|
||||||
|
|
||||||
|
if let Some(text) = model {
|
||||||
|
node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child(
|
||||||
|
Node::new("flow_mat").child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
/// A `<material><mattext texttype="text/html">` pair.
|
/// A `<material><mattext texttype="text/html">` pair.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
+11
-11
@@ -98,7 +98,7 @@ pub fn student(
|
|||||||
));
|
));
|
||||||
out.push_str(&format!("**{}**\n\n", summary.display_name()));
|
out.push_str(&format!("**{}**\n\n", summary.display_name()));
|
||||||
|
|
||||||
// ---------------------------------------------------------------- score
|
// --- score
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"You scored **{:.1} of {:.1} points ({:.0}%)**",
|
"You scored **{:.1} of {:.1} points ({:.0}%)**",
|
||||||
summary.points, summary.points_possible, summary.percent
|
summary.points, summary.points_possible, summary.percent
|
||||||
@@ -144,7 +144,7 @@ pub fn student(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- objectives
|
// --- objectives
|
||||||
if opts.objectives && !summary.objectives.is_empty() {
|
if opts.objectives && !summary.objectives.is_empty() {
|
||||||
out.push_str("## What this exam says about each learning objective\n\n");
|
out.push_str("## What this exam says about each learning objective\n\n");
|
||||||
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
||||||
@@ -180,7 +180,7 @@ pub fn student(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- levels
|
// --- levels
|
||||||
if opts.levels && summary.levels.len() > 1 {
|
if opts.levels && summary.levels.len() > 1 {
|
||||||
out.push_str("## Kinds of thinking\n\n");
|
out.push_str("## Kinds of thinking\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
@@ -231,7 +231,7 @@ pub fn student(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------- what to do
|
// --- what to do
|
||||||
if !summary.focus.is_empty() {
|
if !summary.focus.is_empty() {
|
||||||
out.push_str("## Where to put your time\n\n");
|
out.push_str("## Where to put your time\n\n");
|
||||||
out.push_str("In this order:\n\n");
|
out.push_str("In this order:\n\n");
|
||||||
@@ -274,7 +274,7 @@ pub fn student(
|
|||||||
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
|
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------- missed items
|
// --- missed items
|
||||||
if opts.missed && !summary.missed.is_empty() {
|
if opts.missed && !summary.missed.is_empty() {
|
||||||
out.push_str("## Question by question\n\n");
|
out.push_str("## Question by question\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
@@ -356,7 +356,7 @@ pub fn cohort(
|
|||||||
.unwrap_or_else(|| "date not recorded".into())
|
.unwrap_or_else(|| "date not recorded".into())
|
||||||
));
|
));
|
||||||
|
|
||||||
// ------------------------------------------------------------- summary
|
// --- summary
|
||||||
let r = &analysis.reliability;
|
let r = &analysis.reliability;
|
||||||
out.push_str("## Summary\n\n");
|
out.push_str("## Summary\n\n");
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
@@ -408,7 +408,7 @@ pub fn cohort(
|
|||||||
out.push_str(&format!("> {w}\n\n"));
|
out.push_str(&format!("> {w}\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- revise queue
|
// --- revise queue
|
||||||
let queue = analysis.revise_queue();
|
let queue = analysis.revise_queue();
|
||||||
out.push_str("## What to revise\n\n");
|
out.push_str("## What to revise\n\n");
|
||||||
if queue.is_empty() {
|
if queue.is_empty() {
|
||||||
@@ -477,7 +477,7 @@ pub fn cohort(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------- item table
|
// --- item table
|
||||||
out.push_str("## Every item\n\n");
|
out.push_str("## Every item\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
|
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
|
||||||
@@ -535,7 +535,7 @@ pub fn cohort(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------- class gaps
|
// --- class gaps
|
||||||
out.push_str("## Objectives the class did not meet\n\n");
|
out.push_str("## Objectives the class did not meet\n\n");
|
||||||
if cohort.class_gaps.is_empty() {
|
if cohort.class_gaps.is_empty() {
|
||||||
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
|
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
|
||||||
@@ -556,7 +556,7 @@ pub fn cohort(
|
|||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------ level coverage
|
// --- level coverage
|
||||||
out.push_str("## Coverage and class performance by level\n\n");
|
out.push_str("## Coverage and class performance by level\n\n");
|
||||||
let counts = record.level_counts();
|
let counts = record.level_counts();
|
||||||
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
|
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
|
||||||
@@ -587,7 +587,7 @@ pub fn cohort(
|
|||||||
let _ = bp;
|
let _ = bp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------- archetypes
|
// --- archetypes
|
||||||
if !cohort.archetypes.is_empty() {
|
if !cohort.archetypes.is_empty() {
|
||||||
out.push_str("## Patterns across students\n\n");
|
out.push_str("## Patterns across students\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
|
|||||||
@@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option<BTreeMap<String, f64>> {
|
|||||||
|
|
||||||
/// The token for a response format.
|
/// The token for a response format.
|
||||||
fn format_token(format: Format) -> &'static str {
|
fn format_token(format: Format) -> &'static str {
|
||||||
match format {
|
format.as_str()
|
||||||
Format::SingleBestAnswer => "single_best_answer",
|
|
||||||
Format::MultipleResponse => "multiple_response",
|
|
||||||
Format::TrueFalse => "true_false",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The token for an administration platform.
|
/// The token for an administration platform.
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store};
|
|||||||
|
|
||||||
pub use analysis::{calibrate, classical, irt, students};
|
pub use analysis::{calibrate, classical, irt, students};
|
||||||
|
|
||||||
pub use export::{lecture, qti, report, typst};
|
pub use export::{lecture, practice, qti, report, typst};
|
||||||
|
|
||||||
pub use catalog::Catalog;
|
pub use catalog::Catalog;
|
||||||
pub use course::{CourseFile, SCHEMA_VERSION};
|
pub use course::{CourseFile, SCHEMA_VERSION};
|
||||||
|
|||||||
+94
-9
@@ -352,13 +352,23 @@ fn validate_item(
|
|||||||
issues.push("version must be at least 1".into());
|
issues.push("version must be at least 1".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- options -----------------------------------------------------------
|
// --- options ---
|
||||||
|
// An open-response item takes no options; its answer lives in `solution`.
|
||||||
|
// Every other format needs at least two things to choose between.
|
||||||
|
if it.format.has_options() {
|
||||||
if it.options.len() < 2 {
|
if it.options.len() < 2 {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
"needs at least 2 options, has {}",
|
"needs at least 2 options, has {}",
|
||||||
it.options.len()
|
it.options.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
} else if !it.options.is_empty() {
|
||||||
|
issues.push(format!(
|
||||||
|
"{} items take no options, but {} were given; put the answer in `solution`",
|
||||||
|
it.format.as_str(),
|
||||||
|
it.options.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut seen: Vec<&str> = Vec::new();
|
let mut seen: Vec<&str> = Vec::new();
|
||||||
for (i, o) in it.options.iter().enumerate() {
|
for (i, o) in it.options.iter().enumerate() {
|
||||||
let pos = i + 1;
|
let pos = i + 1;
|
||||||
@@ -418,7 +428,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- key ---------------------------------------------------------------
|
// --- key -----
|
||||||
let keys = it.key_indices();
|
let keys = it.key_indices();
|
||||||
match it.format {
|
match it.format {
|
||||||
Format::SingleBestAnswer => {
|
Format::SingleBestAnswer => {
|
||||||
@@ -448,9 +458,14 @@ fn validate_item(
|
|||||||
issues.push("true_false needs exactly one keyed option".into());
|
issues.push("true_false needs exactly one keyed option".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Format::OpenResponse => {
|
||||||
|
if !keys.is_empty() {
|
||||||
|
issues.push("open_response items have no keyed option".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- level and process must agree -------------------------------------
|
// --- level and process must agree --------
|
||||||
if let Some(p) = it.cognitive_process {
|
if let Some(p) = it.cognitive_process {
|
||||||
if !it.level.allows(p) {
|
if !it.level.allows(p) {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
@@ -461,7 +476,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- design plausibility ----------------------------------------------
|
// --- design plausibility -----------
|
||||||
if let Some(d) = &it.design {
|
if let Some(d) = &it.design {
|
||||||
if let Some(x) = d.expected_difficulty {
|
if let Some(x) = d.expected_difficulty {
|
||||||
if !(0.0..=1.0).contains(&x) {
|
if !(0.0..=1.0).contains(&x) {
|
||||||
@@ -479,7 +494,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- calibration plausibility -----------------------------------------
|
// --- calibration plausibility ------
|
||||||
if let Some(c) = &it.calibration {
|
if let Some(c) = &it.calibration {
|
||||||
if let Some(p) = c.p_value {
|
if let Some(p) = c.p_value {
|
||||||
if !(0.0..=1.0).contains(&p) {
|
if !(0.0..=1.0).contains(&p) {
|
||||||
@@ -514,7 +529,7 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- history must be coherent -----------------------------------------
|
// --- history must be coherent ------
|
||||||
let mut last_version = 0u32;
|
let mut last_version = 0u32;
|
||||||
for (i, h) in it.history.iter().enumerate() {
|
for (i, h) in it.history.iter().enumerate() {
|
||||||
if h.version <= last_version {
|
if h.version <= last_version {
|
||||||
@@ -533,7 +548,7 @@ fn validate_item(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- retirement -------------------------------------------------------
|
// --- retirement ----
|
||||||
if it.retired.is_some() && it.status != Status::Retired {
|
if it.retired.is_some() && it.status != Status::Retired {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
"has a `retired` block but status is `{}`",
|
"has a `retired` block but status is `{}`",
|
||||||
@@ -541,7 +556,7 @@ fn validate_item(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- approval gate ----------------------------------------------------
|
// --- approval gate -------
|
||||||
// Approval is what permits an item onto a graded assessment, so it is the
|
// Approval is what permits an item onto a graded assessment, so it is the
|
||||||
// right place to require that the item is fully sourced and designed.
|
// right place to require that the item is fully sourced and designed.
|
||||||
if it.status == Status::Approved {
|
if it.status == Status::Approved {
|
||||||
@@ -557,9 +572,23 @@ fn validate_item(
|
|||||||
if it.design.is_none() {
|
if it.design.is_none() {
|
||||||
issues.push("approved items must carry a design block".into());
|
issues.push("approved items must carry a design block".into());
|
||||||
}
|
}
|
||||||
|
// An open-response item is graded from its solution, so approving one with
|
||||||
|
// neither a model answer nor a rubric would leave nothing to mark it by.
|
||||||
|
if !it.format.has_options() {
|
||||||
|
let gradeable = it
|
||||||
|
.solution
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty());
|
||||||
|
if !gradeable {
|
||||||
|
issues.push(
|
||||||
|
"approved open_response items need a solution with a model_answer or a rubric"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- cross-file references --------------------------------------------
|
// --- cross-file references ---------
|
||||||
if let Some(c) = course {
|
if let Some(c) = course {
|
||||||
for lo in &it.learning_objectives {
|
for lo in &it.learning_objectives {
|
||||||
match c.learning_objectives.get(lo) {
|
match c.learning_objectives.get(lo) {
|
||||||
@@ -592,6 +621,15 @@ fn validate_item(
|
|||||||
issues.push(format!("unknown stimulus `{st}`"));
|
issues.push(format!("unknown stimulus `{st}`"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A citation that names a reference key must name a real one, so a review
|
||||||
|
// pointer in the solutions document never resolves to nothing.
|
||||||
|
for citation in it.solution.iter().flat_map(|s| &s.review) {
|
||||||
|
if let Some(key) = &citation.reference {
|
||||||
|
if !c.references.contains_key(key) {
|
||||||
|
issues.push(format!("solution.review cites unknown reference `{key}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(floor) = c.policy.partial_credit_floor_level {
|
if let Some(floor) = c.policy.partial_credit_floor_level {
|
||||||
for o in &it.options {
|
for o in &it.options {
|
||||||
if o.is_partial() && it.level < floor {
|
if o.is_partial() && it.level < floor {
|
||||||
@@ -644,6 +682,53 @@ mod tests {
|
|||||||
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
|
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_response_validates_without_options_and_rejects_them() {
|
||||||
|
// No options is fine, and no key is required.
|
||||||
|
let ok = bank(
|
||||||
|
r#"
|
||||||
|
- id: q-a-op-001
|
||||||
|
status: draft
|
||||||
|
level: 2
|
||||||
|
format: open_response
|
||||||
|
stem: Explain the first law.
|
||||||
|
solution:
|
||||||
|
model_answer: Energy is conserved.
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None));
|
||||||
|
|
||||||
|
// Giving an open-response item options is the mistake, and so is approving
|
||||||
|
// one with nothing to grade it by.
|
||||||
|
let bad = bank(
|
||||||
|
r#"
|
||||||
|
- id: q-a-op-002
|
||||||
|
status: approved
|
||||||
|
level: 2
|
||||||
|
format: open_response
|
||||||
|
cognitive_process: explain
|
||||||
|
learning_objectives: [lo-x]
|
||||||
|
sources: [{ lecture: L1.1 }]
|
||||||
|
design: { rationale: r }
|
||||||
|
stem: Explain the first law.
|
||||||
|
options:
|
||||||
|
- { id: A, text: a, correct: true }
|
||||||
|
- { id: B, text: b }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let issues = bad.validate(None);
|
||||||
|
assert!(
|
||||||
|
issues.iter().any(|i| i.contains("take no options")),
|
||||||
|
"{issues:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
issues
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.contains("model_answer or a rubric")),
|
||||||
|
"{issues:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catches_missing_and_multiple_keys() {
|
fn catches_missing_and_multiple_keys() {
|
||||||
let b = bank(
|
let b = bank(
|
||||||
|
|||||||
+312
-1
@@ -19,7 +19,11 @@
|
|||||||
//! it. That keeps bank files readable and reviewable in a pull request while
|
//! it. That keeps bank files readable and reviewable in a pull request while
|
||||||
//! still letting statistics accumulate across terms.
|
//! still letting statistics accumulate across terms.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::de::{self, MapAccess, Visitor};
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::hash::fingerprint;
|
use crate::hash::fingerprint;
|
||||||
@@ -79,8 +83,25 @@ pub struct Item {
|
|||||||
|
|
||||||
/// The answer options in canonical order. Shuffling happens at export time
|
/// The answer options in canonical order. Shuffling happens at export time
|
||||||
/// per form, never here, so the bank stays diffable.
|
/// per form, never here, so the bank stays diffable.
|
||||||
|
///
|
||||||
|
/// Empty for a [`Format::OpenResponse`] item, which is answered in free text
|
||||||
|
/// and graded from its [`Solution`] instead. A choice format must still supply
|
||||||
|
/// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving
|
||||||
|
/// them out is reported there, with every other problem, rather than failing
|
||||||
|
/// the parse on its own.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub options: Vec<Choice>,
|
pub options: Vec<Choice>,
|
||||||
|
|
||||||
|
/// The worked solution: a model answer, an explanation a student can learn
|
||||||
|
/// from, and, for an open-response item, the rubric it is graded against.
|
||||||
|
///
|
||||||
|
/// This is what the solutions document renders and what a paper answer key
|
||||||
|
/// prints. It is withheld from any question paper and from the exam payload,
|
||||||
|
/// the same way an option's `correct` flag is, so a document built for the
|
||||||
|
/// student cannot leak it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub solution: Option<Solution>,
|
||||||
|
|
||||||
/// Objectives this item measures, as ids into the course registry.
|
/// Objectives this item measures, as ids into the course registry.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub learning_objectives: Vec<String>,
|
pub learning_objectives: Vec<String>,
|
||||||
@@ -239,6 +260,214 @@ pub struct Source {
|
|||||||
pub recording_seconds: Option<u32>,
|
pub recording_seconds: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pointer from an item into the course reference registry: where to read more,
|
||||||
|
/// or what to revisit after missing the item.
|
||||||
|
///
|
||||||
|
/// It holds a citation key and a locator rather than a restated citation, so a
|
||||||
|
/// reference is written once in `course.yaml` and a changed edition is a single
|
||||||
|
/// edit. The exporters resolve it against
|
||||||
|
/// [`crate::course::CourseFile::references`] into a short label such as `KKW §6.1`,
|
||||||
|
/// linked when the location resolves to a URL. This is the same pointer a lecture
|
||||||
|
/// [`crate::course::Reading`] uses, kept lean here because an item cites a reading;
|
||||||
|
/// it does not restate one.
|
||||||
|
///
|
||||||
|
/// A citation may also be written as a bare string, which lands unparsed in `text`
|
||||||
|
/// and serializes back out as a string, so a bank that stored readings as plain
|
||||||
|
/// strings keeps loading and round-trips byte-for-byte.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Citation {
|
||||||
|
/// Citation key into the course reference registry.
|
||||||
|
pub reference: Option<String>,
|
||||||
|
/// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
|
||||||
|
pub locator: Option<String>,
|
||||||
|
/// Appended to the reference's `base_url` to reach this location.
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// A full URL, when the location is not under the reference's `base_url`.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// A citation written as a bare string, held unparsed.
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Citation {
|
||||||
|
/// A short display string that needs no reference lookup.
|
||||||
|
///
|
||||||
|
/// Prefers the unparsed `text`, then the key and locator. A caller that holds
|
||||||
|
/// the course, such as an exporter, can resolve a nicer label and a link; this
|
||||||
|
/// is the fallback for one that does not.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The display string, empty when the citation carries nothing.
|
||||||
|
pub fn display(&self) -> String {
|
||||||
|
if let Some(text) = &self.text {
|
||||||
|
return text.clone();
|
||||||
|
}
|
||||||
|
match (&self.reference, &self.locator) {
|
||||||
|
(Some(k), Some(l)) => format!("{k} {l}"),
|
||||||
|
(Some(k), None) => k.clone(),
|
||||||
|
(None, Some(l)) => l.clone(),
|
||||||
|
(None, None) => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a citation as a mapping, or as a bare string when that is all it holds.
|
||||||
|
impl Serialize for Citation {
|
||||||
|
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
if let Some(text) = &self.text {
|
||||||
|
if self.reference.is_none()
|
||||||
|
&& self.locator.is_none()
|
||||||
|
&& self.path.is_none()
|
||||||
|
&& self.url.is_none()
|
||||||
|
{
|
||||||
|
return s.serialize_str(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut map = s.serialize_map(None)?;
|
||||||
|
if let Some(v) = &self.reference {
|
||||||
|
map.serialize_entry("ref", v)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = &self.locator {
|
||||||
|
map.serialize_entry("locator", v)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = &self.path {
|
||||||
|
map.serialize_entry("path", v)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = &self.url {
|
||||||
|
map.serialize_entry("url", v)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = &self.text {
|
||||||
|
map.serialize_entry("text", v)?;
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts a citation written either as a mapping or as a bare string.
|
||||||
|
impl<'de> Deserialize<'de> for Citation {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Citation, D::Error> {
|
||||||
|
/// The mapping form, with the field set kept in one place.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Mapping {
|
||||||
|
#[serde(rename = "ref", default)]
|
||||||
|
reference: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
locator: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct V;
|
||||||
|
impl<'a> Visitor<'a> for V {
|
||||||
|
type Value = Citation;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("a citation mapping with a `ref`, or a plain citation string")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Citation, E> {
|
||||||
|
Ok(Citation {
|
||||||
|
text: Some(v.to_string()),
|
||||||
|
..Citation::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<M: MapAccess<'a>>(
|
||||||
|
self,
|
||||||
|
map: M,
|
||||||
|
) -> std::result::Result<Citation, M::Error> {
|
||||||
|
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
|
||||||
|
Ok(Citation {
|
||||||
|
reference: m.reference,
|
||||||
|
locator: m.locator,
|
||||||
|
path: m.path,
|
||||||
|
url: m.url,
|
||||||
|
text: m.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.deserialize_any(V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of a grading rubric for an open-response item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RubricCriterion {
|
||||||
|
/// What earns the points, e.g. "states H = U + PV" or "compares to ~2.5 kJ/mol".
|
||||||
|
pub description: String,
|
||||||
|
/// Points for this line. Absent lets a grader decide; when present, the lines
|
||||||
|
/// are meant to sum to the item's point value.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub points: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worked solution to an item: what the answer is, why, and how it is graded.
|
||||||
|
///
|
||||||
|
/// One place, versioned with the question, holds everything a student learns from
|
||||||
|
/// after the fact and everything a grader marks an open response against. For a
|
||||||
|
/// choice item the per-option [`Choice::explanation`] says why each option is right
|
||||||
|
/// or wrong; the solution adds the single worked line of reasoning a solutions
|
||||||
|
/// document leads with. For an [`Format::OpenResponse`] item the solution is the
|
||||||
|
/// whole answer, because there are no options to annotate.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Solution {
|
||||||
|
/// The model answer, in the authoring markup. For an open-response item this is
|
||||||
|
/// the response a full-credit student would write; for a choice item it is an
|
||||||
|
/// optional one-line statement of the key in words.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model_answer: Option<String>,
|
||||||
|
/// The worked reasoning a student can learn from: the derivation, the estimate,
|
||||||
|
/// the argument for the key over its neighbours. This is the body of the
|
||||||
|
/// solutions document.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub explanation: Option<String>,
|
||||||
|
/// How an open response is graded, one criterion per line.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub rubric: Vec<RubricCriterion>,
|
||||||
|
/// Responses a short constructed answer would be accepted as. Shown in the
|
||||||
|
/// solutions document as accepted answers, and the hook for automated grading
|
||||||
|
/// later.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub accepted: Vec<String>,
|
||||||
|
/// Where to look again after missing this item, as citations into the course
|
||||||
|
/// reference registry. Resolved and linked by the exporters.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub review: Vec<Citation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Solution {
|
||||||
|
/// Whether the solution carries anything worth rendering.
|
||||||
|
///
|
||||||
|
/// Used to decide whether a solutions entry has a body to print, so an item
|
||||||
|
/// with an empty `solution:` block is treated as having none.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.model_answer.is_none()
|
||||||
|
&& self.explanation.is_none()
|
||||||
|
&& self.rubric.is_empty()
|
||||||
|
&& self.accepted.is_empty()
|
||||||
|
&& self.review.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total of the rubric line points, when every line carries one.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The sum, or `None` if any line omits its points or the rubric is empty.
|
||||||
|
pub fn rubric_points(&self) -> Option<f64> {
|
||||||
|
if self.rubric.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.rubric.iter().map(|c| c.points).sum::<Option<f64>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A figure or data file reproduced with an item.
|
/// A figure or data file reproduced with an item.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -473,6 +702,7 @@ impl Item {
|
|||||||
stimulus: None,
|
stimulus: None,
|
||||||
stem: stem.to_string(),
|
stem: stem.to_string(),
|
||||||
options,
|
options,
|
||||||
|
solution: None,
|
||||||
learning_objectives: Vec::new(),
|
learning_objectives: Vec::new(),
|
||||||
sources: Vec::new(),
|
sources: Vec::new(),
|
||||||
topics: Vec::new(),
|
topics: Vec::new(),
|
||||||
@@ -538,6 +768,14 @@ impl Item {
|
|||||||
self.key_indices().len() > 1
|
self.key_indices().len() > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the item presents selectable options, per its [`Format`].
|
||||||
|
///
|
||||||
|
/// `false` for an [`Format::OpenResponse`] item. Callers that would otherwise
|
||||||
|
/// index `options` or read a key should branch on this first.
|
||||||
|
pub fn has_options(&self) -> bool {
|
||||||
|
self.format.has_options()
|
||||||
|
}
|
||||||
|
|
||||||
/// The display title, falling back to a truncated stem.
|
/// The display title, falling back to a truncated stem.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
@@ -821,4 +1059,77 @@ options:
|
|||||||
IrtModel::ThreePl
|
IrtModel::ThreePl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_response_item_parses_without_options() {
|
||||||
|
let it = item(
|
||||||
|
r#"
|
||||||
|
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.
|
||||||
|
solution:
|
||||||
|
model_answer: >-
|
||||||
|
At constant pressure the P dV expansion work is folded into H = U + PV, so the
|
||||||
|
heat q_p equals the change in H.
|
||||||
|
rubric:
|
||||||
|
- { description: "states H = U + PV", points: 1 }
|
||||||
|
- { description: "identifies q_p with the enthalpy change", points: 1 }
|
||||||
|
review:
|
||||||
|
- { ref: kuriyan2012molecules, locator: "§6.4", path: "6/A/#4" }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(it.format, Format::OpenResponse);
|
||||||
|
assert!(it.options.is_empty());
|
||||||
|
assert!(!it.has_options());
|
||||||
|
assert!(it.key_letters().is_empty());
|
||||||
|
let sol = it.solution.as_ref().expect("has a solution");
|
||||||
|
assert!(!sol.is_empty());
|
||||||
|
assert_eq!(sol.rubric_points(), Some(2.0));
|
||||||
|
assert_eq!(sol.review.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
sol.review[0].reference.as_deref(),
|
||||||
|
Some("kuriyan2012molecules")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_solution_serializes_only_what_it_holds() {
|
||||||
|
let it = item(
|
||||||
|
r#"
|
||||||
|
id: q-demo-op-002
|
||||||
|
status: draft
|
||||||
|
level: 2
|
||||||
|
format: open_response
|
||||||
|
stem: State the first law.
|
||||||
|
solution:
|
||||||
|
model_answer: The total energy of an isolated system is constant.
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let yaml = serde_yaml_ng::to_string(&it).expect("serializes");
|
||||||
|
// Open-response items carry no options key, and an empty rubric is omitted.
|
||||||
|
assert!(!yaml.contains("options:"), "no options key:\n{yaml}");
|
||||||
|
assert!(!yaml.contains("rubric"), "empty rubric omitted:\n{yaml}");
|
||||||
|
assert!(yaml.contains("model_answer:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_citation_round_trips_as_string_or_mapping() {
|
||||||
|
// A bare string stays a bare string.
|
||||||
|
let bare: Citation = serde_yaml_ng::from_str("\"KKW §6.4 (course reserve)\"").unwrap();
|
||||||
|
assert_eq!(bare.text.as_deref(), Some("KKW §6.4 (course reserve)"));
|
||||||
|
assert_eq!(bare.display(), "KKW §6.4 (course reserve)");
|
||||||
|
let back = serde_yaml_ng::to_string(&bare).unwrap();
|
||||||
|
assert_eq!(back.trim(), "KKW §6.4 (course reserve)");
|
||||||
|
|
||||||
|
// A mapping keeps its fields, and `ref` is the key's YAML spelling.
|
||||||
|
let mapped: Citation =
|
||||||
|
serde_yaml_ng::from_str("{ ref: kuriyan2012molecules, locator: \"§6.4\" }").unwrap();
|
||||||
|
assert_eq!(mapped.reference.as_deref(), Some("kuriyan2012molecules"));
|
||||||
|
assert_eq!(mapped.display(), "kuriyan2012molecules §6.4");
|
||||||
|
let back = serde_yaml_ng::to_string(&mapped).unwrap();
|
||||||
|
assert!(back.contains("ref: kuriyan2012molecules"));
|
||||||
|
assert!(back.contains("locator:"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,17 +423,53 @@ pub enum Format {
|
|||||||
MultipleResponse,
|
MultipleResponse,
|
||||||
/// Two options, True and False.
|
/// Two options, True and False.
|
||||||
TrueFalse,
|
TrueFalse,
|
||||||
|
/// A free-text answer the student writes rather than selects.
|
||||||
|
///
|
||||||
|
/// It carries no options and is not machine-scored. What a grader marks it
|
||||||
|
/// against, and what a solutions document shows, lives in the item's
|
||||||
|
/// [`crate::item::Solution`]: a model answer and, when the item is worth more
|
||||||
|
/// than a point, a rubric. This is the format for "explain", "derive", and
|
||||||
|
/// "estimate" prompts that a set of distractors would trivialize.
|
||||||
|
OpenResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Format {
|
impl Format {
|
||||||
|
/// Every response format.
|
||||||
|
pub const ALL: [Format; 4] = [
|
||||||
|
Format::SingleBestAnswer,
|
||||||
|
Format::MultipleResponse,
|
||||||
|
Format::TrueFalse,
|
||||||
|
Format::OpenResponse,
|
||||||
|
];
|
||||||
|
|
||||||
/// The QTI question type Canvas expects for this format.
|
/// The QTI question type Canvas expects for this format.
|
||||||
pub fn qti_type(self) -> &'static str {
|
pub fn qti_type(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Format::SingleBestAnswer => "multiple_choice_question",
|
Format::SingleBestAnswer => "multiple_choice_question",
|
||||||
Format::MultipleResponse => "multiple_answers_question",
|
Format::MultipleResponse => "multiple_answers_question",
|
||||||
Format::TrueFalse => "true_false_question",
|
Format::TrueFalse => "true_false_question",
|
||||||
|
Format::OpenResponse => "essay_question",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The snake_case token used in YAML.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Format::SingleBestAnswer => "single_best_answer",
|
||||||
|
Format::MultipleResponse => "multiple_response",
|
||||||
|
Format::TrueFalse => "true_false",
|
||||||
|
Format::OpenResponse => "open_response",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether items in this format present selectable options.
|
||||||
|
///
|
||||||
|
/// `false` only for [`Format::OpenResponse`]. Validation, assembly, and the
|
||||||
|
/// exporters branch on this rather than on the variant, so the day a second
|
||||||
|
/// free-text format is added it inherits the no-options handling for free.
|
||||||
|
pub fn has_options(self) -> bool {
|
||||||
|
!matches!(self, Format::OpenResponse)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How strongly an item is expected to separate strong from weak students.
|
/// How strongly an item is expected to separate strong from weak students.
|
||||||
@@ -633,4 +669,18 @@ mod tests {
|
|||||||
Status::InReview
|
Status::InReview
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_response_is_the_only_format_without_options() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Format>("\"open_response\"").unwrap(),
|
||||||
|
Format::OpenResponse
|
||||||
|
);
|
||||||
|
assert_eq!(Format::OpenResponse.as_str(), "open_response");
|
||||||
|
assert_eq!(Format::OpenResponse.qti_type(), "essay_question");
|
||||||
|
assert!(!Format::OpenResponse.has_options());
|
||||||
|
for f in Format::ALL {
|
||||||
|
assert_eq!(f.has_options(), f != Format::OpenResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,32 @@ pub fn to_plain(src: &str) -> String {
|
|||||||
out.trim().to_string()
|
out.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts authoring markup to Pandoc-flavoured Markdown, for a Quarto document.
|
||||||
|
///
|
||||||
|
/// Subscripts and superscripts become Pandoc's `~x~` and `^x^`, the symbol table
|
||||||
|
/// renders as Unicode, and the bold, italic, and inline-code spans are already
|
||||||
|
/// Markdown, so they pass through unchanged. Paragraph breaks are kept. Nothing is
|
||||||
|
/// HTML-escaped, because the consumer is a Markdown renderer rather than a page.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `src` - the authoring source.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Pandoc Markdown, trimmed, with paragraph breaks preserved.
|
||||||
|
pub fn to_markdown(src: &str) -> String {
|
||||||
|
let symbolized = apply_symbols(src, false);
|
||||||
|
let mut out = wrap_bracket(&symbolized, "#sub[", "~", "~");
|
||||||
|
out = wrap_bracket(&out, "#sup[", "^", "^");
|
||||||
|
out.lines()
|
||||||
|
.map(|l| l.trim_end())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Passes authoring markup through for Typst.
|
/// Passes authoring markup through for Typst.
|
||||||
///
|
///
|
||||||
/// The markup is already a Typst subset, so this only normalizes whitespace and
|
/// The markup is already a Typst subset, so this only normalizes whitespace and
|
||||||
@@ -379,4 +405,21 @@ mod tests {
|
|||||||
assert_eq!(to_typst("a @ b"), "a \\@ b");
|
assert_eq!(to_typst("a @ b"), "a \\@ b");
|
||||||
assert_eq!(to_typst("x < y"), "x \\< y");
|
assert_eq!(to_typst("x < y"), "x \\< y");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_uses_pandoc_scripts_and_unicode_symbols() {
|
||||||
|
assert_eq!(to_markdown("H#sub[2]O"), "H~2~O");
|
||||||
|
assert_eq!(to_markdown("x#sup[2]"), "x^2^");
|
||||||
|
assert_eq!(
|
||||||
|
to_markdown("K#sub[m] #sym.approx 5 mM"),
|
||||||
|
"K~m~ \u{2248} 5 mM"
|
||||||
|
);
|
||||||
|
// Bold, italic, and code are already Markdown.
|
||||||
|
assert_eq!(
|
||||||
|
to_markdown("**bold** and *em* and `code`"),
|
||||||
|
"**bold** and *em* and `code`"
|
||||||
|
);
|
||||||
|
// Paragraph breaks survive.
|
||||||
|
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user