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