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
+67
View File
@@ -26,8 +26,75 @@ That gives you:
`build/` and `reports/` are in the generated `.gitignore`. `build/` and `reports/` are in the generated `.gitignore`.
The other four are the repository's content and belong in review. 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. 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 ## Point your editor at the schemas
The schemas are the difference between authoring items and looking up field names. The schemas are the difference between authoring items and looking up field names.
+106 -1
View File
@@ -291,7 +291,12 @@ fn lecture_schema() -> Value {
"date": date("Date delivered."), "date": date("Date delivered."),
"unit": { "type": "string", "description": "Unit id." }, "unit": { "type": "string", "description": "Unit id." },
"slides_url": { "type": "string" }, "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."), "text": text("The objective as a student would read it. Start with a verb."),
"unit": { "type": "string" }, "unit": { "type": "string" },
"lectures": string_array("Lecture ids that cover this."), "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(), "level_ceiling": level(),
"prerequisites": string_array( "prerequisites": string_array(
"Objective ids that must come first. Cycles are rejected." "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. /// The schema for one shared stimulus.
fn stimulus_schema() -> Value { fn stimulus_schema() -> Value {
json!({ json!({
@@ -373,6 +472,12 @@ fn course_schema() -> Value {
"type": "object", "type": "object",
"description": "Shared passages, figures, or data that several items refer to.", "description": "Shared passages, figures, or data that several items refer to.",
"additionalProperties": stimulus_schema() "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::assessment::{Kind as AssessmentKind, Platform};
use coursebank::catalog::Severity; use coursebank::catalog::Severity;
use coursebank::item::IrtModel; use coursebank::item::IrtModel;
use coursebank::lecture::Style as PageStyle;
use coursebank::store; use coursebank::store;
/// Manage course item banks, assessments, and the analysis that comes back. /// Manage course item banks, assessments, and the analysis that comes back.
@@ -55,6 +56,9 @@ pub(crate) enum Command {
Lint(LintArgs), Lint(LintArgs),
/// Summarize the item pool and objective coverage. /// Summarize the item pool and objective coverage.
Catalog(CatalogArgs), Catalog(CatalogArgs),
/// Render a lecture's reading list, or check what backs each objective.
#[command(subcommand)]
Lecture(LectureCommand),
/// Work with item banks. /// Work with item banks.
#[command(subcommand)] #[command(subcommand)]
Bank(BankCommand), Bank(BankCommand),
@@ -123,6 +127,64 @@ pub(crate) struct LintArgs {
} }
/// CLI mirror of [`coursebank::catalog::Severity`]. /// 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)] #[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum SeverityArg { pub(crate) enum SeverityArg {
Low, Low,
+4
View File
@@ -10,6 +10,8 @@
//! //!
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`, //! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
//! `lint`, `catalog`. //! `lint`, `catalog`.
//! - [`lectures`] — render a lecture's reading list and check what backs each
//! objective: `lecture`.
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`, //! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
//! `assemble`, `usage`. //! `assemble`, `usage`.
//! - [`export`] — turn an assessment into deliverables: `export`, `template`. //! - [`export`] — turn an assessment into deliverables: `export`, `template`.
@@ -22,6 +24,7 @@
pub(crate) mod analysis; pub(crate) mod analysis;
pub(crate) mod banks; pub(crate) mod banks;
pub(crate) mod export; pub(crate) mod export;
pub(crate) mod lectures;
pub(crate) mod project; pub(crate) mod project;
use coursebank::error::Result; use coursebank::error::Result;
@@ -56,6 +59,7 @@ pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
Command::Validate => project::validate(cli), Command::Validate => project::validate(cli),
Command::Lint(args) => project::lint(cli, args), Command::Lint(args) => project::lint(cli, args),
Command::Catalog(args) => project::catalog(cli, args), Command::Catalog(args) => project::catalog(cli, args),
Command::Lecture(sub) => lectures::lecture(cli, sub),
Command::Bank(sub) => banks::bank(cli, sub), Command::Bank(sub) => banks::bank(cli, sub),
Command::Assessment(sub) => banks::assessment(cli, sub), Command::Assessment(sub) => banks::assessment(cli, sub),
Command::Assemble(args) => banks::assemble(cli, args), 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. /// Writes the `.gitignore`, merging into one that is already there.
/// ///
/// `init` is often run in a repository that already has a `.gitignore`. /// `init` is often run in a repository that already has a `.gitignore`, and the
/// An existing file keeps everything it had and gains only the patterns /// first version of this overwrote it. An existing file now keeps everything it
/// it was missing, at the top where they are easy to see in the diff. /// 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<()> { fn write_gitignore(path: &Path) -> Result<()> {
let existing = match fs::read_to_string(path) { let existing = match fs::read_to_string(path) {
Ok(text) => text, 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 => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return yaml::write_text(path, GITIGNORE); return yaml::write_text(path, GITIGNORE);
} }
+2
View File
@@ -9,6 +9,7 @@
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas | //! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet | //! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`report`] | Markdown and HTML | students, and yourself | //! | [`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 //! [`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 //! key must be generated from the same permutation that produced its question
@@ -20,6 +21,7 @@
//! answers, other students' data, and any numeric rank. //! answers, other students' data, and any numeric rank.
//! The instructor report answers "what should I fix?" and holds the item statistics. //! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod lecture;
pub mod qti; pub mod qti;
pub mod report; pub mod report;
pub mod typst; 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 analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst}; pub use export::{lecture, qti, report, typst};
pub use catalog::Catalog; pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION}; 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. //! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt;
use std::path::Path; 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::date::Date;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -61,6 +64,12 @@ pub struct CourseFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap<String, Objective>, 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. /// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap<String, Stimulus>, pub stimuli: BTreeMap<String, Stimulus>,
@@ -172,9 +181,318 @@ pub struct Lecture {
/// Where the slides live, for study guidance in student reports. /// Where the slides live, for study guidance in student reports.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub slides_url: Option<String>, 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")] #[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. /// A learning objective.
@@ -190,6 +508,15 @@ pub struct Objective {
/// The lectures that develop it. /// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>, 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 /// 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 /// item above the ceiling is a warning: either the item overreaches or the
/// ceiling needs raising. /// 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 { for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() { if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title")); issues.push(format!("lecture `{id}`: empty title"));
@@ -315,6 +661,10 @@ impl CourseFile {
issues.push(format!("lecture `{id}`: unknown unit `{u}`")); 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 { for (id, lo) in &self.learning_objectives {
@@ -347,6 +697,66 @@ impl CourseFile {
issues 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. /// Detects cycles in the objective prerequisite graph.
/// ///
/// A cycle would make a study-order suggestion loop forever, so it is worth /// 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()) .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 /// # Returns
/// ///
@@ -490,11 +901,148 @@ impl CourseFile {
.as_deref() .as_deref()
.and_then(|u| unit_rank.get(u).copied()) .and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX); .unwrap_or(usize::MAX);
(rank, (*id).clone()) (rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
}); });
ids.into_iter().cloned().collect() 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`. /// A skeleton course file for `coursebank init`.
/// ///
/// # Arguments /// # Arguments
@@ -525,6 +1073,7 @@ impl CourseFile {
text: "Replace this with an objective stated as a student action.".to_string(), text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()), unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()], lectures: vec!["L01".to_string()],
order: Some(1),
level_ceiling: Some(Level::Understand), level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(), prerequisites: Vec::new(),
tags: Vec::new(), tags: Vec::new(),
@@ -549,6 +1098,7 @@ impl CourseFile {
}], }],
lectures, lectures,
learning_objectives: los, learning_objectives: los,
references: BTreeMap::new(),
stimuli: BTreeMap::new(), stimuli: BTreeMap::new(),
} }
} }
@@ -706,4 +1256,215 @@ learning_objectives:
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final"); assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), ""); 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"
);
}
} }