feat: support readings and lecture rendering

This commit is contained in:
2026-08-08 16:24:50 -04:00
parent 468af3a815
commit f475c630e0
10 changed files with 1535 additions and 11 deletions
+106 -1
View File
@@ -291,7 +291,12 @@ fn lecture_schema() -> Value {
"date": date("Date delivered."),
"unit": { "type": "string", "description": "Unit id." },
"slides_url": { "type": "string" },
"readings": string_array("Readings assigned with this lecture.")
"readings": {
"type": "array",
"description": "Readings assigned with this lecture, in the order you assign \
them. A plain string is the pre-schema form and still loads.",
"items": reading_schema()
}
}
})
}
@@ -306,6 +311,13 @@ fn objective_schema() -> Value {
"text": text("The objective as a student would read it. Start with a verb."),
"unit": { "type": "string" },
"lectures": string_array("Lecture ids that cover this."),
"order": {
"type": "integer",
"minimum": 1,
"description": "Position in teaching order, low first. A lecture page numbers \
objectives by this; without it they sort by id, which puts \
an objective before its own prerequisite."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Objective ids that must come first. Cycles are rejected."
@@ -320,6 +332,93 @@ fn objective_schema() -> Value {
})
}
/// The schema for one cited work.
fn reference_schema() -> Value {
json!({
"type": "object",
"required": ["title"],
"additionalProperties": false,
"properties": {
"label": text("Short form a reading list shows, such as KKW. One work per label."),
"kind": {
"type": "string",
"enum": strings(&[
"book", "chapter", "article", "preprint", "thesis",
"website", "software", "dataset", "video", "other"
]),
"description": "Kind of work, following BibTeX entry types."
},
"role": {
"type": "string",
"enum": strings(&["required", "supplemental"]),
"description": "required for a course text; supplemental for background."
},
"title": text("Full title."),
"authors": string_array("Authors as `Family, Given`, in printed order."),
"year": { "type": "integer", "description": "Year of publication." },
"edition": { "type": "string", "description": "Edition as printed: 7th." },
"publisher": { "type": "string" },
"container": { "type": "string", "description": "Journal, edited volume, or series." },
"volume": { "type": "string" },
"issue": { "type": "string" },
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
"isbn": { "type": "string" },
"url": { "type": "string", "description": "Canonical URL for the whole work." },
"base_url": {
"type": "string",
"description": "Prefix a reading's `path` is joined to, so the citation key \
appears once instead of once per reading."
},
"note": { "type": "string", "description": "Access notes: reserve shelf, license." }
}
})
}
/// The schema for one reading: a location inside a reference, and what it is for.
fn reading_schema() -> Value {
json!({
"oneOf": [
{ "type": "string", "description": "The pre-schema form: a citation, unparsed." },
reading_mapping_schema()
]
})
}
/// The mapping form of a reading.
fn reading_mapping_schema() -> Value {
json!({
"type": "object",
"required": ["ref"],
"additionalProperties": false,
"properties": {
"ref": text("Citation key into `references`."),
"locator": text("Where inside the work: §6.1, pp. 212-219, ch. 3."),
"path": {
"type": "string",
"description": "Joined to the reference's base_url to reach this location."
},
"url": {
"type": "string",
"description": "Full URL, when base_url does not cover the location."
},
"role": {
"type": "string",
"enum": strings(&["assigned", "supplemental"]),
"description": "supplemental means offered but not separately assessed."
},
"objectives": string_array(
"Objective ids this reading serves. A student who misses one of these is \
pointed here, so the list is what makes study guidance specific."
),
"summary": text("What the section contains."),
"focus": text("What to take from it. This is the sentence a student report quotes."),
"skip": text("What to gloss, and why it is out of scope."),
"text": text("A pre-schema citation string, held unparsed.")
}
})
}
/// The schema for one shared stimulus.
fn stimulus_schema() -> Value {
json!({
@@ -373,6 +472,12 @@ fn course_schema() -> Value {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
"additionalProperties": stimulus_schema()
},
"references": {
"type": "object",
"description": "Works the course cites, by citation key. Readings point in \
here, so an edition change is one edit.",
"additionalProperties": reference_schema()
}
}
})
+62
View File
@@ -23,6 +23,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use coursebank::assessment::{Kind as AssessmentKind, Platform};
use coursebank::catalog::Severity;
use coursebank::item::IrtModel;
use coursebank::lecture::Style as PageStyle;
use coursebank::store;
/// Manage course item banks, assessments, and the analysis that comes back.
@@ -55,6 +56,9 @@ pub(crate) enum Command {
Lint(LintArgs),
/// Summarize the item pool and objective coverage.
Catalog(CatalogArgs),
/// Render a lecture's reading list, or check what backs each objective.
#[command(subcommand)]
Lecture(LectureCommand),
/// Work with item banks.
#[command(subcommand)]
Bank(BankCommand),
@@ -123,6 +127,64 @@ pub(crate) struct LintArgs {
}
/// CLI mirror of [`coursebank::catalog::Severity`].
#[derive(Debug, Subcommand)]
pub(crate) enum LectureCommand {
/// Write the readings block for one lecture.
///
/// The course file is the source of truth for what a lecture assigns and why,
/// so the list on the website is generated from it. Objective numbers are
/// positional and are resolved here rather than authored.
Readings {
/// Lecture id, e.g. L1.1.
id: String,
/// Which flavour of Markdown to write.
#[arg(long, value_enum, default_value = "quarto")]
style: StyleArg,
/// Output path; prints to stdout when omitted.
#[arg(long)]
out: Option<PathBuf>,
},
/// Write the objectives block for one lecture, grouped by level.
///
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
/// `readings`, so generating one and hand-writing the other is what this
/// exists to prevent.
Objectives {
/// Lecture id, e.g. L1.1.
id: String,
/// Which flavour of Markdown to write.
#[arg(long, value_enum, default_value = "quarto")]
style: StyleArg,
/// Output path; prints to stdout when omitted.
#[arg(long)]
out: Option<PathBuf>,
},
/// Show the readings behind each objective, and which objectives have none.
Coverage {
/// Only this lecture's objectives.
#[arg(long)]
lecture: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum StyleArg {
/// Pandoc definition lists, as a Quarto lecture page wants them.
Quarto,
/// Plain Markdown bullets.
Plain,
}
impl StyleArg {
/// Converts the CLI value into the library's [`PageStyle`].
pub(crate) fn as_style(self) -> PageStyle {
match self {
StyleArg::Quarto => PageStyle::Quarto,
StyleArg::Plain => PageStyle::Plain,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum SeverityArg {
Low,
+4
View File
@@ -10,6 +10,8 @@
//!
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
//! `lint`, `catalog`.
//! - [`lectures`] — render a lecture's reading list and check what backs each
//! objective: `lecture`.
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
//! `assemble`, `usage`.
//! - [`export`] — turn an assessment into deliverables: `export`, `template`.
@@ -22,6 +24,7 @@
pub(crate) mod analysis;
pub(crate) mod banks;
pub(crate) mod export;
pub(crate) mod lectures;
pub(crate) mod project;
use coursebank::error::Result;
@@ -56,6 +59,7 @@ pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
Command::Validate => project::validate(cli),
Command::Lint(args) => project::lint(cli, args),
Command::Catalog(args) => project::catalog(cli, args),
Command::Lecture(sub) => lectures::lecture(cli, sub),
Command::Bank(sub) => banks::bank(cli, sub),
Command::Assessment(sub) => banks::assessment(cli, sub),
Command::Assemble(args) => banks::assemble(cli, args),
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering lecture pages, and checking what backs each objective.
//!
//! Both handlers here read the course file and nothing else, so neither needs a
//! bank or a single response. That is deliberate: a reading list is useful in week
//! one, before any item exists.
use coursebank::course::CourseFile;
use coursebank::error::Result;
use coursebank::lecture::{objectives_markdown, readings_markdown};
use coursebank::yaml;
use crate::cli::{Cli, LectureCommand};
use crate::commands::Outcome;
/// `lecture`: render a reading list, or report reading coverage.
pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
let course = CourseFile::load_dir(&cli.course)?;
match sub {
LectureCommand::Readings { id, style, out } => emit(
readings_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Objectives { id, style, out } => emit(
objectives_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
}
}
/// Writes rendered Markdown to a file, or to stdout when no path was given.
fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
match out {
Some(path) => {
yaml::write_text(path, &markdown)?;
println!("wrote {}", path.display());
}
None => print!("{markdown}"),
}
Ok(Outcome::Ok)
}
/// Prints the readings behind each objective.
///
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
/// that is the case where a student report can name what was missed but not where
/// to go and read about it.
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
let ids: Vec<String> = match lecture {
Some(l) => course
.lecture_objectives(l)
.into_iter()
.map(str::to_string)
.collect(),
None => course.objectives_in_order(),
};
for id in &ids {
let readings = course.readings_for_objective(id);
println!("{id}");
if readings.is_empty() {
println!(" (no reading)");
continue;
}
for (lecture_id, reading) in readings {
let Some(key) = reading.reference.as_deref() else {
continue;
};
let reference = course.reference(key, lecture_id)?;
let supplemental = match reading.role {
coursebank::course::ReadingRole::Supplemental => " (supplemental)",
coursebank::course::ReadingRole::Assigned => "",
};
println!(
" {lecture_id} {}{supplemental}",
reading.cite(key, reference)
);
if let Some(focus) = &reading.focus {
println!(
" {}",
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
);
}
}
}
let gaps = course.objectives_without_readings();
if gaps.is_empty() {
if !quiet {
println!("\nevery assessed objective has a reading behind it");
}
return Ok(Outcome::Ok);
}
println!(
"\n{} assessed objective(s) with no reading, so a student report cannot say \
where to go back to:",
gaps.len()
);
for id in gaps {
println!(" - {id}");
}
Ok(Outcome::Findings)
}
+5 -4
View File
@@ -183,13 +183,14 @@ fn merge_gitignore(existing: &str) -> GitignoreMerge {
/// Writes the `.gitignore`, merging into one that is already there.
///
/// `init` is often run in a repository that already has a `.gitignore`.
/// An existing file keeps everything it had and gains only the patterns
/// it was missing, at the top where they are easy to see in the diff.
/// `init` is often run in a repository that already has a `.gitignore`, and the
/// first version of this overwrote it. An existing file now keeps everything it
/// had and gains only the patterns it was missing, at the top where they are easy
/// to see in the diff.
fn write_gitignore(path: &Path) -> Result<()> {
let existing = match fs::read_to_string(path) {
Ok(text) => text,
// Nothing to merge with. Stay quiet about it.
// Nothing to merge with. Stay quiet about it, the way this always has.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return yaml::write_text(path, GITIGNORE);
}
+2
View File
@@ -9,6 +9,7 @@
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website |
//!
//! [`qti`] and [`typst`] share one rule that is easy to get wrong: a form's answer
//! key must be generated from the same permutation that produced its question
@@ -20,6 +21,7 @@
//! answers, other students' data, and any numeric rank.
//! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod lecture;
pub mod qti;
pub mod report;
pub mod typst;
+414
View File
@@ -0,0 +1,414 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering a lecture's objectives and readings as Markdown.
//!
//! The course file is the source of truth for what a lecture assigns and why, so
//! the reading list on the course website is generated rather than kept in step by
//! hand. Two copies of the same prose drift within a term; one copy and a build
//! step do not.
//!
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
//! Those numbers are positional and so cannot be authored: inserting an objective
//! renumbers everything after it. They are computed here and never stored.
//!
//! What this module does not do is invent prose. Everything printed comes from
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
//! none of the three renders as a bare citation.
use crate::course::{CourseFile, Reading, ReadingRole, Reference};
use crate::error::{Error, Result};
use crate::taxonomy::Level;
/// Which flavour of Markdown to emit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Style {
/// Pandoc definition lists with `<br>` before the objective line, which is
/// what a Quarto lecture page uses.
#[default]
Quarto,
/// Plain Markdown bullets, for a report or a README.
Plain,
}
/// Renders the objectives for one lecture, grouped by level.
///
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
/// disagree. Generating one half of the page and hand-writing the other is how you
/// get a note pointing at LO 8 when LO 8 has become LO 9.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
/// grouped last under no heading.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
course.lecture(lecture, "lecture page")?;
let ids = course.lecture_objectives(lecture);
let mut out = String::from("## Learning objectives\n\n");
out.push_str("After this lecture, you should be able to do the following.\n\n");
// Levels in taxonomy order, then whatever declares no ceiling.
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
groups.push((None, Vec::new()));
for id in &ids {
let ceiling = course.learning_objectives[*id].level_ceiling;
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
slot.1.push(id);
}
}
for (level, members) in &groups {
if members.is_empty() {
continue;
}
if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name()));
}
for id in members {
let text = course.objective_text(id);
out.push_str(&match style {
Style::Quarto => format!("(@) {text}\n"),
Style::Plain => format!("1. {text}\n"),
});
}
out.push('\n');
}
Ok(out)
}
/// Renders the readings for one lecture.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id, such as `L1.1`.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. Supplemental readings follow the assigned
/// ones under their own subheading, and are omitted entirely when there are none.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the lecture id or a cited reference is not
/// registered.
pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
let lec = course.lecture(lecture, "lecture page")?;
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
// `_(LO ...)_` agree with the objective list printed above them.
let order = course.lecture_objectives(lecture);
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
let mut out = String::from("## Readings\n\n");
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
let group: Vec<&Reading> = lec.readings.iter().filter(|r| r.role == role).collect();
if group.is_empty() {
continue;
}
if role == ReadingRole::Supplemental {
out.push_str("### Supplemental\n\n");
}
for reading in group {
out.push_str(&entry(course, reading, style, &number)?);
}
}
Ok(out)
}
/// Renders one reading.
///
/// # Arguments
///
/// * `course` - the course, for resolving references and placeholders.
/// * `reading` - the reading.
/// * `style` - which flavour to emit.
/// * `number` - the position of an objective on this page, if it has one.
///
/// # Returns
///
/// The entry, followed by a blank line.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the cited reference is not registered.
fn entry(
course: &CourseFile,
reading: &Reading,
style: Style,
number: &impl Fn(&str) -> Option<usize>,
) -> Result<String> {
// A reading carried over from the old string form has nothing to resolve.
if let (None, Some(text)) = (&reading.reference, &reading.text) {
return Ok(match style {
Style::Quarto => format!("{text}\n\n"),
Style::Plain => format!("- {text}\n"),
});
}
let key = reading
.reference
.as_deref()
.ok_or_else(|| Error::other("reading has neither a reference nor text"))?;
let reference = course.reference(key, "lecture page")?;
let mut out = String::new();
out.push_str(&heading(reading, key, reference, style));
// The three prose fields in the order a reader wants them: what it is, what to
// take from it, what to leave.
let body: Vec<String> = [&reading.summary, &reading.focus, &reading.skip]
.into_iter()
.flatten()
.map(|prose| {
course.expand_objective_refs(prose, |id| match number(id) {
Some(n) => format!("LO {n}"),
None => course.objective_text(id),
})
})
.collect();
match style {
Style::Quarto => {
if !body.is_empty() {
out.push_str(&format!(": {}\n", body.join("\n")));
}
let mut numbers: Vec<usize> = reading
.objectives
.iter()
.filter_map(|o| number(o))
.collect();
numbers.sort_unstable();
if !numbers.is_empty() {
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
out.push_str(&format!("<br>\n_(LO {})_\n", list.join(", ")));
}
out.push('\n');
}
Style::Plain => {
if !body.is_empty() {
out.push_str(&format!(" {}\n", body.join(" ")));
}
}
}
Ok(out)
}
/// The citation line that opens an entry.
///
/// # Arguments
///
/// * `reading` - the reading.
/// * `key` - its citation key.
/// * `reference` - the cited work.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// A linked citation when the location has a URL, and a plain one when it does not.
fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
let label = reference.label.as_deref().unwrap_or(key);
let locator = reading.locator.as_deref().unwrap_or("");
let linked = match reading.resolve_url(reference) {
Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
Some(url) => format!("[{}]({url})", reference.title),
None if !locator.is_empty() => locator.to_string(),
None => reference.title.clone(),
};
match style {
Style::Quarto => format!("`{label}` {linked}\n"),
Style::Plain => format!("- **{label}** {linked}\n"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::course::{Lecture, Objective, ReferenceRole};
/// A course with one lecture, two objectives, and one reference.
fn course() -> CourseFile {
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
c.lectures.clear();
c.learning_objectives.clear();
c.references.insert(
"kuriyan2013molecules".into(),
Reference {
label: Some("KKW".into()),
role: ReferenceRole::Required,
title: "The molecules of life".into(),
base_url: Some("https://example.org/kkw/".into()),
..Reference::default()
},
);
for (id, order) in [("lo-second", 2), ("lo-first", 1)] {
c.learning_objectives.insert(
id.into(),
Objective {
text: format!("objective {id}"),
lectures: vec!["L1.1".into()],
order: Some(order),
..objective_defaults()
},
);
}
c.lectures.insert(
"L1.1".into(),
Lecture {
title: "Enthalpy".into(),
date: None,
unit: None,
slides_url: None,
readings: vec![
Reading {
reference: Some("kuriyan2013molecules".into()),
locator: Some("§6.1".into()),
path: Some("6/A/#1".into()),
objectives: vec!["lo-second".into(), "lo-first".into()],
summary: Some("What a system is.".into()),
focus: Some("A worked instance of {lo-first}.".into()),
..Reading::default()
},
Reading {
reference: Some("kuriyan2013molecules".into()),
locator: Some("§1.9".into()),
path: Some("1/B/#9".into()),
role: ReadingRole::Supplemental,
objectives: vec!["lo-second".into()],
summary: Some("Background.".into()),
..Reading::default()
},
],
},
);
c
}
/// The non-defaulted half of an objective, so the fixtures stay short.
fn objective_defaults() -> Objective {
Objective {
text: String::new(),
unit: None,
lectures: Vec::new(),
order: None,
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
}
}
#[test]
fn the_quarto_form_matches_the_page_it_replaces() {
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Readings
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
: What a system is.
A worked instance of LO 1.
<br>
_(LO 1, 2)_
### Supplemental
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
: Background.
<br>
_(LO 2)_
";
assert_eq!(md, expected);
}
#[test]
fn objective_numbers_follow_teaching_order_not_id_order() {
// `lo-second` sorts first alphabetically and second by `order`.
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("_(LO 1, 2)_"));
assert!(md.contains("A worked instance of LO 1."));
}
#[test]
fn objectives_group_by_level_in_taxonomy_order() {
let mut c = course();
c.learning_objectives
.get_mut("lo-first")
.expect("fixture")
.level_ceiling = Some(Level::Remember);
c.learning_objectives
.get_mut("lo-second")
.expect("fixture")
.level_ceiling = Some(Level::Apply);
let md = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Learning objectives
After this lecture, you should be able to do the following.
### Remember
(@) objective lo-first
### Apply
(@) objective lo-second
";
assert_eq!(md, expected);
}
#[test]
fn an_objective_with_no_level_still_appears() {
// Ungrouped, at the end, rather than silently dropped.
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("(@) objective lo-first"));
assert!(md.contains("(@) objective lo-second"));
assert!(!md.contains("###"));
}
#[test]
fn a_supplemental_heading_appears_only_when_something_is_under_it() {
let mut c = course();
c.lectures.get_mut("L1.1").expect("lecture").readings.pop();
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
assert!(!md.contains("Supplemental"));
}
#[test]
fn a_legacy_string_reading_still_renders() {
let mut c = course();
let readings = &mut c.lectures.get_mut("L1.1").expect("lecture").readings;
readings.clear();
readings.push(Reading {
text: Some("KKW §6.1: system and surroundings. https://example.org".into()),
..Reading::default()
});
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("system and surroundings"));
}
#[test]
fn an_unknown_reference_is_an_error_rather_than_a_blank() {
let mut c = course();
c.references.clear();
let err = readings_markdown(&c, "L1.1", Style::Quarto);
assert!(err.is_err());
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst};
pub use export::{lecture, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
+766 -5
View File
@@ -16,9 +16,12 @@
//! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::date::Date;
use crate::error::{Error, Result};
@@ -61,6 +64,12 @@ pub struct CourseFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap<String, Objective>,
/// Works the course cites, keyed by citation key such as
/// `kuriyan2013molecules`. Readings point in here rather than restating a
/// citation, so a reference is written once and a changed edition is one edit.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub references: BTreeMap<String, Reference>,
/// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap<String, Stimulus>,
@@ -172,9 +181,318 @@ pub struct Lecture {
/// Where the slides live, for study guidance in student reports.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slides_url: Option<String>,
/// Assigned readings for the session.
/// Assigned readings for the session, in the order you assign them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
pub readings: Vec<Reading>,
}
/// A work the course cites: a textbook, an article, a dataset, a recording.
///
/// Keyed by citation key, so this registry is a bibliography rather than a second
/// naming scheme. The field names follow BibTeX where BibTeX has one, which makes
/// import and export mechanical.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reference {
/// The short form a reading list shows, such as `KKW`. Unique across the
/// registry, because reports print it in place of the key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// What kind of work this is, which decides how a citation renders.
#[serde(default)]
pub kind: ReferenceKind,
/// Whether the course requires it or lists it as background.
#[serde(default)]
pub role: ReferenceRole,
/// Full title.
pub title: String,
/// Authors as `Family, Given`, in the order printed on the work.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
/// Year of publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<u32>,
/// Edition as printed: `7th`, `Revised`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
/// Publisher, for a book.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// The journal, edited volume, or series this sits inside.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
/// Volume within the container.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volume: Option<String>,
/// Issue within the volume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issue: Option<String>,
/// Page range of the work as a whole, not of any one reading.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<String>,
/// DOI, bare: `10.1038/nature12373`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
/// ISBN, for a book.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isbn: Option<String>,
/// Canonical URL for the work as a whole.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Prefix a reading's `path` is appended to. Having this means the citation
/// key appears once rather than once per reading.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// Anything students need to know about getting hold of it: reserve shelf,
/// license, paywall.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// The kind of work, chosen to map onto BibTeX entry types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceKind {
/// A whole book.
#[default]
Book,
/// A chapter in an edited volume.
Chapter,
/// A journal article.
Article,
/// A preprint, which is an article without a container.
Preprint,
/// A thesis or dissertation.
Thesis,
/// A page or resource that exists only online.
Website,
/// A program or library.
Software,
/// A published dataset.
Dataset,
/// A recording.
Video,
/// Anything else.
Other,
}
/// Whether the course requires a work or offers it as background.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceRole {
/// A course text. Assigned readings come from it.
Required,
/// Listed so students know it exists. Never assigned.
#[default]
Supplemental,
}
/// One assigned location inside a [`Reference`], and what it is assigned for.
///
/// The prose splits three ways because each part answers a different question and
/// each has a different consumer. `summary` says what the section contains, `focus`
/// says what to take from it, and `skip` says what to ignore. A student report
/// quotes `focus` at somebody who missed the objective; a lecture page prints all
/// three.
///
/// A reading written as a bare string, which is what this field held before the
/// schema existed, still parses: the whole string lands in `text`, and serializing
/// writes it back out as a string rather than a mapping.
#[derive(Debug, Clone, Default)]
pub struct Reading {
/// Citation key into [`CourseFile::references`].
pub reference: Option<String>,
/// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`.
pub locator: Option<String>,
/// Appended to the reference's `base_url` to reach this location.
pub path: Option<String>,
/// A full URL, for a location that is not under the reference's `base_url`.
pub url: Option<String>,
/// Whether it is assigned or offered alongside.
pub role: ReadingRole,
/// The objectives this reading serves.
pub objectives: Vec<String>,
/// What the section contains.
pub summary: Option<String>,
/// What to take from it, which is the sentence a study suggestion quotes.
pub focus: Option<String>,
/// What to gloss, and why it is out of scope.
pub skip: Option<String>,
/// A reading written as a bare string before this schema existed, held
/// unparsed.
pub text: Option<String>,
}
/// Whether a reading is assigned or offered alongside.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReadingRole {
/// Assigned, and therefore fair to assess.
#[default]
Assigned,
/// Offered as background. Not separately assessed.
Supplemental,
}
impl Reading {
/// The URL for this location.
///
/// # Arguments
///
/// * `reference` - the work this reading is inside.
///
/// # Returns
///
/// `url` when given, otherwise the reference's `base_url` joined with `path`,
/// otherwise `None`.
pub fn resolve_url(&self, reference: &Reference) -> Option<String> {
if let Some(url) = &self.url {
return Some(url.clone());
}
let path = self.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}"),
})
}
/// A short citation for a report: `KKW §6.1`.
///
/// # Arguments
///
/// * `key` - the citation key, used when the reference declares no label.
/// * `reference` - the work, for its label.
///
/// # Returns
///
/// The label and locator, or the unparsed `text` for a legacy reading.
pub fn cite(&self, key: &str, reference: &Reference) -> String {
if let Some(text) = &self.text {
return text.clone();
}
let label = reference.label.as_deref().unwrap_or(key);
match &self.locator {
Some(locator) => format!("{label} {locator}"),
None => label.to_string(),
}
}
}
/// Writes a reading as a mapping, or as a bare string when that is all it holds.
///
/// The string case keeps a course file that predates this schema byte-identical
/// through a load-and-save cycle, so migrating is something you choose rather than
/// something the tool does to your file the first time it writes it.
impl Serialize for Reading {
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.objectives.is_empty() {
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 self.role != ReadingRole::Assigned {
map.serialize_entry("role", &self.role)?;
}
if !self.objectives.is_empty() {
map.serialize_entry("objectives", &self.objectives)?;
}
if let Some(v) = &self.summary {
map.serialize_entry("summary", v)?;
}
if let Some(v) = &self.focus {
map.serialize_entry("focus", v)?;
}
if let Some(v) = &self.skip {
map.serialize_entry("skip", v)?;
}
if let Some(v) = &self.text {
map.serialize_entry("text", v)?;
}
map.end()
}
}
/// Accepts a reading written either as a mapping or as a bare string.
///
/// The string form is what `readings` held before this schema, so course files
/// written against the old shape keep loading. It is the same courtesy
/// [`yaml::flexible_string`] extends to an unquoted `schema_version: 1.0`.
impl<'de> Deserialize<'de> for Reading {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Reading, 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)]
role: ReadingRole,
#[serde(default)]
objectives: Vec<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
focus: Option<String>,
#[serde(default)]
skip: Option<String>,
#[serde(default)]
text: Option<String>,
}
struct V;
impl<'a> Visitor<'a> for V {
type Value = Reading;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a reading mapping with a `ref`, or a plain citation string")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Reading, E> {
Ok(Reading {
text: Some(v.to_string()),
..Reading::default()
})
}
fn visit_map<M: MapAccess<'a>>(self, map: M) -> std::result::Result<Reading, M::Error> {
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(Reading {
reference: m.reference,
locator: m.locator,
path: m.path,
url: m.url,
role: m.role,
objectives: m.objectives,
summary: m.summary,
focus: m.focus,
skip: m.skip,
text: m.text,
})
}
}
d.deserialize_any(V)
}
}
/// A learning objective.
@@ -190,6 +508,15 @@ pub struct Objective {
/// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Position in teaching order, low first.
///
/// The registry is a map, so declaration order is lost on load, and sorting by
/// id would put `lo-enthalpy` before `lo-first-law` when the second is a
/// prerequisite of the first. Anything that prints objectives in the order you
/// teach them, a lecture page above all, needs this. Objectives without it sort
/// last, by id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
/// The highest level you intend to assess this objective at. Assembling an
/// item above the ceiling is a warning: either the item overreaches or the
/// ceiling needs raising.
@@ -306,6 +633,25 @@ impl CourseFile {
}
}
let mut labels: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (key, reference) in &self.references {
if reference.title.trim().is_empty() {
issues.push(format!("reference `{key}`: empty title"));
}
if let Some(label) = &reference.label {
labels.entry(label.as_str()).or_default().push(key);
}
}
for (label, keys) in &labels {
if keys.len() > 1 {
issues.push(format!(
"references: `{label}` is the label of {}; a label has to name one work \
because reports print it instead of the key",
keys.join(" and ")
));
}
}
for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title"));
@@ -315,6 +661,10 @@ impl CourseFile {
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
}
}
let mut seen: Vec<(&str, &str)> = Vec::new();
for (index, reading) in lec.readings.iter().enumerate() {
issues.extend(self.reading_issues(id, index, reading, &mut seen));
}
}
for (id, lo) in &self.learning_objectives {
@@ -347,6 +697,66 @@ impl CourseFile {
issues
}
/// Checks one reading, collecting every problem with it.
///
/// # Arguments
///
/// * `lecture` - the lecture id, for the message.
/// * `index` - position in the lecture's list, since a reading has no id.
/// * `reading` - the reading.
/// * `seen` - reference and locator pairs already found in this lecture,
/// extended as it goes.
///
/// # Returns
///
/// One message per problem.
fn reading_issues<'a>(
&self,
lecture: &str,
index: usize,
reading: &'a Reading,
seen: &mut Vec<(&'a str, &'a str)>,
) -> Vec<String> {
let mut issues = Vec::new();
let at = format!("lecture `{lecture}` reading {}", index + 1);
let Some(key) = reading.reference.as_deref() else {
if reading.text.is_none() {
issues.push(format!(
"{at}: needs a `ref` naming a reference, or a plain citation string"
));
}
return issues;
};
match self.references.get(key) {
None => issues.push(format!("{at}: unknown reference `{key}`")),
Some(reference) => {
if reading.path.is_some() && reference.base_url.is_none() && reading.url.is_none() {
issues.push(format!(
"{at}: has a `path` but reference `{key}` has no `base_url` to join it to"
));
}
}
}
if let Some(locator) = reading.locator.as_deref() {
if seen.contains(&(key, locator)) {
issues.push(format!(
"{at}: `{key} {locator}` is assigned twice in one lecture"
));
}
seen.push((key, locator));
}
for objective in &reading.objectives {
if !self.learning_objectives.contains_key(objective) {
issues.push(format!("{at}: unknown learning objective `{objective}`"));
}
}
issues
}
/// Detects cycles in the objective prerequisite graph.
///
/// A cycle would make a study-order suggestion loop forever, so it is worth
@@ -470,7 +880,8 @@ impl CourseFile {
.unwrap_or_else(|| id.to_string())
}
/// Objectives in a stable teaching order: by unit as declared, then by id.
/// Objectives in a stable teaching order: by unit as declared, then by
/// [`Objective::order`], then by id.
///
/// # Returns
///
@@ -490,11 +901,148 @@ impl CourseFile {
.as_deref()
.and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX);
(rank, (*id).clone())
(rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
});
ids.into_iter().cloned().collect()
}
/// The objectives a lecture covers, in teaching order.
///
/// # Arguments
///
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Objective ids whose `lectures` list names this lecture, ordered by
/// [`Objective::order`] and then by id.
pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> {
let mut ids: Vec<&String> = self
.learning_objectives
.iter()
.filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture))
.map(|(id, _)| id)
.collect();
ids.sort_by_key(|id| {
let lo = &self.learning_objectives[*id];
(lo.order.unwrap_or(u32::MAX), (*id).clone())
});
ids.into_iter().map(String::as_str).collect()
}
/// Every reading that serves an objective, with the lecture it was assigned in.
///
/// Derived by scanning lectures rather than stored on the objective, for the
/// same reason [`crate::history::History`] derives usage from assessment
/// records: a second copy of an edge is a second thing to keep in step. It also
/// puts the pointer on the volatile side, since a new edition renumbers
/// sections but leaves your objectives alone.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// Pairs of lecture id and reading, in lecture order then assignment order.
pub fn readings_for_objective(&self, objective: &str) -> Vec<(&str, &Reading)> {
let mut out = Vec::new();
for (lecture_id, lecture) in &self.lectures {
for reading in &lecture.readings {
if reading.objectives.iter().any(|o| o == objective) {
out.push((lecture_id.as_str(), reading));
}
}
}
out
}
/// Assessed objectives with no reading behind them.
///
/// These are the objectives a student report cannot advise on: it can say the
/// objective was missed, but not where to go and read about it.
///
/// # Returns
///
/// Objective ids in teaching order.
pub fn objectives_without_readings(&self) -> Vec<&str> {
let cited: std::collections::BTreeSet<&str> = self
.lectures
.values()
.flat_map(|l| l.readings.iter())
.flat_map(|r| r.objectives.iter())
.map(String::as_str)
.collect();
self.objectives_in_order()
.into_iter()
.filter_map(|id| {
let (key, lo) = self.learning_objectives.get_key_value(&id)?;
(lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str())
})
.collect()
}
/// Looks up a reference, erroring on a dangling citation key.
///
/// # Arguments
///
/// * `key` - the citation key.
/// * `context` - what cited it, for the error message.
///
/// # Returns
///
/// The reference.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the key is not registered.
pub fn reference(&self, key: &str, context: &str) -> Result<&Reference> {
self.references.get(key).ok_or_else(|| Error::Unresolved {
kind: "reference",
id: key.to_string(),
context: Some(context.to_string()),
})
}
/// Expands `{objective-id}` in a prose field to whatever the caller wants.
///
/// Reading notes refer to objectives in passing ("a worked instance of
/// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a
/// student report renders it as text. Only a name that resolves to a declared
/// objective is treated as a placeholder, so `$U_\text{final}$` passes through
/// untouched; that collision is the reason this is not a general template
/// syntax.
///
/// # Arguments
///
/// * `prose` - the field to expand.
/// * `render` - called with each resolved objective id.
///
/// # Returns
///
/// The prose with resolved placeholders replaced.
pub fn expand_objective_refs(&self, prose: &str, render: impl Fn(&str) -> String) -> String {
let mut out = String::with_capacity(prose.len());
let mut rest = prose;
while let Some(open) = rest.find('{') {
let (head, tail) = rest.split_at(open);
out.push_str(head);
let Some(close) = tail.find('}') else {
out.push_str(tail);
return out;
};
let name = &tail[1..close];
if self.learning_objectives.contains_key(name) {
out.push_str(&render(name));
} else {
out.push_str(&tail[..=close]);
}
rest = &tail[close + 1..];
}
out.push_str(rest);
out
}
/// A skeleton course file for `coursebank init`.
///
/// # Arguments
@@ -525,6 +1073,7 @@ impl CourseFile {
text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()],
order: Some(1),
level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(),
tags: Vec::new(),
@@ -549,6 +1098,7 @@ impl CourseFile {
}],
lectures,
learning_objectives: los,
references: BTreeMap::new(),
stimuli: BTreeMap::new(),
}
}
@@ -706,4 +1256,215 @@ learning_objectives:
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), "");
}
/// A course with one reference and two readings, one of them supplemental.
fn with_readings() -> CourseFile {
parse(
r#"
course: { code: X, title: Y, term: Z }
references:
kuriyan2013molecules:
label: KKW
role: required
title: The molecules of life
base_url: https://example.org/kkw/
lectures:
L1.1:
title: Enthalpy
readings:
- ref: kuriyan2013molecules
locator: '§6.1'
path: '6/A/#1'
objectives: [lo-a]
summary: What a system is.
focus: Fix the definitions.
- ref: kuriyan2013molecules
locator: '§1.9'
path: '1/B/#9'
role: supplemental
objectives: [lo-b]
learning_objectives:
lo-a: { text: A, lectures: [L1.1], order: 1 }
lo-b: { text: B, lectures: [L1.1], order: 2 }
"#,
)
}
#[test]
fn a_structured_reading_parses_and_validates() {
let c = with_readings();
assert!(c.validate().is_empty(), "{:?}", c.validate());
let readings = &c.lectures["L1.1"].readings;
assert_eq!(
readings[0].reference.as_deref(),
Some("kuriyan2013molecules")
);
assert_eq!(readings[0].role, ReadingRole::Assigned);
assert_eq!(readings[1].role, ReadingRole::Supplemental);
}
#[test]
fn a_reading_url_is_built_from_the_reference_base() {
let c = with_readings();
let reference = &c.references["kuriyan2013molecules"];
let reading = &c.lectures["L1.1"].readings[0];
assert_eq!(
reading.resolve_url(reference).as_deref(),
Some("https://example.org/kkw/6/A/#1")
);
assert_eq!(reading.cite("kuriyan2013molecules", reference), "KKW §6.1");
}
#[test]
fn a_bare_string_reading_still_parses_and_round_trips() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L01:
title: One
readings:
- 'KKW §6.1: system and surroundings. https://example.org/1'
"#,
);
let reading = &c.lectures["L01"].readings[0];
assert!(reading.reference.is_none());
assert_eq!(
reading.text.as_deref(),
Some("KKW §6.1: system and surroundings. https://example.org/1")
);
assert!(c.validate().is_empty());
// Serializing writes the string back as a string, so a load-and-save cycle
// does not migrate a file the author has not chosen to migrate.
let yaml = serde_yaml_ng::to_string(&c).expect("serializes");
assert!(yaml.contains("- 'KKW §6.1: system and surroundings. https://example.org/1'"));
assert!(!yaml.contains("text:"));
}
#[test]
fn readings_resolve_backwards_from_an_objective() {
let c = with_readings();
let found = c.readings_for_objective("lo-a");
assert_eq!(found.len(), 1);
assert_eq!(found[0].0, "L1.1");
assert_eq!(found[0].1.locator.as_deref(), Some("§6.1"));
assert!(c.readings_for_objective("lo-nobody").is_empty());
}
#[test]
fn an_objective_with_no_reading_is_reported() {
let mut c = with_readings();
assert!(c.objectives_without_readings().is_empty());
c.learning_objectives.insert(
"lo-orphan".to_string(),
Objective {
text: "Orphan".to_string(),
unit: None,
lectures: vec!["L1.1".to_string()],
order: Some(3),
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]);
// An objective you teach but do not test is not a gap.
c.learning_objectives
.get_mut("lo-orphan")
.expect("just inserted")
.assessed = false;
assert!(c.objectives_without_readings().is_empty());
}
#[test]
fn objective_order_beats_id_order_within_a_lecture() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L1.1: { title: One }
learning_objectives:
lo-enthalpy: { text: Third, lectures: [L1.1], order: 3 }
lo-first-law: { text: Second, lectures: [L1.1], order: 2 }
lo-system: { text: First, lectures: [L1.1], order: 1 }
"#,
);
// Alphabetically this is enthalpy, first-law, system, which puts an
// objective ahead of its own prerequisite.
assert_eq!(
c.lecture_objectives("L1.1"),
vec!["lo-system", "lo-first-law", "lo-enthalpy"]
);
}
#[test]
fn unknown_references_and_objectives_on_a_reading_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
references:
known: { title: A book }
lectures:
L01:
title: One
readings:
- { ref: missing, locator: '§1' }
- { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] }
"#,
);
let issues = c.validate();
assert!(
issues
.iter()
.any(|i| i.contains("unknown reference `missing`"))
);
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-nope`"))
);
// `path` with no base_url to join it to.
assert!(issues.iter().any(|i| i.contains("base_url")));
}
#[test]
fn a_duplicate_label_and_a_duplicate_locator_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
references:
one: { title: First, label: KKW }
two: { title: Second, label: KKW }
lectures:
L01:
title: One
readings:
- { ref: one, locator: '§1' }
- { ref: one, locator: '§1' }
"#,
);
let issues = c.validate();
assert!(issues.iter().any(|i| i.contains("`KKW` is the label of")));
assert!(
issues
.iter()
.any(|i| i.contains("assigned twice in one lecture"))
);
}
#[test]
fn only_a_declared_objective_id_is_a_placeholder() {
let c = with_readings();
let expanded = c.expand_objective_refs(
r"a worked instance of {lo-a}, where $U_\text{final}$ is unchanged, {lo-typo} too",
|id| format!("<{id}>"),
);
assert_eq!(
expanded,
r"a worked instance of <lo-a>, where $U_\text{final}$ is unchanged, {lo-typo} too"
);
}
}