feat: cooked up something fierce
Pipeline / check (pull_request) Successful in 3m9s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-26 18:22:25 -04:00
parent eabc98ad31
commit 5ac1e317c0
26 changed files with 1485 additions and 44 deletions
+55 -10
View File
@@ -403,6 +403,15 @@ fn validate_item(
if seen.contains(&o.id.as_str()) {
issues.push(format!("option {pos}: duplicate option id `{}`", o.id));
}
if let Some(retirement) = &o.retired {
if retirement.reason.trim().is_empty() {
issues.push(format!(
"option {pos}: retired without a reason. The reason is the finding — what \
the option did or failed to do — and it is the only part of a retirement \
that is worth anything later."
));
}
}
seen.push(&o.id);
let credit = o.credit();
@@ -442,14 +451,27 @@ fn validate_item(
}
// --- key ---
// Counted over the pool that can still be drawn: a retired option is a
// record, not an offer.
let (live_keys, live_distractors) = it.pool();
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
if keys.len() != 1 {
issues.push(format!(
"single_best_answer needs exactly one keyed option, has {}",
keys.len()
));
// Several defensible keys is a pool, not a bug — it is what lets you
// test whether "fourth" or "last of the four" is doing the work.
// Exactly one of them reaches a student, and that is the
// placement's business: see
// [`crate::catalog::Catalog::validate_record`].
if live_keys.is_empty() {
issues.push("single_best_answer needs at least one keyed option".into());
}
if live_distractors.is_empty() {
let retired = it.options.iter().any(|o| o.retired.is_some());
issues.push(if retired {
"every distractor is retired, so nothing can be drawn against the key".into()
} else {
"has no option that is not keyed correct, so it asks nothing".to_string()
});
}
}
Format::MultipleResponse => {
@@ -780,20 +802,43 @@ mod tests {
options:
- { id: A, text: a, correct: true }
- { id: B, text: b, correct: true }
- id: q-a-003
status: draft
level: 1
format: single_best_answer
stem: s
options:
- { id: o-key-one, text: a, correct: true }
- { id: o-key-two, text: b, correct: true }
- { id: o-wrong-one, text: c }
- { id: o-wrong-two, text: d }
"#,
);
let issues = b.validate(None);
// No key at all is still a bank problem: nothing can be drawn from it.
assert!(
issues
.iter()
.any(|i| i.contains("exactly one keyed option"))
.any(|i| i.starts_with("q-a-001") && i.contains("at least one keyed option")),
"{issues:?}"
);
assert_eq!(
// Keying every option is still a bank problem, for the older reason: a
// question with nothing to choose against asks nothing.
assert!(
issues
.iter()
.filter(|i| i.contains("exactly one keyed option"))
.count(),
2
.any(|i| i.starts_with("q-a-002") && i.contains("asks nothing")),
"{issues:?}"
);
// Two defensible keys alongside real distractors is not a problem. It
// is the pool doing its job — it is what lets you test whether the
// wording of the key is what students are answering. Exactly one of
// them reaches a student, and that is checked against the placement
// that administers it, in `Catalog::validate_placement`.
assert!(
!issues.iter().any(|i| i.starts_with("q-a-003")
&& (i.contains("keyed option") || i.contains("asks nothing"))),
"{issues:?}"
);
}