//! 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; use super::subtitle; /// 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, ) { if file.body.trim().is_empty() { failures.push(Failure::new(&file.path, "corpus body is empty")); return; } 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); subtitle::check_monster(&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); subtitle::check_spell(&source_text, 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); subtitle::check_magic_item(&source_text, 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 | Kind::Animal => monster.map(|fields| KindFields::Monster(Box::new(fields))), Kind::Core | Kind::Class | Kind::Feat | Kind::Glossary | Kind::Equipment | Kind::Species | Kind::Background => 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.path().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.path(), &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.path().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.path(), &KindFields::Other, &mut failures, ); assert_eq!(failures.len(), 1); assert!(failures[0].message.contains("extra")); } #[test] fn check_reports_an_empty_body_even_against_a_trivial_source() { // A blank corpus body against an all-whitespace source would // otherwise pass: both sides normalize to zero words, so a plain // word-stream diff sees no divergence. An empty body must fail // regardless of what the source looks like. 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.path().join(&source), " \n\n \n"); let file = corpus_file(Kind::Core, " \n\n"); let mut failures = Vec::new(); check( &file, &source, layer_root.path(), &KindFields::Other, &mut failures, ); assert_eq!(failures.len(), 1); assert!(failures[0].message.contains("empty")); } #[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.path(), &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.path().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.path(), &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.path().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.path(), &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*Small Beast, Unaligned*\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.path().join(&source), source_text); // `source_text` with its stat-block region cut, frozen as a literal // rather than computed by calling `stat_block::source_outside_region` // itself, so a region-boundary bug in that function shows up as a // fidelity mismatch here instead of shifting the fixture and the // check together. let body = "# Rat\n\n*Small Beast, Unaligned*\n\n## Actions\n\nBite.\n"; 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.path(), &fields, &mut failures); assert_eq!(failures, vec![]); } }