feat: add learning objective targets
This commit is contained in:
+98
-15
@@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::SCHEMA_VERSION;
|
||||
use crate::course::{CourseFile, SCHEMA_VERSION};
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::history::History;
|
||||
@@ -106,10 +106,20 @@ pub fn select(
|
||||
// to become unsatisfiable once the level quotas are full.
|
||||
for (objective, needed) in &blueprint.objective_minimums {
|
||||
let mut have = 0;
|
||||
// A requirement written against an objective is satisfied by items on
|
||||
// any of its targets, which is the only way a coverage requirement stays
|
||||
// writable: a blueprint that had to name each target separately would be
|
||||
// as long as the registry, and would need editing every time an
|
||||
// objective gained one.
|
||||
let mut candidates: Vec<&crate::catalog::Entry> = eligible
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
|
||||
.filter(|e| {
|
||||
e.item
|
||||
.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
|
||||
})
|
||||
.filter(|e| !e.item.bonus)
|
||||
.collect();
|
||||
rank(&mut candidates, history, seed, "objective");
|
||||
@@ -465,7 +475,7 @@ pub fn to_record(
|
||||
bonus: is_bonus || e.item.bonus,
|
||||
key: e.item.key_letters(),
|
||||
level: Some(e.item.level),
|
||||
learning_objectives: e.item.learning_objectives.clone(),
|
||||
learning_targets: e.item.learning_targets.clone(),
|
||||
credit_overrides: BTreeMap::new(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
@@ -585,11 +595,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `record` - the assessment record.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to. An
|
||||
/// objective minimum is satisfied by items on any of that objective's
|
||||
/// targets, the same way [`select`] fills it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per discrepancy, empty when the form matches the design.
|
||||
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
|
||||
let Some(bp) = &record.blueprint else {
|
||||
return vec!["the record carries no blueprint to check against".into()];
|
||||
};
|
||||
@@ -609,7 +622,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
let got = record
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
|
||||
.filter(|p| {
|
||||
p.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == objective || course.objective_for(t) == objective)
|
||||
})
|
||||
.count();
|
||||
if got < *needed {
|
||||
out.push(format!(
|
||||
@@ -620,7 +637,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The set of objectives an assessment covers.
|
||||
/// The set of learning targets an assessment covers, as tagged.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -628,12 +645,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The objective ids, deduplicated.
|
||||
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
|
||||
/// The target ids, deduplicated.
|
||||
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
|
||||
record
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|p| p.learning_objectives.iter().cloned())
|
||||
.flat_map(|p| p.learning_targets.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The objectives an assessment covers, through the targets it measured.
|
||||
///
|
||||
/// The list a coverage claim should be made from: "this exam covered eleven of
|
||||
/// the course's thirty-two objectives" is a sentence about the blueprint, while
|
||||
/// the same count over targets is a sentence about how finely the course happens
|
||||
/// to be subdivided.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `record` - the assessment record.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The objective ids, deduplicated.
|
||||
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
|
||||
record
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|p| p.learning_targets.iter())
|
||||
.map(|t| course.objective_for(t).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -690,12 +731,14 @@ blueprint:
|
||||
level_counts: { 1: 2, 3: 1 }
|
||||
objective_minimums: { lo-key: 2 }
|
||||
items:
|
||||
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
|
||||
- { number: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
|
||||
- { number: 2, item: "b::q-2", level: 1 }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let issues = check_blueprint(&record);
|
||||
let course: CourseFile =
|
||||
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
|
||||
let issues = check_blueprint(&record, &course);
|
||||
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
|
||||
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
|
||||
// Level 1 matches, so it must not be reported.
|
||||
@@ -703,17 +746,57 @@ items:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_objectives_deduplicates() {
|
||||
fn an_objective_minimum_is_met_by_items_on_its_targets() {
|
||||
// The blueprint names the objective a report will classify; the items
|
||||
// are tagged with the specific performances they measure.
|
||||
let record: AssessmentFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
assessment: { id: x, title: X }
|
||||
blueprint:
|
||||
objective_minimums: { lo-binding: 2 }
|
||||
items:
|
||||
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
|
||||
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let course: CourseFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
course: { code: C, title: T, term: M }
|
||||
learning_objectives:
|
||||
lo-binding: { text: Quantify binding. }
|
||||
learning_targets:
|
||||
t-kd: { text: Write the expression., objective: lo-binding }
|
||||
t-plot: { text: Read a plot., objective: lo-binding }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let issues = check_blueprint(&record, &course);
|
||||
assert!(
|
||||
!issues.iter().any(|i| i.contains("lo-binding")),
|
||||
"two items on its targets satisfy it: {issues:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
covered_objectives(&record, &course)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["lo-binding"],
|
||||
"coverage is claimed at the tier the blueprint is written in"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_targets_deduplicates() {
|
||||
let record: AssessmentFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
assessment: { id: x, title: X }
|
||||
items:
|
||||
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
|
||||
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
|
||||
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
|
||||
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let set = covered_objectives(&record);
|
||||
let set = covered_targets(&record);
|
||||
assert_eq!(set.len(), 2);
|
||||
assert!(set.contains("lo-a"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user