feat: splitting

This commit is contained in:
2026-09-26 01:15:12 -04:00
parent 220363d4d3
commit eabc98ad31
29 changed files with 4843 additions and 234 deletions
+22 -53
View File
@@ -352,8 +352,10 @@ fn validate_item(
if it.stem.trim().is_empty() {
issues.push("empty stem".into());
}
if it.version == 0 {
issues.push("version must be at least 1".into());
if let Some(replaced) = &it.supersedes {
if *replaced == it.id {
issues.push("supersedes names this item".into());
}
}
// --- options ----
@@ -379,15 +381,22 @@ fn validate_item(
if o.text.trim().is_empty() {
issues.push(format!("option {pos}: empty text"));
}
let letter_ok = o.id.len() == 1
&& o.id
.chars()
.next()
.map(|c| c.is_ascii_uppercase() && c <= 'H')
.unwrap_or(false);
if !letter_ok {
// Two forms are accepted: the 2.0 name, and the letter that preceded
// it. A bank migrates when `coursebank migrate options` is run on it,
// not when the tool is upgraded, so a course mid-migration still loads.
let slug_ok = o.id.strip_prefix("o-").is_some_and(|rest| {
!rest.is_empty()
&& !rest.starts_with('-')
&& !rest.ends_with('-')
&& !rest.contains("--")
&& rest
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
});
if !slug_ok && !Item::is_legacy_option_id(&o.id) {
issues.push(format!(
"option {pos}: id `{}` must be a single letter A through H",
"option {pos}: id `{}` is neither a name such as `o-fourth-line` nor a pre-2.0 \
letter A through H",
o.id
));
}
@@ -514,10 +523,10 @@ fn validate_item(
));
}
}
for letter in c.option_stats.keys() {
if it.option(letter).is_none() {
for option in c.option_stats.keys() {
if it.option(option).is_none() {
issues.push(format!(
"calibration.option_stats has `{letter}`, which is not an option of this item"
"calibration.option_stats has `{option}`, which is not an option of this item"
));
}
}
@@ -533,25 +542,6 @@ fn validate_item(
}
}
// --- history must be coherent ------
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
issues.push(format!(
"history entry {} has version {} which does not increase",
i + 1,
h.version
));
}
last_version = h.version;
}
if !it.history.is_empty() && last_version > it.version {
issues.push(format!(
"history records version {last_version} but the item says version {}",
it.version
));
}
// --- retirement -----
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
@@ -1027,27 +1017,6 @@ learning_targets:
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
r#"
- id: q-a-001
version: 2
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
history:
- { version: 2, date: 2026-01-01, change: second }
- { version: 1, date: 2026-01-02, change: first }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("does not increase")));
}
#[test]
fn level_counts_exclude_drafts_and_bonuses() {
let b = bank(