fix: handle targets
Pipeline / check (pull_request) Failing after 2m38s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-21 15:24:16 -04:00
parent 8ec48fb185
commit 003340dc6f
3 changed files with 58 additions and 117 deletions
+12 -22
View File
@@ -56,7 +56,7 @@ 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. /// Render a lecture's reading list, or check what backs each target.
#[command(subcommand)] #[command(subcommand)]
Lecture(LectureCommand), Lecture(LectureCommand),
/// Work with item banks. /// Work with item banks.
@@ -146,10 +146,14 @@ pub(crate) enum LectureCommand {
#[arg(long)] #[arg(long)]
out: Option<PathBuf>, out: Option<PathBuf>,
}, },
/// 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. /// One block rather than two: the objective and its targets are the same
/// The performances behind them are `lecture targets`. /// 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 { Objectives {
/// Lecture id, e.g. L1.1. /// Lecture id, e.g. L1.1.
id: String, id: String,
@@ -160,22 +164,7 @@ pub(crate) enum LectureCommand {
#[arg(long)] #[arg(long)]
out: Option<PathBuf>, out: Option<PathBuf>,
}, },
/// Write the learning targets block for one lecture, grouped by objective. /// Show the readings behind each learning target, and which have none.
///
/// 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<PathBuf>,
},
/// Show the readings behind each target, and which targets have none.
Coverage { Coverage {
/// Only this lecture's targets. /// Only this lecture's targets.
#[arg(long)] #[arg(long)]
@@ -221,7 +210,7 @@ impl SeverityArg {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub(crate) struct CatalogArgs { 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)] #[arg(long)]
pub(crate) coverage: bool, pub(crate) coverage: bool,
/// Show topic counts. /// Show topic counts.
@@ -310,7 +299,8 @@ pub(crate) struct AssembleArgs {
/// Bonus items per level, same syntax. /// Bonus items per level, same syntax.
#[arg(long, value_delimiter = ',')] #[arg(long, value_delimiter = ',')]
pub(crate) bonus: Vec<String>, pub(crate) bonus: Vec<String>,
/// 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 = ',')] #[arg(long, value_delimiter = ',')]
pub(crate) require: Vec<String>, pub(crate) require: Vec<String>,
/// Restrict the draw to these lectures. /// Restrict the draw to these lectures.
+1 -5
View File
@@ -10,7 +10,7 @@
use coursebank::course::CourseFile; use coursebank::course::CourseFile;
use coursebank::error::Result; use coursebank::error::Result;
use coursebank::lecture::{objectives_markdown, readings_markdown, targets_markdown}; use coursebank::lecture::{objectives_markdown, readings_markdown};
use coursebank::yaml; use coursebank::yaml;
use crate::cli::{Cli, LectureCommand}; use crate::cli::{Cli, LectureCommand};
@@ -29,10 +29,6 @@ pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
objectives_markdown(&course, id, style.as_style())?, objectives_markdown(&course, id, style.as_style())?,
out.as_deref(), 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), LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
} }
} }
+45 -90
View File
@@ -9,8 +9,8 @@
//! hand. Two copies of the same prose drift within a term; one copy and a build //! hand. Two copies of the same prose drift within a term; one copy and a build
//! step do not. //! step do not.
//! //!
//! Three pages come out of here: the lecture's learning objectives, its learning //! Two pages come out of here: the lecture's learning objectives with their
//! targets grouped under those objectives, and its readings. //! targets enumerated under each, and its readings.
//! //!
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants, //! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
//! with `_(T 4, 7)_` numbering resolved from [`numbered_targets`]. Those numbers //! 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<Level>, Vec
groups 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 /// One page rather than two. The objective and its targets are the same claim
/// for, which is what a student reads before class and what an exam report will /// at two grains, so printing them in separate sections would list every target
/// classify. The performances behind them are [`targets_markdown`]. /// 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 /// On a lecture whose entries are all objectives, the output is what it has
/// always been, grouped by level. /// always been, grouped by level.
@@ -170,66 +184,20 @@ fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option<Level>, Vec
/// ///
/// # Returns /// # Returns
/// ///
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are /// The Markdown, ending in a newline. On an untiered lecture, objectives with
/// grouped last under no heading. /// no `level_ceiling` are grouped last under no heading.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`Error::Unresolved`] when the lecture id is not registered. /// Returns [`Error::Unresolved`] when the lecture id is not registered.
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> { pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
course.lecture(lecture, "lecture page")?; course.lecture(lecture, "lecture page")?;
let ids = course.lecture_objectives(lecture);
let mut out = String::from("## Learning objectives\n\n"); 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"); 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<String> {
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) { 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)) { for (level, members) in by_level(course, &course.lecture_entries(lecture)) {
if let Some(level) = level { if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name())); 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); let (groups, leftovers) = target_layout(course, lecture);
for (objective, targets) in groups { 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 { for target in targets {
out.push_str(&bullet(&course.text_for(target), style)); out.push_str(&bullet(&course.text_for(target), style));
} }
out.push('\n'); 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() { if !leftovers.is_empty() {
for id in leftovers { for id in leftovers {
out.push_str(&bullet(&course.text_for(id), style)); 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")?; let lec = course.lecture(lecture, "lecture page")?;
// Positional numbers for this page, so `{lo-id}` in a note and the trailing // 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 order = numbered_targets(course, lecture);
let number = |id: &str| order.iter().position(|o| o == id).map(|i| i + 1); 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] #[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 md = objectives_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\ let expected = "\
## Learning objectives ## Learning objectives
After this lecture, you should be able to do the following. After this lecture, you should be able to do the following.
(@) Quantify binding. **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.
(@) objective lo-first (@) objective lo-first
(@) objective lo-second (@) objective lo-second
"; ";
assert_eq!(md, expected); 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] #[test]
fn reading_numbers_point_at_targets() { fn reading_numbers_point_at_the_enumerated_targets() {
// Numbering runs over the targets page, because that is the tier a // The numbers on the page and the ones a reading cites come from the
// reading serves. // same function, so they cannot drift apart.
let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders"); let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
assert!(readings.contains("_(T 1, 2)_"), "{readings}"); assert!(readings.contains("_(T 1, 2)_"), "{readings}");
assert!(readings.contains("A worked instance of T 1.")); assert!(readings.contains("A worked instance of T 1."));
} }
#[test] #[test]
fn an_untiered_lecture_renders_the_same_list_on_both_pages() { fn an_untiered_lecture_is_grouped_by_level_as_before() {
// Where no targets are declared, each objective is its own target, and // Where no targets are declared, each objective is its own target and
// neither page silently drops anything. // there is nothing to nest, so the page keeps its level headings.
let c = course(); let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
let objectives = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders"); assert!(md.contains("objective lo-first"), "{md}");
let targets = targets_markdown(&c, "L1.1", Style::Quarto).expect("renders"); assert!(md.contains("objective lo-second"), "{md}");
for text in ["objective lo-first", "objective lo-second"] { assert!(
assert!(objectives.contains(text), "{objectives}"); !md.contains("**"),
assert!(targets.contains(text), "{targets}"); "nothing to bold on an untiered page: {md}"
} );
} }
#[test] #[test]