From 003340dc6fab5899a192199486f9f303eb94a762 Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Mon, 21 Sep 2026 15:24:16 -0400 Subject: [PATCH] fix: handle targets --- src/cli.rs | 34 ++++------ src/commands/lectures.rs | 6 +- src/export/lecture.rs | 135 +++++++++++++-------------------------- 3 files changed, 58 insertions(+), 117 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index a01ee2c..ab56996 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,7 +56,7 @@ 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. + /// Render a lecture's reading list, or check what backs each target. #[command(subcommand)] Lecture(LectureCommand), /// Work with item banks. @@ -146,10 +146,14 @@ pub(crate) enum LectureCommand { #[arg(long)] out: Option, }, - /// Write the learning objectives block for one lecture, grouped by level. + /// Write the learning objectives block for one lecture, with each + /// objective's learning targets enumerated beneath it. /// - /// The short page: the handful of claims the lecture is accountable for. - /// The performances behind them are `lecture targets`. + /// One block rather than two: the objective and its targets are the same + /// claim at two grains, and separate sections would print every target + /// twice. The numbering comes from the same place as the `_(T 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, @@ -160,22 +164,7 @@ pub(crate) enum LectureCommand { #[arg(long)] out: Option, }, - /// Write the learning targets block for one lecture, grouped by objective. - /// - /// The numbering comes from the same place as the `_(T 4, 7)_` lists in - /// `readings`, so generating one and hand-writing the other is what this - /// exists to prevent. - Targets { - /// Lecture id, e.g. L1.1. - id: String, - /// Which flavour of Markdown to write. - #[arg(long, value_enum, default_value = "quarto")] - style: StyleArg, - /// Output path; prints to stdout when omitted. - #[arg(long)] - out: Option, - }, - /// Show the readings behind each target, and which targets have none. + /// Show the readings behind each learning target, and which have none. Coverage { /// Only this lecture's targets. #[arg(long)] @@ -221,7 +210,7 @@ impl SeverityArg { #[derive(Debug, Args)] pub(crate) struct CatalogArgs { - /// Show per-objective coverage and the gaps in it. + /// Show coverage per objective and per target, and the gaps in it. #[arg(long)] pub(crate) coverage: bool, /// Show topic counts. @@ -310,7 +299,8 @@ pub(crate) struct AssembleArgs { /// Bonus items per level, same syntax. #[arg(long, value_delimiter = ',')] pub(crate) bonus: Vec, - /// Minimum items per objective, e.g. --require lo-kinetics=2. + /// Minimum items per objective, e.g. --require lo-kinetics=2. Satisfied by + /// items on any of that objective's targets. #[arg(long, value_delimiter = ',')] pub(crate) require: Vec, /// Restrict the draw to these lectures. diff --git a/src/commands/lectures.rs b/src/commands/lectures.rs index 61f6e15..9c67b05 100644 --- a/src/commands/lectures.rs +++ b/src/commands/lectures.rs @@ -10,7 +10,7 @@ use coursebank::course::CourseFile; use coursebank::error::Result; -use coursebank::lecture::{objectives_markdown, readings_markdown, targets_markdown}; +use coursebank::lecture::{objectives_markdown, readings_markdown}; use coursebank::yaml; use crate::cli::{Cli, LectureCommand}; @@ -29,10 +29,6 @@ pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result { objectives_markdown(&course, id, style.as_style())?, out.as_deref(), ), - LectureCommand::Targets { id, style, out } => emit( - targets_markdown(&course, id, style.as_style())?, - out.as_deref(), - ), LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet), } } diff --git a/src/export/lecture.rs b/src/export/lecture.rs index 4158c67..79e728a 100644 --- a/src/export/lecture.rs +++ b/src/export/lecture.rs @@ -9,8 +9,8 @@ //! hand. Two copies of the same prose drift within a term; one copy and a build //! step do not. //! -//! Three pages come out of here: the lecture's learning objectives, its learning -//! targets grouped under those objectives, and its readings. +//! Two pages come out of here: the lecture's learning objectives with their +//! targets enumerated under each, and its readings. //! //! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants, //! with `_(T 4, 7)_` numbering resolved from [`numbered_targets`]. Those numbers @@ -153,11 +153,25 @@ fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option, Vec groups } -/// Renders the learning objectives for one lecture. +/// Renders the learning objectives for one lecture, with each objective's +/// targets enumerated beneath it. /// -/// This is the short page: the four to eight claims the lecture is accountable -/// for, which is what a student reads before class and what an exam report will -/// classify. The performances behind them are [`targets_markdown`]. +/// One page rather than two. The objective and its targets are the same claim +/// at two grains, so printing them in separate sections would list every target +/// twice and leave a reader matching them up by hand. +/// +/// The objective is set in bold rather than as a heading, and the targets are +/// numbered: the numbers are what a reading's `_(T 4, 7)_` points at, and they +/// run continuously down the page rather than restarting under each objective, +/// because a reading cites a target without caring which objective it serves. +/// Pandoc's `(@)` example lists continue numbering across the paragraphs +/// between them, which is why the objective lines can sit in the middle of the +/// sequence without breaking it. +/// +/// An objective with no targets of its own is printed as a numbered line +/// instead, since it stands as its own target and a reading may cite it. +/// Bolding it and then repeating it as its only target is the duplication this +/// layout exists to avoid. /// /// On a lecture whose entries are all objectives, the output is what it has /// always been, grouped by level. @@ -170,66 +184,20 @@ fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option, Vec /// /// # Returns /// -/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are -/// grouped last under no heading. +/// The Markdown, ending in a newline. On an untiered lecture, objectives with +/// no `level_ceiling` are grouped last under no heading. /// /// # Errors /// /// Returns [`Error::Unresolved`] when the lecture id is not registered. pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result { course.lecture(lecture, "lecture page")?; - let ids = course.lecture_objectives(lecture); let mut out = String::from("## Learning objectives\n\n"); out.push_str("After this lecture, you should be able to do the following.\n\n"); - for (level, members) in by_level(course, &ids) { - if let Some(level) = level { - out.push_str(&format!("### {}\n\n", level.name())); - } - for id in members { - out.push_str(&bullet(&course.text_for(id), style)); - } - out.push('\n'); - } - Ok(out) -} - -/// Renders the learning targets for one lecture, grouped under their objectives. -/// -/// The long page: every performance an item on this material could be written -/// against. Grouping by objective rather than by level is deliberate, because -/// the objective is the grouping the student is being taught and the one a -/// report will use; level headings would cut across it and scatter one -/// objective's targets over three places. -/// -/// The numbers here are the ones a reading's `_(T 4, 7)_` refers to, and both -/// come from [`numbered_targets`], so they cannot disagree. -/// -/// # Arguments -/// -/// * `course` - the loaded course file. -/// * `lecture` - the lecture id. -/// * `style` - which flavour to emit. -/// -/// # Returns -/// -/// The Markdown, ending in a newline. On a lecture with no targets declared, -/// the objectives stand as their own targets and the page is grouped by level -/// instead. -/// -/// # Errors -/// -/// Returns [`Error::Unresolved`] when the lecture id is not registered. -pub fn targets_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result { - course.lecture(lecture, "lecture page")?; - - let mut out = String::from("## Learning targets\n\n"); - out.push_str( - "Each objective above is met by the specific things below. Exam questions are \ - written against these.\n\n", - ); if !is_tiered(course, lecture) { + // Levels in taxonomy order, then whatever declares no ceiling. for (level, members) in by_level(course, &course.lecture_entries(lecture)) { if let Some(level) = level { out.push_str(&format!("### {}\n\n", level.name())); @@ -244,12 +212,14 @@ pub fn targets_markdown(course: &CourseFile, lecture: &str, style: Style) -> Res let (groups, leftovers) = target_layout(course, lecture); for (objective, targets) in groups { - out.push_str(&format!("### {}\n\n", course.text_for(objective))); + out.push_str(&format!("**{}**\n\n", course.text_for(objective))); for target in targets { out.push_str(&bullet(&course.text_for(target), style)); } out.push('\n'); } + // Targets whose objective this lecture does not teach, and objectives with + // no targets, in the order `numbered_targets` expects them. if !leftovers.is_empty() { for id in leftovers { out.push_str(&bullet(&course.text_for(id), style)); @@ -297,7 +267,7 @@ pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Re let lec = course.lecture(lecture, "lecture page")?; // Positional numbers for this page, so `{lo-id}` in a note and the trailing - // `_(T ...)_` agree with the targets page printed alongside. + // `_(T ...)_` agree with the enumerated targets printed above them. let order = numbered_targets(course, lecture); let number = |id: &str| order.iter().position(|o| o == id).map(|i| i + 1); @@ -593,60 +563,45 @@ After this lecture, you should be able to do the following. } #[test] - fn the_objectives_page_lists_claims_and_not_the_targets_under_them() { + fn the_page_bolds_each_objective_and_enumerates_its_targets() { let md = objectives_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders"); let expected = "\ ## Learning objectives After this lecture, you should be able to do the following. -(@) Quantify binding. - -"; - assert_eq!( - md, expected, - "the page is the short list, not all forty rows" - ); - } - - #[test] - fn the_targets_page_groups_targets_under_their_objective() { - let md = targets_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders"); - let expected = "\ -## Learning targets - -Each objective above is met by the specific things below. Exam questions are \ -written against these. - -### Quantify binding. +**Quantify binding.** (@) objective lo-first (@) objective lo-second "; assert_eq!(md, expected); + // Each target appears once on the page, which is the point of merging + // the two sections. + assert_eq!(md.matches("objective lo-first").count(), 1); } #[test] - fn reading_numbers_point_at_targets() { - // Numbering runs over the targets page, because that is the tier a - // reading serves. + fn reading_numbers_point_at_the_enumerated_targets() { + // The numbers on the page and the ones a reading cites come from the + // same function, so they cannot drift apart. let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders"); assert!(readings.contains("_(T 1, 2)_"), "{readings}"); assert!(readings.contains("A worked instance of T 1.")); } #[test] - fn an_untiered_lecture_renders_the_same_list_on_both_pages() { - // Where no targets are declared, each objective is its own target, and - // neither page silently drops anything. - let c = course(); - let objectives = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders"); - let targets = targets_markdown(&c, "L1.1", Style::Quarto).expect("renders"); - for text in ["objective lo-first", "objective lo-second"] { - assert!(objectives.contains(text), "{objectives}"); - assert!(targets.contains(text), "{targets}"); - } + fn an_untiered_lecture_is_grouped_by_level_as_before() { + // Where no targets are declared, each objective is its own target and + // there is nothing to nest, so the page keeps its level headings. + let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders"); + assert!(md.contains("objective lo-first"), "{md}"); + assert!(md.contains("objective lo-second"), "{md}"); + assert!( + !md.contains("**"), + "nothing to bold on an untiered page: {md}" + ); } #[test]