diff --git a/docs/guide/setup.md b/docs/guide/setup.md index 12782ec..1a99c21 100644 --- a/docs/guide/setup.md +++ b/docs/guide/setup.md @@ -26,8 +26,75 @@ That gives you: `build/` and `reports/` are in the generated `.gitignore`. The other four are the repository's content and belong in review. +If the directory already has a `.gitignore`, `init` keeps it and inserts only the patterns it was missing at the top, so running this inside an existing repository costs you nothing. + Add `--with-examples` if you want a filled-in bank to read rather than an empty directory to stare at. +## Declare your texts once + +Every work the course cites goes in `references`, keyed by the citation key you would use in a `.bib` file. + +```yaml +references: + kuriyan2013molecules: + label: KKW + kind: book + role: required + title: 'The molecules of life: Physical and chemical principles' + authors: ['Kuriyan, John', 'Konforti, Boyana', 'Wemmer, David'] + year: 2013 + publisher: W. W. Norton & Company + base_url: https://library.scient.ing/kuriyan2013molecules/ + note: On reserve at the Bevier Engineering Library. +``` + +`label` is the short form a reading list shows, and it has to name one work, because reports print it instead of the key. +`base_url` is what a reading's `path` is joined to, so the key appears once in the file rather than once per reading. + +## Point readings at objectives + +A reading names a location inside a reference and lists the objectives it serves. + +```yaml +lectures: + L1.1: + title: Enthalpy + readings: + - ref: kuriyan2013molecules + locator: '§1.3' + path: '1/A/#3' + objectives: [lo-water-attenuation, lo-coulomb-estimate] + summary: >- + Ionic interactions: favorable in vacuum, attenuated ~80-fold by water. + focus: >- + The two magnitudes and the factor of 80. + skip: >- + Skip the unit-conversion derivation. +``` + +The three prose fields answer three different questions, and each has a different reader. +`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. + +The mapping lives on the reading rather than on the objective because objectives outlive editions. +When a textbook renumbers its sections, one block of `readings` changes and `learning_objectives` does not. +Going the other way is a scan: `coursebank lecture coverage` lists the readings behind each objective and flags the ones with none. + +Set `order` on each objective if you want a lecture page to number them in teaching order. +The registry is a map, so declaration order is lost on load, and sorting by id would put `lo-enthalpy` ahead of `lo-first-law`. + +A reading written as a plain string, which is what this field held before, still loads and is written back out unchanged. + +## Generate the reading list + +```console +$ coursebank lecture readings L1.1 --out lectures/l1_1-readings.qmd +wrote lectures/l1_1-readings.qmd +``` + +Objective numbers in the generated page (`_(LO 4, 7)_`) are positional, so they are computed at render time rather than written down. +Insert an objective and everything after it renumbers on the next build. + ## Point your editor at the schemas The schemas are the difference between authoring items and looking up field names. diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs index 329ce36..8d4e030 100644 --- a/src/authoring/jsonschema.rs +++ b/src/authoring/jsonschema.rs @@ -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() } } }) diff --git a/src/cli.rs b/src/cli.rs index 26003f7..ac7a66d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + }, + /// 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, + }, + /// Show the readings behind each objective, and which objectives have none. + Coverage { + /// Only this lecture's objectives. + #[arg(long)] + lecture: Option, + }, +} + +#[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, diff --git a/src/commands.rs b/src/commands.rs index e4a7f7d..8b98b7f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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 { 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), diff --git a/src/commands/lectures.rs b/src/commands/lectures.rs new file mode 100644 index 0000000..82f6e64 --- /dev/null +++ b/src/commands/lectures.rs @@ -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 { + 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 { + 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 { + let ids: Vec = 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) +} diff --git a/src/commands/project.rs b/src/commands/project.rs index 6b6317d..3ace208 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -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); } diff --git a/src/export.rs b/src/export.rs index 2a02566..6de4840 100644 --- a/src/export.rs +++ b/src/export.rs @@ -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; diff --git a/src/export/lecture.rs b/src/export/lecture.rs new file mode 100644 index 0000000..cc18850 --- /dev/null +++ b/src/export/lecture.rs @@ -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 `
` 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 { + 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, 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 { + 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, +) -> Result { + // 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 = [&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 = reading + .objectives + .iter() + .filter_map(|o| number(o)) + .collect(); + numbers.sort_unstable(); + if !numbers.is_empty() { + let list: Vec = numbers.iter().map(|n| n.to_string()).collect(); + out.push_str(&format!("
\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. +
+_(LO 1, 2)_ + +### Supplemental + +`KKW` [§1.9](https://example.org/kkw/1/B/#9) +: Background. +
+_(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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c1ff233..4517392 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/model/course.rs b/src/model/course.rs index a648461..2dc2ef2 100644 --- a/src/model/course.rs +++ b/src/model/course.rs @@ -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, + /// 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, + /// Shared stimuli for case-based testlets, keyed by id. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub stimuli: BTreeMap, @@ -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, - /// 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, + pub readings: Vec, +} + +/// 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, + /// 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, + /// Year of publication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + /// Edition as printed: `7th`, `Revised`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edition: Option, + /// Publisher, for a book. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publisher: Option, + /// The journal, edited volume, or series this sits inside. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container: Option, + /// Volume within the container. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volume: Option, + /// Issue within the volume. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issue: Option, + /// Page range of the work as a whole, not of any one reading. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pages: Option, + /// DOI, bare: `10.1038/nature12373`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doi: Option, + /// ISBN, for a book. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isbn: Option, + /// Canonical URL for the work as a whole. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// 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, + /// 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, +} + +/// 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, + /// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`. + pub locator: Option, + /// Appended to the reference's `base_url` to reach this location. + pub path: Option, + /// A full URL, for a location that is not under the reference's `base_url`. + pub url: Option, + /// Whether it is assigned or offered alongside. + pub role: ReadingRole, + /// The objectives this reading serves. + pub objectives: Vec, + /// What the section contains. + pub summary: Option, + /// What to take from it, which is the sentence a study suggestion quotes. + pub focus: Option, + /// What to gloss, and why it is out of scope. + pub skip: Option, + /// A reading written as a bare string before this schema existed, held + /// unparsed. + pub text: Option, +} + +/// 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 { + 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(&self, s: S) -> std::result::Result { + 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: D) -> std::result::Result { + /// 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, + #[serde(default)] + locator: Option, + #[serde(default)] + path: Option, + #[serde(default)] + url: Option, + #[serde(default)] + role: ReadingRole, + #[serde(default)] + objectives: Vec, + #[serde(default)] + summary: Option, + #[serde(default)] + focus: Option, + #[serde(default)] + skip: Option, + #[serde(default)] + text: Option, + } + + 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(self, v: &str) -> std::result::Result { + Ok(Reading { + text: Some(v.to_string()), + ..Reading::default() + }) + } + + fn visit_map>(self, map: M) -> std::result::Result { + 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, + /// 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, /// 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 { + 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 , where $U_\text{final}$ is unchanged, {lo-typo} too" + ); + } }