From f97d8f897d5c2284c6ba4e2f2b8e64be12865581 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 30 Jul 2026 08:10:37 -0400 Subject: [PATCH] Add the fidelity layer for storied srd verify Every corpus file's body must match its vendored source word for word, as a normalized stream that heals a line break but not a changed, dropped, or added word. Monsters carve out their stat-block region (the AC line through the last stat line before the first heading) and check it against frontmatter as a word-accounting problem instead, since that text becomes typed fields, not prose. Spells and magic items get a second check: their frontmatter fields must exactly match the value in the corresponding bold field line of the body, so the two representations of the same fact cannot drift apart. A magic item's source has no bold field lines at all (category, rarity, and attunement are stated in one italic subtitle line); this corpus adds book-style `**Category:**`/`**Rarity:**`/`**Attunement:**` lines as a new convention, and those lines are excluded from the body fidelity check since they restate the subtitle rather than diverge from the source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017jBcx24HfGr66ZAJnYf1fR --- src/srd/verify/diff.rs | 181 ++++++++++++++++ src/srd/verify/fidelity.rs | 390 ++++++++++++++++++++++++++++++++++ src/srd/verify/field_lines.rs | 331 +++++++++++++++++++++++++++++ src/srd/verify/layer.rs | 6 +- src/srd/verify/magic_item.rs | 6 +- src/srd/verify/mod.rs | 86 ++++++-- src/srd/verify/monster.rs | 6 +- src/srd/verify/normalize.rs | 176 +++++++++++++++ src/srd/verify/schema.rs | 7 +- src/srd/verify/stat_block.rs | 346 ++++++++++++++++++++++++++++++ 10 files changed, 1496 insertions(+), 39 deletions(-) create mode 100644 src/srd/verify/diff.rs create mode 100644 src/srd/verify/fidelity.rs create mode 100644 src/srd/verify/field_lines.rs create mode 100644 src/srd/verify/normalize.rs create mode 100644 src/srd/verify/stat_block.rs diff --git a/src/srd/verify/diff.rs b/src/srd/verify/diff.rs new file mode 100644 index 0000000..ccc188a --- /dev/null +++ b/src/srd/verify/diff.rs @@ -0,0 +1,181 @@ +//! Finds the first place two word streams diverge and describes it. +//! +//! The edit script is a Levenshtein alignment (insert, delete, substitute +//! each cost one word) rather than a longest-common-subsequence alignment, +//! because a single changed word should read as one substitution, not as a +//! drop immediately followed by an unrelated add. + +const CONTEXT_WORDS: usize = 3; + +enum Op { + Equal(String), + Delete(String), + Insert(String), + Substitute(String, String), +} + +/// What kind of divergence an `Op` describes, or `None` for `Op::Equal`. +/// Splitting this out of `first_divergence` means every arm, including +/// the equal case, runs during the scan for the first mismatch, rather +/// than only at one already-known-non-equal index. +fn template(op: &Op) -> Option { + match op { + Op::Equal(_) => None, + Op::Delete(word) => Some(format!( + "source has \"{word}\" but the corpus body drops it" + )), + Op::Insert(word) => Some(format!( + "corpus body adds \"{word}\", which the source does not have" + )), + Op::Substitute(source_word, corpus_word) => Some(format!( + "source has \"{source_word}\" but the corpus body has \"{corpus_word}\"" + )), + } +} + +/// Describes the first divergence between `source` and `corpus`, or `None` +/// if the two word streams are identical. +pub fn first_divergence(source: &[String], corpus: &[String]) -> Option { + let ops = edit_script(source, corpus); + let (index, message) = ops + .iter() + .enumerate() + .find_map(|(i, op)| template(op).map(|message| (i, message)))?; + let before = context_before(&ops, index); + let after = context_after(&ops, index); + let context = format!("{before} ... {after}").trim().to_string(); + Some(format!("{message} (context: {context})")) +} + +/// Aligns `source` against `corpus` word by word, minimizing the number of +/// single-word inserts, deletes, and substitutions. +fn edit_script(source: &[String], corpus: &[String]) -> Vec { + let (n, m) = (source.len(), corpus.len()); + let mut distance = vec![vec![0u32; m + 1]; n + 1]; + for (i, row) in distance.iter_mut().enumerate() { + row[0] = i as u32; + } + for (j, cell) in distance[0].iter_mut().enumerate() { + *cell = j as u32; + } + for i in 1..=n { + for j in 1..=m { + distance[i][j] = if source[i - 1] == corpus[j - 1] { + distance[i - 1][j - 1] + } else { + 1 + distance[i - 1][j - 1] + .min(distance[i - 1][j]) + .min(distance[i][j - 1]) + }; + } + } + + let mut ops = Vec::new(); + let (mut i, mut j) = (n, m); + while i > 0 || j > 0 { + if i > 0 && j > 0 && source[i - 1] == corpus[j - 1] { + ops.push(Op::Equal(source[i - 1].clone())); + i -= 1; + j -= 1; + } else if i > 0 && j > 0 && distance[i][j] == distance[i - 1][j - 1] + 1 { + ops.push(Op::Substitute(source[i - 1].clone(), corpus[j - 1].clone())); + i -= 1; + j -= 1; + } else if i > 0 && distance[i][j] == distance[i - 1][j] + 1 { + ops.push(Op::Delete(source[i - 1].clone())); + i -= 1; + } else { + ops.push(Op::Insert(corpus[j - 1].clone())); + j -= 1; + } + } + ops.reverse(); + ops +} + +/// The last few equal words before `ops[index]`, in reading order. +fn context_before(ops: &[Op], index: usize) -> String { + let mut words: Vec<&str> = ops[..index] + .iter() + .rev() + .filter_map(equal_word) + .take(CONTEXT_WORDS) + .collect(); + words.reverse(); + words.join(" ") +} + +/// The next few equal words after `ops[index]`, in reading order. +fn context_after(ops: &[Op], index: usize) -> String { + ops[index + 1..] + .iter() + .filter_map(equal_word) + .take(CONTEXT_WORDS) + .collect::>() + .join(" ") +} + +fn equal_word(op: &Op) -> Option<&str> { + match op { + Op::Equal(word) => Some(word.as_str()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn words(text: &str) -> Vec { + text.split_whitespace().map(str::to_string).collect() + } + + #[test] + fn context_skips_a_second_nearby_divergence() { + let message = first_divergence(&words("a b c d e f g"), &words("a b X d Y f g")).unwrap(); + assert!(message.contains("context: a b ... d f g")); + } + + #[test] + fn identical_streams_have_no_divergence() { + assert_eq!(first_divergence(&words("a b c"), &words("a b c")), None); + } + + #[test] + fn reports_a_dropped_word() { + let message = first_divergence( + &words("the low roar into the fire"), + &words("the low into the fire"), + ) + .unwrap(); + assert!(message.contains("drops it")); + assert!(message.contains("\"roar\"")); + assert!(message.contains("the low ... into the fire")); + } + + #[test] + fn reports_an_added_word() { + let message = first_divergence( + &words("the low roar into"), + &words("the low roar loud into"), + ) + .unwrap(); + assert!(message.contains("adds")); + assert!(message.contains("\"loud\"")); + } + + #[test] + fn reports_a_changed_word() { + let message = + first_divergence(&words("a low roar into"), &words("a low roars into")).unwrap(); + assert!(message.contains("\"roar\"")); + assert!(message.contains("\"roars\"")); + } + + #[test] + fn context_is_short_at_the_start_of_the_stream() { + let message = + first_divergence(&words("roar into fire"), &words("roars into fire")).unwrap(); + assert!(message.contains("context: ... into fire")); + } +} diff --git a/src/srd/verify/fidelity.rs b/src/srd/verify/fidelity.rs new file mode 100644 index 0000000..e501c09 --- /dev/null +++ b/src/srd/verify/fidelity.rs @@ -0,0 +1,390 @@ +//! Compares a corpus entry's body to its vendored source as normalized +//! word streams, plus each kind's fidelity carve-out. + +use std::fs; +use std::path::Path; + +use super::Failure; +use super::diff; +use super::field_lines; +use super::kind::Kind; +use super::layer::CorpusFile; +use super::magic_item::MagicItemFields; +use super::monster::MonsterFields; +use super::normalize; +use super::spell::SpellFields; +use super::stat_block; + +/// What a kind's schema validation produced, so `check` can run the right +/// carve-out without re-deriving it from the raw frontmatter. +pub enum KindFields { + Spell(SpellFields), + MagicItem(MagicItemFields), + Monster(Box), + Other, +} + +/// Checks `file`'s fidelity against its vendored source at +/// `layer_root.join(source)`, appending any problems to `failures`. +pub fn check( + file: &CorpusFile, + source: &Path, + layer_root: &Path, + fields: &KindFields, + failures: &mut Vec, +) { + let source_path = layer_root.join(source); + let source_text = match fs::read_to_string(&source_path) { + Ok(text) => text, + Err(error) => { + failures.push(Failure::new( + &file.path, + format!("cannot read source '{}': {error}", source_path.display()), + )); + return; + } + }; + + match fields { + KindFields::Monster(monster_fields) => { + check_body( + &stat_block::source_outside_region(&source_text), + &file.body, + &file.path, + failures, + ); + stat_block::check_accounting(&source_text, monster_fields, &file.path, failures); + } + KindFields::Spell(spell_fields) => { + check_body(&source_text, &file.body, &file.path, failures); + field_lines::check_spell(&file.body, spell_fields, &file.path, failures); + } + KindFields::MagicItem(item_fields) => { + let stripped_body = field_lines::strip_magic_item_field_lines(&file.body); + check_body(&source_text, &stripped_body, &file.path, failures); + field_lines::check_magic_item(&file.body, item_fields, &file.path, failures); + } + KindFields::Other => { + check_body(&source_text, &file.body, &file.path, failures); + } + } +} + +fn check_body(source_text: &str, corpus_body: &str, path: &Path, failures: &mut Vec) { + let source_words = normalize::words(source_text); + let corpus_words = normalize::words(corpus_body); + if let Some(message) = diff::first_divergence(&source_words, &corpus_words) { + failures.push(Failure::new(path, message)); + } +} + +/// The fields kind-specific schema validation produced for `file`'s kind, +/// or `Other` for kinds with no extra schema beyond the universal keys. +pub fn kind_fields( + kind: Kind, + spell: Option, + item: Option, + monster: Option, +) -> Option { + match kind { + Kind::Spell => spell.map(KindFields::Spell), + Kind::MagicItem => item.map(KindFields::MagicItem), + Kind::Monster => monster.map(|fields| KindFields::Monster(Box::new(fields))), + Kind::Core | Kind::Class | Kind::Feat => Some(KindFields::Other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + use std::path::PathBuf; + + fn corpus_file(kind: Kind, body: &str) -> CorpusFile { + CorpusFile { + path: PathBuf::from("core/playing-the-game.md"), + kind, + frontmatter: serde_yaml_ng::Value::Null, + body: body.to_string(), + } + } + + #[test] + fn check_passes_for_an_identical_body() { + let layer_root = unique_temp_dir(); + let source = PathBuf::from("sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"); + write_file( + &layer_root.join(&source), + "# Playing the Game\n\nRules text.\n", + ); + let file = corpus_file(Kind::Core, "# Playing the Game\n\nRules text.\n"); + let mut failures = Vec::new(); + + check( + &file, + &source, + &layer_root, + &KindFields::Other, + &mut failures, + ); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_reports_a_dropped_word() { + let layer_root = unique_temp_dir(); + let source = PathBuf::from("sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"); + write_file( + &layer_root.join(&source), + "# Playing the Game\n\nRules text extra here.\n", + ); + let file = corpus_file(Kind::Core, "# Playing the Game\n\nRules text here.\n"); + let mut failures = Vec::new(); + + check( + &file, + &source, + &layer_root, + &KindFields::Other, + &mut failures, + ); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("extra")); + } + + #[test] + fn check_reports_an_unreadable_source() { + let layer_root = unique_temp_dir(); + let source = PathBuf::from("sources/dnd.srd.5.2.1/missing.md"); + let file = corpus_file(Kind::Core, "body"); + let mut failures = Vec::new(); + + check( + &file, + &source, + &layer_root, + &KindFields::Other, + &mut failures, + ); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("cannot read source")); + } + + #[test] + fn kind_fields_maps_spell_when_present() { + let fields = kind_fields( + Kind::Spell, + Some(SpellFields { + level: 3, + school: "evocation".to_string(), + classes: vec!["wizard".to_string()], + casting_time: "Action".to_string(), + range: "150 feet".to_string(), + components: "V".to_string(), + duration: "Instantaneous".to_string(), + }), + None, + None, + ); + assert!(matches!(fields, Some(KindFields::Spell(_)))); + } + + #[test] + fn kind_fields_is_none_for_spell_when_absent() { + assert!(kind_fields(Kind::Spell, None, None, None).is_none()); + } + + #[test] + fn kind_fields_maps_magic_item_when_present() { + let fields = kind_fields( + Kind::MagicItem, + None, + Some(MagicItemFields { + category: "Ring".to_string(), + rarity: "rare".to_string(), + attunement: false, + attunement_note: None, + }), + None, + ); + assert!(matches!(fields, Some(KindFields::MagicItem(_)))); + } + + #[test] + fn kind_fields_maps_monster_when_present() { + let fields = kind_fields( + Kind::Monster, + None, + None, + Some(MonsterFields { + size: "Small".to_string(), + creature_type: "Fey".to_string(), + alignment: "Neutral".to_string(), + ac: "10".to_string(), + hp: "1".to_string(), + speed: "5 ft.".to_string(), + abilities: [ + ( + "str", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ( + "dex", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ( + "con", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ( + "int", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ( + "wis", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ( + "cha", + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + }, + ), + ], + cr: "0".to_string(), + skills: None, + senses: None, + languages: None, + resistances: None, + immunities: None, + vulnerabilities: None, + gear: None, + }), + ); + assert!(matches!(fields, Some(KindFields::Monster(_)))); + } + + #[test] + fn kind_fields_is_other_for_core() { + assert!(matches!( + kind_fields(Kind::Core, None, None, None), + Some(KindFields::Other) + )); + } + + #[test] + fn check_runs_the_spell_field_line_carveout() { + let layer_root = unique_temp_dir(); + let source = PathBuf::from("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let source_text = "# Fireball\n\n**Casting Time:** Action\n"; + write_file(&layer_root.join(&source), source_text); + let file = corpus_file(Kind::Spell, source_text); + let fields = KindFields::Spell(SpellFields { + level: 3, + school: "evocation".to_string(), + classes: vec!["wizard".to_string()], + casting_time: "Bonus Action".to_string(), + range: "Self".to_string(), + components: "V".to_string(), + duration: "Instantaneous".to_string(), + }); + let mut failures = Vec::new(); + + check(&file, &source, &layer_root, &fields, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("Bonus Action"))); + } + + #[test] + fn check_runs_the_magic_item_field_line_carveout() { + let layer_root = unique_temp_dir(); + let source = + PathBuf::from("sources/dnd.srd.5.2.1/10_Magic_Items/Magic_Items_Each/Amulet.md"); + let source_text = "# Amulet\n\n*Wondrous Item, Rare*\n"; + write_file(&layer_root.join(&source), source_text); + let body = "# Amulet\n\n*Wondrous Item, Rare*\n\n**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** None\n"; + let file = corpus_file(Kind::MagicItem, body); + let fields = KindFields::MagicItem(MagicItemFields { + category: "Wondrous Item".to_string(), + rarity: "rare".to_string(), + attunement: false, + attunement_note: None, + }); + let mut failures = Vec::new(); + + check(&file, &source, &layer_root, &fields, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_runs_the_monster_stat_region_carveout() { + let layer_root = unique_temp_dir(); + let source = PathBuf::from("sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Rat.md"); + let source_text = "# Rat\n\n**AC** 10\n\n**HP** 1 (1d4)\n\n**Speed** 5 ft.\n\n| | MOD | SAVE | | MOD | SAVE | | MOD | SAVE |\n| :- | :- | :- | :- | :- | :- | :- | :- | :- |\n| **Str 1** | +0 | +0 | **Dex 1** | +0 | +0 | **Con 1** | +0 | +0 |\n| **Int 1** | +0 | +0 | **Wis 1** | +0 | +0 | **Cha 1** | +0 | +0 |\n\n**CR** 0\n\n## Actions\n\nBite.\n"; + write_file(&layer_root.join(&source), source_text); + let body = stat_block::source_outside_region(source_text); + let file = corpus_file(Kind::Monster, &body); + fn ability() -> crate::srd::verify::monster::Ability { + crate::srd::verify::monster::Ability { + score: 1, + modifier: 0, + save: 0, + } + } + let fields = KindFields::Monster(Box::new(MonsterFields { + size: "Small".to_string(), + creature_type: "Beast".to_string(), + alignment: "Unaligned".to_string(), + ac: "10".to_string(), + hp: "1 (1d4)".to_string(), + speed: "5 ft.".to_string(), + abilities: [ + ("str", ability()), + ("dex", ability()), + ("con", ability()), + ("int", ability()), + ("wis", ability()), + ("cha", ability()), + ], + cr: "0".to_string(), + skills: None, + senses: None, + languages: None, + resistances: None, + immunities: None, + vulnerabilities: None, + gear: None, + })); + let mut failures = Vec::new(); + + check(&file, &source, &layer_root, &fields, &mut failures); + + assert_eq!(failures, vec![]); + } +} diff --git a/src/srd/verify/field_lines.rs b/src/srd/verify/field_lines.rs new file mode 100644 index 0000000..6b1af2b --- /dev/null +++ b/src/srd/verify/field_lines.rs @@ -0,0 +1,331 @@ +//! Checks a spell or magic item's mechanical frontmatter fields against +//! their book-style bold field lines in the corpus body +//! (`**Casting Time:** Action`), so the two representations of the same +//! fact cannot drift apart. +//! +//! A spell's field lines are copied verbatim from the vendored source, so +//! they are already part of the ordinary body word-stream check; nothing +//! about them needs special handling there. A magic item's field lines +//! are not: the source states category, rarity, and attunement in one +//! italic subtitle line with no bold labels at all, so this corpus adds +//! `**Category:**`, `**Rarity:**`, and `**Attunement:**` lines that +//! restate the subtitle in structured form. Restating it does not +//! introduce new source content, so those three lines are cut before the +//! body word-stream check runs, the same way a monster's stat-block +//! region is cut from the source side; the subtitle line itself stays in +//! the body verbatim and is still checked against the source normally. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; + +use super::Failure; +use super::magic_item::MagicItemFields; +use super::spell::SpellFields; + +static FIELD_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^\*\*([^*:]+):\*\*\s*(.*)$").unwrap()); + +static MAGIC_ITEM_FIELD_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^\*\*(?:Category|Rarity|Attunement):\*\*.*\n?").unwrap()); + +/// Removes the `**Category:**`, `**Rarity:**`, and `**Attunement:**` +/// lines this corpus adds to a magic item's body, so the remaining text +/// is exactly what the source has. +pub fn strip_magic_item_field_lines(body: &str) -> String { + MAGIC_ITEM_FIELD_LINE.replace_all(body, "").to_string() +} + +/// The corpus body's `**Rarity:**` field line value for `rarity`: title +/// case with spaces instead of dashes (`very-rare` becomes `Very Rare`). +fn body_rarity(rarity: &str) -> String { + rarity + .split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +/// The corpus body's `**Attunement:**` field line value for `attunement` +/// and `attunement_note`. +fn body_attunement(attunement: bool, note: Option<&str>) -> String { + match (attunement, note) { + (false, _) => "None".to_string(), + (true, None) => "Requires Attunement".to_string(), + (true, Some(note)) => format!("Requires Attunement {note}"), + } +} + +/// Every `**Label:** value` line in `body`, keyed by label. +fn field_lines(body: &str) -> HashMap { + FIELD_LINE + .captures_iter(body) + .map(|captures| { + ( + captures[1].trim().to_string(), + captures[2].trim().to_string(), + ) + }) + .collect() +} + +/// Checks that `label`'s frontmatter-derived `expected` value exactly +/// matches its body field line, appending a failure if the line is +/// missing or its value differs. +fn check_field( + lines: &HashMap, + label: &str, + expected: &str, + path: &Path, + failures: &mut Vec, +) { + match lines.get(label) { + None => failures.push(Failure::new( + path, + format!("corpus body has no '**{label}:**' field line"), + )), + Some(actual) if actual != expected => failures.push(Failure::new( + path, + format!("frontmatter expects '**{label}:** {expected}' but the body has '{actual}'"), + )), + Some(_) => {} + } +} + +/// Checks a spell's `casting_time`, `range`, `components`, and `duration` +/// against their body field lines. +pub fn check_spell(body: &str, fields: &SpellFields, path: &Path, failures: &mut Vec) { + let lines = field_lines(body); + check_field(&lines, "Casting Time", &fields.casting_time, path, failures); + check_field(&lines, "Range", &fields.range, path, failures); + check_field(&lines, "Components", &fields.components, path, failures); + check_field(&lines, "Duration", &fields.duration, path, failures); +} + +/// Checks a magic item's `category`, `rarity`, and `attunement` against +/// their body field lines. `rarity: varies` is skipped: the source shows +/// a per-variant rarity list or the phrase "Rarity Varies" depending on +/// the item, with no single literal rendering to check against. +pub fn check_magic_item( + body: &str, + fields: &MagicItemFields, + path: &Path, + failures: &mut Vec, +) { + let lines = field_lines(body); + check_field(&lines, "Category", &fields.category, path, failures); + if fields.rarity != "varies" { + check_field( + &lines, + "Rarity", + &body_rarity(&fields.rarity), + path, + failures, + ); + } + let expected_attunement = body_attunement(fields.attunement, fields.attunement_note.as_deref()); + check_field(&lines, "Attunement", &expected_attunement, path, failures); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn strip_magic_item_field_lines_removes_only_the_three_added_labels() { + let body = "*Wondrous Item, Rare (Requires Attunement)*\n\n\ +**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** Requires Attunement\n\n\ +Prose that mentions **bold** text stays.\n"; + + let stripped = strip_magic_item_field_lines(body); + + assert!(stripped.contains("Wondrous Item, Rare (Requires Attunement)")); + assert!(stripped.contains("Prose that mentions **bold** text stays.")); + assert!(!stripped.contains("**Category:**")); + assert!(!stripped.contains("**Rarity:**")); + assert!(!stripped.contains("**Attunement:**")); + } + + fn spell_fields() -> SpellFields { + SpellFields { + level: 3, + school: "evocation".to_string(), + classes: vec!["sorcerer".to_string(), "wizard".to_string()], + casting_time: "Action".to_string(), + range: "150 feet".to_string(), + components: "V, S, M".to_string(), + duration: "Instantaneous".to_string(), + } + } + + fn item_fields() -> MagicItemFields { + MagicItemFields { + category: "Wondrous Item".to_string(), + rarity: "rare".to_string(), + attunement: true, + attunement_note: None, + } + } + + #[test] + fn field_lines_reads_every_bold_labeled_line() { + let body = "**Casting Time:** Action\n\n**Range:** 150 feet\n"; + let lines = field_lines(body); + assert_eq!(lines.get("Casting Time"), Some(&"Action".to_string())); + assert_eq!(lines.get("Range"), Some(&"150 feet".to_string())); + } + + #[test] + fn check_spell_passes_when_every_field_matches() { + let body = "**Casting Time:** Action\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n"; + let mut failures = Vec::new(); + + check_spell( + body, + &spell_fields(), + &PathBuf::from("spells/fireball.md"), + &mut failures, + ); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_spell_reports_a_missing_field_line() { + let body = + "**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n"; + let mut failures = Vec::new(); + + check_spell( + body, + &spell_fields(), + &PathBuf::from("spells/fireball.md"), + &mut failures, + ); + + assert!( + failures + .iter() + .any(|f| f.message.contains("no '**Casting Time:**'")) + ); + } + + #[test] + fn check_spell_reports_a_frontmatter_body_drift() { + let body = "**Casting Time:** Bonus Action\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n"; + let mut failures = Vec::new(); + + check_spell( + body, + &spell_fields(), + &PathBuf::from("spells/fireball.md"), + &mut failures, + ); + + assert!(failures.iter().any(|f| f.message.contains("Bonus Action"))); + } + + #[test] + fn check_magic_item_passes_when_every_field_matches() { + let body = "**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** Requires Attunement\n"; + let mut failures = Vec::new(); + + check_magic_item( + body, + &item_fields(), + &PathBuf::from("magic-items/amulet.md"), + &mut failures, + ); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_magic_item_skips_the_rarity_field_line_for_varies() { + let mut fields = item_fields(); + fields.rarity = "varies".to_string(); + let body = "**Category:** Wondrous Item\n\n**Attunement:** Requires Attunement\n"; + let mut failures = Vec::new(); + + check_magic_item( + body, + &fields, + &PathBuf::from("magic-items/bag.md"), + &mut failures, + ); + + assert_eq!(failures, vec![]); + } + + #[test] + fn check_magic_item_reports_a_rarity_drift() { + let body = "**Category:** Wondrous Item\n\n**Rarity:** Legendary\n\n**Attunement:** Requires Attunement\n"; + let mut failures = Vec::new(); + + check_magic_item( + body, + &item_fields(), + &PathBuf::from("magic-items/amulet.md"), + &mut failures, + ); + + assert!(failures.iter().any(|f| f.message.contains("Legendary"))); + } + + #[test] + fn check_magic_item_reports_an_attunement_drift() { + let body = "**Category:** Wondrous Item\n\n**Rarity:** Rare\n\n**Attunement:** None\n"; + let mut failures = Vec::new(); + + check_magic_item( + body, + &item_fields(), + &PathBuf::from("magic-items/amulet.md"), + &mut failures, + ); + + assert!(failures.iter().any(|f| f.message.contains("Attunement"))); + } + + #[test] + fn body_rarity_titles_a_simple_rarity() { + assert_eq!(body_rarity("rare"), "Rare"); + } + + #[test] + fn body_rarity_skips_an_empty_word_from_a_leading_dash() { + assert_eq!(body_rarity("-rare"), " Rare"); + } + + #[test] + fn body_rarity_splits_a_dashed_rarity_into_two_words() { + assert_eq!(body_rarity("very-rare"), "Very Rare"); + } + + #[test] + fn body_attunement_renders_false_as_none() { + assert_eq!(body_attunement(false, None), "None"); + } + + #[test] + fn body_attunement_renders_true_without_a_note() { + assert_eq!(body_attunement(true, None), "Requires Attunement"); + } + + #[test] + fn body_attunement_renders_true_with_a_note() { + assert_eq!( + body_attunement(true, Some("by a Druid")), + "Requires Attunement by a Druid" + ); + } +} diff --git a/src/srd/verify/layer.rs b/src/srd/verify/layer.rs index f845b54..f27a42d 100644 --- a/src/srd/verify/layer.rs +++ b/src/srd/verify/layer.rs @@ -10,10 +10,8 @@ use super::Failure; use super::kind::Kind; /// One parsed corpus entry: its frontmatter, still as a generic YAML value -/// so each kind's schema can validate it, and its body markdown. `body` -/// is not read yet in the schema layer; the fidelity layer compares it -/// to the vendored source. -#[allow(dead_code)] +/// so each kind's schema can validate it, and its body markdown. The +/// fidelity layer compares `body` to the vendored source. pub struct CorpusFile { pub path: PathBuf, pub kind: Kind, diff --git a/src/srd/verify/magic_item.rs b/src/srd/verify/magic_item.rs index a4c22ea..3366f73 100644 --- a/src/srd/verify/magic_item.rs +++ b/src/srd/verify/magic_item.rs @@ -21,10 +21,8 @@ const RARITIES: [&str; 7] = [ "varies", ]; -/// A magic item's mechanical frontmatter, validated. Not read again yet -/// in the schema layer; the fidelity layer's field-line drift check -/// reads these values. -#[allow(dead_code)] +/// A magic item's mechanical frontmatter, validated. The fidelity +/// layer's field-line drift check reads these values. pub struct MagicItemFields { pub category: String, pub rarity: String, diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index dd3dc30..1b4340f 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -3,21 +3,28 @@ //! corpus format contract: whatever it accepts is legal, whatever it //! rejects is not. //! -//! This is the schema layer: every corpus file parses, its universal and -//! per-kind frontmatter fields are well-formed, and its slug is -//! deterministic and collision-free. Fidelity against the vendored -//! source, coverage of the vendored tree, and shape (entry-count floors, -//! README, meta.yaml) are separate layers built on top of this one. - +//! This adds the fidelity layer to the schema layer: every corpus file's +//! body matches its vendored source as a normalized word stream (or, for +//! monsters, matches outside the stat-block region, with the region +//! checked against frontmatter instead), and spells and magic items keep +//! their frontmatter and body field lines from drifting apart. Coverage +//! of the vendored tree and corpus shape (entry-count floors, README, +//! meta.yaml) are a separate layer built on top of this one. + +mod diff; +mod fidelity; +mod field_lines; #[cfg(test)] mod fixtures; mod kind; mod layer; mod magic_item; mod monster; +mod normalize; mod schema; mod slug; mod spell; +mod stat_block; mod values; use std::collections::HashMap; @@ -73,10 +80,10 @@ impl Report { } } -/// Verifies the corpus layer rooted at `layer_root`'s schema: every file -/// parses, its frontmatter matches its kind, and its slug is well-formed -/// and collision-free. Returns every failure found rather than stopping -/// at the first. +/// Verifies the corpus layer rooted at `layer_root`'s schema and fidelity: +/// every file parses, its frontmatter matches its kind, its slug is +/// well-formed and collision-free, and its body matches its vendored +/// source. Returns every failure found rather than stopping at the first. pub fn verify(layer_root: &Path) -> Report { let (files, mut failures) = layer::discover(layer_root); @@ -91,17 +98,26 @@ pub fn verify(layer_root: &Path) -> Report { }; names.push((file.path.clone(), universal.name.clone())); - match file.kind { - Kind::Spell => { - spell::validate(file, &mut failures); - } - Kind::MagicItem => { - magic_item::validate(file, &mut failures); - } - Kind::Monster => { - monster::validate(file, &mut failures); - } - Kind::Core | Kind::Class | Kind::Feat => {} + let spell_fields = (file.kind == Kind::Spell) + .then(|| spell::validate(file, &mut failures)) + .flatten(); + let item_fields = (file.kind == Kind::MagicItem) + .then(|| magic_item::validate(file, &mut failures)) + .flatten(); + let monster_fields = (file.kind == Kind::Monster) + .then(|| monster::validate(file, &mut failures)) + .flatten(); + + if let Some(kind_fields) = + fidelity::kind_fields(file.kind, spell_fields, item_fields, monster_fields) + { + fidelity::check( + file, + &universal.source, + layer_root, + &kind_fields, + &mut failures, + ); } } @@ -216,10 +232,12 @@ mod tests { fn verify_requires_nothing_extra_for_core_class_and_feat() { let layer_root = unique_temp_dir(); let source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; - write_file(&layer_root.join(source), "content"); + write_file(&layer_root.join(source), "# Alert\n\nBody text.\n"); write_file( &layer_root.join("feats/alert.md"), - &format!("---\nname: Alert\ntype: feat\nsource: {source}\n---\nbody\n"), + &format!( + "---\nname: Alert\ntype: feat\nsource: {source}\n---\n# Alert\n\nBody text.\n" + ), ); let report = verify(&layer_root); @@ -227,6 +245,28 @@ mod tests { assert_eq!(report.failures, vec![]); } + #[test] + fn verify_reports_a_fidelity_mismatch() { + let layer_root = unique_temp_dir(); + let source = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; + write_file(&layer_root.join(source), "# Alert\n\nSource text.\n"); + write_file( + &layer_root.join("feats/alert.md"), + &format!( + "---\nname: Alert\ntype: feat\nsource: {source}\n---\n# Alert\n\nDifferent text.\n" + ), + ); + + let report = verify(&layer_root); + + assert!( + report + .failures + .iter() + .any(|f| f.message.contains("Source") || f.message.contains("Different")) + ); + } + #[test] fn verify_reports_a_slug_collision_across_files() { let layer_root = unique_temp_dir(); diff --git a/src/srd/verify/monster.rs b/src/srd/verify/monster.rs index 9aec096..92a1310 100644 --- a/src/srd/verify/monster.rs +++ b/src/srd/verify/monster.rs @@ -14,10 +14,8 @@ use super::values; const ABILITY_KEYS: [&str; 6] = ["str", "dex", "con", "int", "wis", "cha"]; -/// One ability's score, modifier, and save. Not read again yet in the -/// schema layer; the fidelity layer's stat-block accounting check reads -/// these values. -#[allow(dead_code)] +/// One ability's score, modifier, and save. The fidelity layer's +/// stat-block accounting check reads these values. pub struct Ability { pub score: i64, pub modifier: i64, diff --git a/src/srd/verify/normalize.rs b/src/srd/verify/normalize.rs new file mode 100644 index 0000000..93eacfa --- /dev/null +++ b/src/srd/verify/normalize.rs @@ -0,0 +1,176 @@ +//! Turns markdown text into the word stream fidelity checks compare. +//! +//! A word stream drops everything markdown uses for structure (headings, +//! emphasis, table pipes and alignment rows, list bullets, horizontal +//! rules) and keeps everything else, so a healed line break disappears +//! but a changed, dropped, or added word does not. Structural characters +//! become spaces rather than being deleted outright, because the vendored +//! source sometimes runs a value straight into the next bold label with no +//! space (`9**Languages**`); deleting the markers would fuse the two words +//! into one and hide the boundary that whitespace-splitting depends on. + +use std::sync::LazyLock; + +use regex::Regex; + +static HORIZONTAL_RULE: LazyLock = + LazyLock::new(|| Regex::new(r"^(-(\s*-){2,}|\*(\s*\*){2,}|_(\s*_){2,})$").unwrap()); + +static LEADING_BULLET: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:[-*+]|\d+\.)\s+").unwrap()); + +static HEADING_MARKER: LazyLock = LazyLock::new(|| Regex::new(r"^#{1,6}\s+").unwrap()); + +/// Splits `text` into its normalized word stream. +pub fn words(text: &str) -> Vec { + let mut buffer = String::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_horizontal_rule(trimmed) || is_table_rule(trimmed) { + continue; + } + let without_heading = HEADING_MARKER.replace(line, ""); + let without_bullet = LEADING_BULLET.replace(&without_heading, ""); + for ch in without_bullet.chars() { + buffer.push(normalize_char(ch)); + } + buffer.push(' '); + } + buffer.split_whitespace().map(str::to_string).collect() +} + +/// Reports whether `trimmed` is a thematic break: three or more of the same +/// character among `-`, `*`, or `_`, optionally spaced. +fn is_horizontal_rule(trimmed: &str) -> bool { + HORIZONTAL_RULE.is_match(trimmed) +} + +/// Reports whether `trimmed` is a markdown table alignment row: it has at +/// least one pipe, and nothing left over once pipes, colons, dashes, and +/// whitespace are removed. +fn is_table_rule(trimmed: &str) -> bool { + trimmed.contains('|') + && trimmed + .chars() + .all(|ch| matches!(ch, '|' | ':' | '-' | ' ' | '\t')) +} + +/// Maps one character to its normalized form: markdown structural +/// punctuation becomes a space, unicode dashes and quotes become their +/// ASCII equivalents, and everything else passes through unchanged. +fn normalize_char(ch: char) -> char { + match ch { + '#' | '*' | '_' | '|' => ' ', + '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2212}' => '-', + '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'', + '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"', + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_plain_prose_into_words() { + assert_eq!( + words("A bright streak flashes."), + vec!["A", "bright", "streak", "flashes."] + ); + } + + #[test] + fn heals_a_line_break_inside_a_sentence() { + let broken = "A bright streak flashes from you to a point you choose within range and then blossoms with a low roar into a fiery explosion. Each creature in a\n\n20-foot-radius Sphere"; + let healed = "A bright streak flashes from you to a point you choose within range and then blossoms with a low roar into a fiery explosion. Each creature in a 20-foot-radius Sphere"; + assert_eq!(words(broken), words(healed)); + } + + #[test] + fn strips_heading_markers() { + assert_eq!(words("## Actions"), vec!["Actions"]); + } + + #[test] + fn strips_emphasis_markers_without_fusing_adjacent_words() { + assert_eq!( + words("***Scimitar.*** *Melee Attack Roll:* +4"), + vec!["Scimitar.", "Melee", "Attack", "Roll:", "+4"] + ); + } + + #[test] + fn treats_a_label_glued_to_the_previous_value_as_two_words() { + assert_eq!( + words("Passive Perception 9**Languages** Common, Goblin"), + vec![ + "Passive", + "Perception", + "9", + "Languages", + "Common,", + "Goblin" + ] + ); + } + + #[test] + fn drops_a_table_alignment_row() { + assert_eq!(words("| :--------- | :--- | :--- |\nnext"), vec!["next"]); + } + + #[test] + fn keeps_a_table_data_row_as_space_separated_cells() { + assert_eq!( + words("| **Str 8** | −1 | −1 |"), + vec!["Str", "8", "-1", "-1"] + ); + } + + #[test] + fn drops_a_dash_horizontal_rule() { + assert_eq!(words("above\n\n---\n\nbelow"), vec!["above", "below"]); + } + + #[test] + fn drops_a_spaced_horizontal_rule() { + assert_eq!(words("above\n\n- - -\n\nbelow"), vec!["above", "below"]); + } + + #[test] + fn strips_an_unordered_list_bullet() { + assert_eq!(words("- First item"), vec!["First", "item"]); + } + + #[test] + fn strips_an_ordered_list_bullet() { + assert_eq!(words("1. First item"), vec!["First", "item"]); + } + + #[test] + fn normalizes_unicode_minus_to_ascii() { + assert_eq!(words("\u{2212}1"), vec!["-1"]); + } + + #[test] + fn normalizes_unicode_dashes_to_ascii() { + assert_eq!( + words("en\u{2013}dash em\u{2014}dash"), + vec!["en-dash", "em-dash"] + ); + } + + #[test] + fn normalizes_unicode_quotes_to_ascii() { + assert_eq!( + words("\u{2018}quoted\u{2019} and \u{201C}double\u{201D}"), + vec!["'quoted'", "and", "\"double\""] + ); + } + + #[test] + fn ignores_blank_lines() { + assert_eq!(words("first\n\n\nsecond"), vec!["first", "second"]); + } +} diff --git a/src/srd/verify/schema.rs b/src/srd/verify/schema.rs index 889aee4..d6c600b 100644 --- a/src/srd/verify/schema.rs +++ b/src/srd/verify/schema.rs @@ -58,10 +58,9 @@ pub fn allowed_keys(kind: Kind) -> Vec<&'static str> { UNIVERSAL_KEYS.iter().chain(extra).copied().collect() } -/// The universal fields every corpus file must have, validated. `source` -/// is not read yet in the schema layer; the fidelity and coverage layers -/// use it to find the vendored file a corpus entry claims. -#[allow(dead_code)] +/// The universal fields every corpus file must have, validated. The +/// fidelity and coverage layers use `source` to find the vendored file a +/// corpus entry claims. pub struct Universal { pub name: String, pub source: PathBuf, diff --git a/src/srd/verify/stat_block.rs b/src/srd/verify/stat_block.rs new file mode 100644 index 0000000..5dae6bd --- /dev/null +++ b/src/srd/verify/stat_block.rs @@ -0,0 +1,346 @@ +//! Extracts a monster's stat-block region from its vendored source, and +//! checks that region against frontmatter word for word. +//! +//! The vendored monster files are consistent across the corpus: a stat +//! block always opens with a `**AC**` line and always ends at the first +//! `##` heading (`Traits`, `Actions`, `Bonus Actions`, or `Legendary +//! Actions`, whichever comes first). Inside that span, the source packs +//! fields tightly enough that a value sometimes runs straight into the +//! next bold label with no space (`Passive Perception 9**Languages**`); +//! `normalize::words` treats bold markers as word boundaries for exactly +//! this reason. +//! +//! One field in the region has no frontmatter home: `**Initiative**` is +//! tactical information this corpus does not track, so its label and +//! value are cut from the region before the word-accounting check runs, +//! rather than being reported as unaccounted every time. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; + +use super::Failure; +use super::monster::MonsterFields; +use super::normalize; + +/// Labels inside the region that describe structure, not a value: the +/// `**AC**`/`**HP**`/etc. field names, the ability table's `MOD`/`SAVE` +/// column headers, and the ability abbreviations themselves. None of +/// these words has a frontmatter value of its own to match against. +const REGION_LABELS: [&str; 19] = [ + "AC", + "HP", + "Speed", + "MOD", + "SAVE", + "Str", + "Dex", + "Con", + "Int", + "Wis", + "Cha", + "Skills", + "Senses", + "Languages", + "Resistances", + "Immunities", + "Vulnerabilities", + "Gear", + "CR", +]; + +static INITIATIVE_CLAUSE: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*Initiative\*\*[^*]*").unwrap()); + +/// The byte span of `source`'s stat-block region: from the start of its +/// `**AC**` line through the end of the line before the first `##` +/// heading. `None` if the source has no `**AC**` line at all. +pub fn region_span(source: &str) -> Option<(usize, usize)> { + let mut offset = 0; + let mut start = None; + for line in source.split_inclusive('\n') { + let trimmed = line.trim_end_matches('\n').trim_start(); + if let Some(start) = start { + if trimmed.starts_with("## ") { + return Some((start, offset)); + } + } else if trimmed.starts_with("**AC**") { + start = Some(offset); + } + offset += line.len(); + } + start.map(|start| (start, source.len())) +} + +/// `source` with its stat-block region cut out, for the ordinary body +/// word-stream check. +pub fn source_outside_region(source: &str) -> String { + match region_span(source) { + Some((start, end)) => format!("{}{}", &source[..start], &source[end..]), + None => source.to_string(), + } +} + +/// Checks that every word of the source's stat-block region is accounted +/// for by `fields`' mechanical values, and every mechanical value's words +/// appear in the region, appending any mismatch to `failures`. +pub fn check_accounting( + source: &str, + fields: &MonsterFields, + path: &Path, + failures: &mut Vec, +) { + let Some((start, end)) = region_span(source) else { + failures.push(Failure::new( + path, + "source has no **AC** line to start a stat block", + )); + return; + }; + let region_raw = &source[start..end]; + let without_initiative = INITIATIVE_CLAUSE.replace_all(region_raw, ""); + let region_words: Vec = normalize::words(&without_initiative) + .into_iter() + .filter(|word| !REGION_LABELS.contains(&word.as_str())) + .collect(); + let frontmatter_words = normalize::words(&mechanical_text(fields)); + + let region_counts = word_counts(®ion_words); + let frontmatter_counts = word_counts(&frontmatter_words); + + let unaccounted = shortfall_words(®ion_counts, &frontmatter_counts); + if !unaccounted.is_empty() { + failures.push(Failure::new( + path, + format!( + "stat block has words the frontmatter does not account for: {}", + unaccounted.join(", ") + ), + )); + } + let unmatched = shortfall_words(&frontmatter_counts, ®ion_counts); + if !unmatched.is_empty() { + failures.push(Failure::new( + path, + format!( + "frontmatter has values the stat block does not contain: {}", + unmatched.join(", ") + ), + )); + } +} + +/// Every mechanical value's text, in field order, for the stat-block +/// word-accounting check. +fn mechanical_text(fields: &MonsterFields) -> String { + let mut parts = vec![fields.ac.clone(), fields.hp.clone(), fields.speed.clone()]; + for (_, ability) in &fields.abilities { + parts.push(ability.score.to_string()); + parts.push(signed(ability.modifier)); + parts.push(signed(ability.save)); + } + parts.push(fields.cr.clone()); + for value in [ + &fields.skills, + &fields.senses, + &fields.languages, + &fields.resistances, + &fields.immunities, + &fields.vulnerabilities, + &fields.gear, + ] + .into_iter() + .flatten() + { + parts.push(value.clone()); + } + parts.join(" ") +} + +/// Renders a modifier or save with its sign always shown, matching the +/// stat block (`+0`, `+2`, `-1`). +fn signed(value: i64) -> String { + if value >= 0 { + format!("+{value}") + } else { + value.to_string() + } +} + +fn word_counts(words: &[String]) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for word in words { + *counts.entry(word.as_str()).or_insert(0) += 1; + } + counts +} + +/// Every word in `left` whose count exceeds its count in `right`, quoted +/// and sorted for a deterministic message. +fn shortfall_words(left: &HashMap<&str, usize>, right: &HashMap<&str, usize>) -> Vec { + let mut words: Vec = left + .iter() + .filter(|(word, count)| right.get(*word).copied().unwrap_or(0) < **count) + .map(|(word, _)| format!("\"{word}\"")) + .collect(); + words.sort(); + words +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::srd::verify::monster::Ability; + use std::path::PathBuf; + + /// Modeled on the vendored Goblin Warrior source: a smashed + /// Gear/Senses/Languages run with no space between labels. + const GOBLIN: &str = "# Goblin Warrior\n\n\ +*Small Fey (Goblinoid), Chaotic Neutral*\n\n\ +**AC** 15 **Initiative** +2 (12)\n\n\ +**HP** 10 (3d6)\n\n\ +**Speed** 30 ft.\n\n\ +| | MOD | SAVE | | MOD | SAVE | | MOD | SAVE |\n\ +| :- | :- | :- | :- | :- | :- | :- | :- | :- |\n\ +| **Str 8** | \u{2212}1 | \u{2212}1 | **Dex 15** | +2 | +2 | **Con 10** | +0 | +0 |\n\ +| **Int 10** | +0 | +0 | **Wis 8** | \u{2212}1 | \u{2212}1 | **Cha 8** | \u{2212}1 | \u{2212}1 |\n\n\ +**Skills** Stealth +6\n\n\ +**Gear** Leather Armor, Scimitar, Shield, Shortbow **Senses** Darkvision 60 ft.; Passive Perception 9**Languages** Common, Goblin\n\n\ +**CR** 1/4 (XP 50; PB +2)\n\n\ +## Actions\n\n\ +***Scimitar.*** *Melee Attack Roll:* +4, reach 5 ft.\n"; + + fn ability(score: i64, modifier: i64, save: i64) -> Ability { + Ability { + score, + modifier, + save, + } + } + + fn goblin_fields() -> MonsterFields { + MonsterFields { + size: "Small".to_string(), + creature_type: "Fey (Goblinoid)".to_string(), + alignment: "Chaotic Neutral".to_string(), + ac: "15".to_string(), + hp: "10 (3d6)".to_string(), + speed: "30 ft.".to_string(), + abilities: [ + ("str", ability(8, -1, -1)), + ("dex", ability(15, 2, 2)), + ("con", ability(10, 0, 0)), + ("int", ability(10, 0, 0)), + ("wis", ability(8, -1, -1)), + ("cha", ability(8, -1, -1)), + ], + cr: "1/4 (XP 50; PB +2)".to_string(), + skills: Some("Stealth +6".to_string()), + senses: Some("Darkvision 60 ft.; Passive Perception 9".to_string()), + languages: Some("Common, Goblin".to_string()), + resistances: None, + immunities: None, + vulnerabilities: None, + gear: Some("Leather Armor, Scimitar, Shield, Shortbow".to_string()), + } + } + + #[test] + fn region_span_starts_at_ac_and_ends_before_the_first_heading() { + let (start, end) = region_span(GOBLIN).unwrap(); + assert!(GOBLIN[start..].starts_with("**AC**")); + assert!(GOBLIN[..end].ends_with("PB +2)\n\n")); + } + + #[test] + fn region_span_is_none_without_an_ac_line() { + assert_eq!(region_span("# No stats here\n\nJust prose.\n"), None); + } + + #[test] + fn region_span_runs_to_the_end_of_the_source_without_a_closing_heading() { + let source = "# Truncated\n\n**AC** 10\n\n**HP** 1\n"; + assert_eq!(region_span(source), Some((13, source.len()))); + } + + #[test] + fn source_outside_region_keeps_the_flavor_line_and_actions() { + let outside = source_outside_region(GOBLIN); + assert!(outside.contains("Small Fey")); + assert!(outside.contains("Scimitar.")); + assert!(!outside.contains("Stealth")); + } + + #[test] + fn source_outside_region_returns_the_whole_source_without_an_ac_line() { + let source = "# No stats\n\nJust prose.\n"; + assert_eq!(source_outside_region(source), source); + } + + #[test] + fn check_accounting_passes_for_matching_fields() { + let path = PathBuf::from("monsters/goblin-warrior.md"); + let mut failures = Vec::new(); + + check_accounting(GOBLIN, &goblin_fields(), &path, &mut failures); + + assert_eq!(failures, vec![]); + } + + #[test] + fn mechanical_text_joins_required_and_present_optional_fields() { + let text = mechanical_text(&goblin_fields()); + + assert!(text.contains("15")); + assert!(text.contains("10 (3d6)")); + assert!(text.contains("+2")); + assert!(text.contains("Stealth +6")); + assert!(text.contains("Leather Armor, Scimitar, Shield, Shortbow")); + } + + #[test] + fn signed_shows_a_plus_for_zero_and_positive_values() { + assert_eq!(signed(0), "+0"); + assert_eq!(signed(2), "+2"); + assert_eq!(signed(-1), "-1"); + } + + #[test] + fn check_accounting_reports_a_stat_region_word_missing_from_frontmatter() { + let path = PathBuf::from("monsters/goblin-warrior.md"); + let mut fields = goblin_fields(); + fields.skills = None; + let mut failures = Vec::new(); + + check_accounting(GOBLIN, &fields, &path, &mut failures); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("Stealth")); + assert!(failures[0].message.contains("stat block has")); + } + + #[test] + fn check_accounting_reports_a_frontmatter_value_missing_from_the_region() { + let path = PathBuf::from("monsters/goblin-warrior.md"); + let mut fields = goblin_fields(); + fields.cr = "1/4 (XP 999; PB +2)".to_string(); + let mut failures = Vec::new(); + + check_accounting(GOBLIN, &fields, &path, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("999"))); + } + + #[test] + fn check_accounting_reports_missing_ac_line() { + let path = PathBuf::from("monsters/no-stats.md"); + let mut failures = Vec::new(); + + check_accounting("# No stats\n", &goblin_fields(), &path, &mut failures); + + assert_eq!(failures.len(), 1); + assert!(failures[0].message.contains("no **AC** line")); + } +} -- 2.51.2