fix: handle targets
This commit is contained in:
+12
-22
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user