diff --git a/src/srd/verify/coverage.rs b/src/srd/verify/coverage.rs --- a/src/srd/verify/coverage.rs +++ b/src/srd/verify/coverage.rs @@ -13,6 +13,12 @@ const VENDORED_ROOT: &str = "sources/dnd.srd.5.2.1"; /// The minimum number of entries each kind's directory must hold, so an /// empty or half-built corpus fails loudly instead of quietly passing. +/// Coverage (every `*_Each/` file claimed exactly once) already makes +/// each kind's real count exact by construction, so these floors are not +/// trying to predict that exact count — they only need to catch "empty +/// or nearly so." The five newest floors are set well under the vendored +/// file counts discovered on disk at the pinned commit: glossary 154, +/// equipment 14, species 9, background 4, animal 95. fn floor(kind: Kind) -> usize { match kind { Kind::Spell => 300, @@ -21,6 +27,11 @@ Kind::MagicItem => 200, Kind::Class => 12, Kind::Feat => 15, Kind::Core => 5, + Kind::Glossary => 100, + Kind::Equipment => 10, + Kind::Species => 6, + Kind::Background => 3, + Kind::Animal => 60, } } @@ -236,6 +247,11 @@ (Kind::MagicItem, 200), (Kind::Class, 12), (Kind::Feat, 15), (Kind::Core, 5), + (Kind::Glossary, 100), + (Kind::Equipment, 10), + (Kind::Species, 6), + (Kind::Background, 3), + (Kind::Animal, 60), ]); let mut failures = Vec::new(); diff --git a/src/srd/verify/fidelity.rs b/src/srd/verify/fidelity.rs --- a/src/srd/verify/fidelity.rs +++ b/src/srd/verify/fidelity.rs @@ -98,8 +98,14 @@ ) -> 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), + 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), } } diff --git a/src/srd/verify/fixtures.rs b/src/srd/verify/fixtures.rs --- a/src/srd/verify/fixtures.rs +++ b/src/srd/verify/fixtures.rs @@ -38,6 +38,14 @@ pub const BARD_SOURCE: &str = "sources/dnd.srd.5.2.1/03_Character_Classes/Character_Classes_Each/Bard.md"; pub const ALERT_SOURCE: &str = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/Alert.md"; pub const CORE_SOURCE: &str = "sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"; +pub const GLOSSARY_SOURCE: &str = + "sources/dnd.srd.5.2.1/08_Rules_Glossary/Rules_Glossary_Each/Advantage.md"; +pub const EQUIPMENT_SOURCE: &str = "sources/dnd.srd.5.2.1/06_Equipment/Equipment_Each/Armor.md"; +pub const SPECIES_SOURCE: &str = + "sources/dnd.srd.5.2.1/04_Character_Origins/Species/Species_Each/Elf.md"; +pub const BACKGROUND_SOURCE: &str = + "sources/dnd.srd.5.2.1/04_Character_Origins/Backgrounds/Backgrounds_Each/Acolyte.md"; +pub const ANIMAL_SOURCE: &str = "sources/dnd.srd.5.2.1/12_Animals/Animals_Each/Wolf.md"; const FIREBALL_BODY: &str = "# Fireball\n\n*Level 3 Evocation (Sorcerer, Wizard)*\n\n**Casting Time:** Action\n\n**Range:** 150 feet\n\n**Components:** V, S, M\n\n**Duration:** Instantaneous\n\nA bright streak flashes from you.\n"; @@ -50,6 +58,12 @@ const BARD_BODY: &str = "# Bard\n\nBards are charming performers.\n"; const ALERT_BODY: &str = "# Alert\n\nYou gain the following benefits.\n"; const CORE_BODY: &str = "# Playing the Game\n\nThe three pillars of play are exploration, social interaction, and combat.\n"; +const GLOSSARY_BODY: &str = "# Advantage\n\nAdvantage represents a favorable situation.\n"; +const EQUIPMENT_BODY: &str = "# Armor\n\nArmor comes in three categories.\n"; +const SPECIES_BODY: &str = "# Elf\n\nElves are a magical people.\n"; +const BACKGROUND_BODY: &str = "# Acolyte\n\nYou spent your life in service to a temple.\n"; + +const ANIMAL_SOURCE_TEXT: &str = "# Wolf\n\n*Medium Beast, Unaligned*\n\n**AC** 12\n\n**HP** 11 (2d8 + 2)\n\n**Speed** 40 ft.\n\n| | MOD | SAVE | | MOD | SAVE | | MOD | SAVE |\n| :- | :- | :- | :- | :- | :- | :- | :- | :- |\n| **Str 12** | +1 | +1 | **Dex 15** | +2 | +2 | **Con 12** | +1 | +1 |\n| **Int 3** | -4 | -4 | **Wis 12** | +1 | +1 | **Cha 6** | -2 | -2 |\n\n**Skills** Perception +3, Stealth +4\n\n**CR** 1/4\n\n## Actions\n\n***Bite.*** *Melee Attack Roll:* +3, reach 5 ft.\n"; /// A small valid corpus layer: one entry per kind, its matching fake /// vendored source, and a README and meta.yaml that satisfy the @@ -110,6 +124,47 @@ layer.write( "core/playing-the-game.md", &format!( "---\nname: Playing the Game\ntype: core\nsource: {CORE_SOURCE}\n---\n{CORE_BODY}" + ), + ); + + layer.write(GLOSSARY_SOURCE, GLOSSARY_BODY); + layer.write( + "glossary/advantage.md", + &format!( + "---\nname: Advantage\ntype: glossary\nsource: {GLOSSARY_SOURCE}\n---\n{GLOSSARY_BODY}" + ), + ); + + layer.write(EQUIPMENT_SOURCE, EQUIPMENT_BODY); + layer.write( + "equipment/armor.md", + &format!( + "---\nname: Armor\ntype: equipment\nsource: {EQUIPMENT_SOURCE}\n---\n{EQUIPMENT_BODY}" + ), + ); + + layer.write(SPECIES_SOURCE, SPECIES_BODY); + layer.write( + "species/elf.md", + &format!( + "---\nname: Elf\ntype: species\nsource: {SPECIES_SOURCE}\n---\n{SPECIES_BODY}" + ), + ); + + layer.write(BACKGROUND_SOURCE, BACKGROUND_BODY); + layer.write( + "backgrounds/acolyte.md", + &format!( + "---\nname: Acolyte\ntype: background\nsource: {BACKGROUND_SOURCE}\n---\n{BACKGROUND_BODY}" + ), + ); + + layer.write(ANIMAL_SOURCE, ANIMAL_SOURCE_TEXT); + let animal_body = super::stat_block::source_outside_region(ANIMAL_SOURCE_TEXT); + layer.write( + "animals/wolf.md", + &format!( + "---\nname: Wolf\ntype: animal\nsource: {ANIMAL_SOURCE}\nsize: Medium\ncreature_type: Beast\nalignment: Unaligned\nac: '12'\nhp: 11 (2d8 + 2)\nspeed: 40 ft.\ncr: 1/4\nskills: Perception +3, Stealth +4\nabilities:\n str: {{score: 12, mod: 1, save: 1}}\n dex: {{score: 15, mod: 2, save: 2}}\n con: {{score: 12, mod: 1, save: 1}}\n int: {{score: 3, mod: -4, save: -4}}\n wis: {{score: 12, mod: 1, save: 1}}\n cha: {{score: 6, mod: -2, save: -2}}\n---\n{animal_body}" ), ); diff --git a/src/srd/verify/integration.rs b/src/srd/verify/integration.rs --- a/src/srd/verify/integration.rs +++ b/src/srd/verify/integration.rs @@ -31,7 +31,7 @@ let report = verify(&layer.root); assert_eq!(extra_failures(&layer), vec![]); - assert_eq!(report.failures.len(), 6); + assert_eq!(report.failures.len(), 11); assert!(report.failures.iter().all(|f| f.message.contains("needs"))); } @@ -377,3 +377,25 @@ let failures = extra_failures(&layer); assert!(failures.iter().any(|f| f.message.contains("sha256"))); } + +#[test] +fn an_animal_claiming_from_monsters_each_is_reported() { + // The contract-extension probe: an animal is monster-shaped, but it + // must still only claim from 12_Animals/Animals_Each/. Pointing one + // at a real Monsters_Each file (monster and animal schemas being + // identical) must not let it slip through. + let layer = Layer::valid(); + layer.replace( + "animals/wolf.md", + "source: sources/dnd.srd.5.2.1/12_Animals/Animals_Each/Wolf.md", + "source: sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Goblin_Warrior.md", + ); + + let failures = extra_failures(&layer); + + assert!( + failures + .iter() + .any(|f| f.message.contains("animal") && f.message.contains("vendored directory")) + ); +} diff --git a/src/srd/verify/kind.rs b/src/srd/verify/kind.rs --- a/src/srd/verify/kind.rs +++ b/src/srd/verify/kind.rs @@ -1,5 +1,5 @@ -/// The six entry kinds a corpus layer holds, one directory each under the -/// layer root. +/// The eleven entry kinds a corpus layer holds, one directory each under +/// the layer root. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Kind { Core, @@ -8,17 +8,27 @@ Spell, Monster, MagicItem, Feat, + Glossary, + Equipment, + Species, + Background, + Animal, } impl Kind { /// Every kind, in the layer's directory order. - pub const ALL: [Kind; 6] = [ + pub const ALL: [Kind; 11] = [ Kind::Core, Kind::Class, Kind::Spell, Kind::Monster, Kind::MagicItem, Kind::Feat, + Kind::Glossary, + Kind::Equipment, + Kind::Species, + Kind::Background, + Kind::Animal, ]; /// The layer directory this kind's entries live in. @@ -30,6 +40,11 @@ Kind::Spell => "spells", Kind::Monster => "monsters", Kind::MagicItem => "magic-items", Kind::Feat => "feats", + Kind::Glossary => "glossary", + Kind::Equipment => "equipment", + Kind::Species => "species", + Kind::Background => "backgrounds", + Kind::Animal => "animals", } } @@ -42,6 +57,11 @@ Kind::Spell => "spell", Kind::Monster => "monster", Kind::MagicItem => "magic-item", Kind::Feat => "feat", + Kind::Glossary => "glossary", + Kind::Equipment => "equipment", + Kind::Species => "species", + Kind::Background => "background", + Kind::Animal => "animal", } } } @@ -62,5 +82,19 @@ #[test] fn magic_item_dir_and_type_names_differ() { assert_eq!(Kind::MagicItem.dir_name(), "magic-items"); assert_eq!(Kind::MagicItem.type_name(), "magic-item"); + } + + #[test] + fn background_dir_name_is_plural_but_type_name_is_singular() { + assert_eq!(Kind::Background.dir_name(), "backgrounds"); + assert_eq!(Kind::Background.type_name(), "background"); + } + + #[test] + fn every_kind_has_a_distinct_dir_name() { + let mut dir_names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.dir_name()).collect(); + dir_names.sort(); + dir_names.dedup(); + assert_eq!(dir_names.len(), Kind::ALL.len()); } } diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -110,7 +110,7 @@ .flatten(); let item_fields = (file.kind == Kind::MagicItem) .then(|| magic_item::validate(file, &mut failures)) .flatten(); - let monster_fields = (file.kind == Kind::Monster) + let monster_fields = matches!(file.kind, Kind::Monster | Kind::Animal) .then(|| monster::validate(file, &mut failures)) .flatten(); @@ -156,7 +156,8 @@ }; assert_eq!( report.summary(), - "verified layer: 1 core, 0 classes, 3 spells, 0 monsters, 0 magic-items, 0 feats" + "verified layer: 1 core, 0 classes, 3 spells, 0 monsters, 0 magic-items, 0 feats, \ + 0 glossary, 0 equipment, 0 species, 0 backgrounds, 0 animals" ); } diff --git a/src/srd/verify/schema.rs b/src/srd/verify/schema.rs --- a/src/srd/verify/schema.rs +++ b/src/srd/verify/schema.rs @@ -13,18 +13,38 @@ //! - `monster` → `11_Monsters/Monsters_Each/` //! - `magic-item` → `10_Magic_Items/Magic_Items_Each/` //! - `class` → `03_Character_Classes/Character_Classes_Each/` //! - `feat` → `05_Feats/Feats_Each/` +//! - `glossary` → `08_Rules_Glossary/Rules_Glossary_Each/` +//! - `equipment` → `06_Equipment/Equipment_Each/` +//! - `species` → `04_Character_Origins/Species/Species_Each/` +//! - `background` → `04_Character_Origins/Backgrounds/Backgrounds_Each/` +//! (the vendored repo's own directory and file names are +//! inconsistently cased and, in one file, misspelled — +//! `Backgrounds_Heaader.md` — but `Backgrounds_Each/` itself is spelled +//! correctly) +//! - `animal` → `12_Animals/Animals_Each/`. An animal's vendored source +//! is shaped exactly like a monster's (the same `**AC**`-to-heading +//! stat block, the same italic `*Size Type, Alignment*` subtitle line), +//! so `animal` reuses `monster.rs`'s schema and `stat_block.rs`'s +//! region handling rather than duplicating either. //! - `core` → a `.md` file directly inside any numbered chapter directory //! (`NN_Name/File.md`), not inside a further subdirectory. Every //! numbered directory has this shape whether or not it also has its own //! `_Each` family (`01_Playing_The_Game/Playing_The_Game.md`, but also //! `07_Spells/Spells_Header.md` — the chapter's own framing prose, //! distinct from its `_Each` entries), so `core` is not pinned to one -//! specific directory the way the other five kinds are. +//! specific directory the way the other kinds are. //! -//! Without this, a corpus file can claim any vendored file at all — the -//! `Legal.md` licensing page, another kind's own `_Each` entry, anything -//! — and inflate its kind's entry count with content that was never -//! meant to be that kind. +//! `glossary`, `equipment`, `species`, and `background` carry no fields +//! beyond the universal three, the same as `feat`: their vendored source +//! is prose (a glossary term's definition, an equipment table entry, a +//! species' or background's descriptive traits), not a stat block or a +//! book-style mechanical summary, so there is nothing to type beyond +//! what the body word-stream check already holds accountable to source. +//! +//! Without this per-kind family check, a corpus file can claim any +//! vendored file at all — the `Legal.md` licensing page, another kind's +//! own `_Each` entry, anything — and inflate its kind's entry count with +//! content that was never meant to be that kind. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -42,6 +62,12 @@ const MONSTER_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/"; const MAGIC_ITEM_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/10_Magic_Items/Magic_Items_Each/"; const CLASS_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/03_Character_Classes/Character_Classes_Each/"; const FEAT_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/05_Feats/Feats_Each/"; +const GLOSSARY_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/08_Rules_Glossary/Rules_Glossary_Each/"; +const EQUIPMENT_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/06_Equipment/Equipment_Each/"; +const SPECIES_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/04_Character_Origins/Species/Species_Each/"; +const BACKGROUND_SOURCE_DIR: &str = + "sources/dnd.srd.5.2.1/04_Character_Origins/Backgrounds/Backgrounds_Each/"; +const ANIMAL_SOURCE_DIR: &str = "sources/dnd.srd.5.2.1/12_Animals/Animals_Each/"; const UNIVERSAL_KEYS: [&str; 3] = ["name", "type", "source"]; @@ -78,10 +104,16 @@ /// Every frontmatter key `kind` allows, universal keys included. pub fn allowed_keys(kind: Kind) -> Vec<&'static str> { let extra: &[&str] = match kind { - Kind::Core | Kind::Class | Kind::Feat => &[], + Kind::Core + | Kind::Class + | Kind::Feat + | Kind::Glossary + | Kind::Equipment + | Kind::Species + | Kind::Background => &[], Kind::Spell => &SPELL_KEYS, Kind::MagicItem => &MAGIC_ITEM_KEYS, - Kind::Monster => &MONSTER_KEYS, + Kind::Monster | Kind::Animal => &MONSTER_KEYS, }; UNIVERSAL_KEYS.iter().chain(extra).copied().collect() } @@ -217,6 +249,11 @@ Kind::Monster => source_str.starts_with(MONSTER_SOURCE_DIR), Kind::MagicItem => source_str.starts_with(MAGIC_ITEM_SOURCE_DIR), Kind::Class => source_str.starts_with(CLASS_SOURCE_DIR), Kind::Feat => source_str.starts_with(FEAT_SOURCE_DIR), + Kind::Glossary => source_str.starts_with(GLOSSARY_SOURCE_DIR), + Kind::Equipment => source_str.starts_with(EQUIPMENT_SOURCE_DIR), + Kind::Species => source_str.starts_with(SPECIES_SOURCE_DIR), + Kind::Background => source_str.starts_with(BACKGROUND_SOURCE_DIR), + Kind::Animal => source_str.starts_with(ANIMAL_SOURCE_DIR), Kind::Core => is_core_chapter_source(source), } } @@ -294,351 +331,5 @@ failures } #[cfg(test)] -mod tests { - use super::*; - use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; - - fn corpus_file(kind: Kind, path: PathBuf, frontmatter_yaml: &str) -> CorpusFile { - CorpusFile { - path, - kind, - frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), - body: String::new(), - } - } - - fn layer_with_source(source_relative: &str) -> PathBuf { - let layer_root = unique_temp_dir(); - write_file(&layer_root.join(source_relative), "content"); - layer_root - } - - #[test] - fn allowed_keys_for_core_is_universal_only() { - assert_eq!(allowed_keys(Kind::Core), vec!["name", "type", "source"]); - } - - #[test] - fn allowed_keys_for_spell_includes_spell_fields() { - assert!(allowed_keys(Kind::Spell).contains(&"school")); - } - - #[test] - fn validate_universal_accepts_a_well_formed_file() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert_eq!(failures, vec![]); - assert_eq!(universal.unwrap().name, "Fireball"); - } - - #[test] - fn validate_universal_rejects_a_missing_name() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "type: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert!(universal.is_none()); - assert!(failures.iter().any(|f| f.message.contains("'name'"))); - } - - #[test] - fn validate_universal_rejects_a_missing_type() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!(failures.iter().any(|f| f.message.contains("'type'"))); - } - - #[test] - fn validate_universal_rejects_a_missing_source_key() { - let layer_root = unique_temp_dir(); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert!(universal.is_none()); - assert!(failures.iter().any(|f| f.message.contains("'source'"))); - } - - #[test] - fn validate_universal_rejects_an_unknown_key() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\nfoo: bar", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!( - failures - .iter() - .any(|f| f.message.contains("unknown frontmatter key 'foo'")) - ); - } - - #[test] - fn validate_universal_rejects_a_non_string_key() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\n5: bar", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!( - failures - .iter() - .any(|f| f.message.contains("keys must be strings")) - ); - } - - #[test] - fn validate_universal_rejects_a_non_mapping_frontmatter() { - let layer_root = unique_temp_dir(); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "just a string", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert!(universal.is_none()); - assert!( - failures - .iter() - .any(|f| f.message.contains("must be a YAML mapping")) - ); - } - - #[test] - fn validate_universal_rejects_a_type_directory_mismatch() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: monster\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!( - failures - .iter() - .any(|f| f.message.contains("does not match its directory")) - ); - } - - #[test] - fn validate_universal_rejects_a_missing_source_file() { - let layer_root = unique_temp_dir(); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!( - failures - .iter() - .any(|f| f.message.contains("does not exist")) - ); - } - - #[test] - fn validate_universal_rejects_a_source_from_the_wrong_kind_family() { - // A fabricated feat sourced from the top-level Legal.md: it exists - // and is under the vendored tree, but Legal.md is nowhere near - // 05_Feats/Feats_Each/, so it must not be usable to inflate the - // feat count. - let layer_root = layer_with_source("sources/dnd.srd.5.2.1/Legal.md"); - let file = corpus_file( - Kind::Feat, - layer_root.join("feats/legal-information.md"), - "name: Legal Information\ntype: feat\nsource: sources/dnd.srd.5.2.1/Legal.md", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert!(universal.is_none()); - assert!( - failures - .iter() - .any(|f| f.message.contains("feat") && f.message.contains("vendored directory")) - ); - } - - #[test] - fn validate_universal_accepts_a_core_source_directly_under_a_numbered_chapter() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"); - let file = corpus_file( - Kind::Core, - layer_root.join("core/playing-the-game.md"), - "name: Playing the Game\ntype: core\nsource: sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert_eq!(failures, vec![]); - assert!(universal.is_some()); - } - - #[test] - fn validate_universal_rejects_a_core_source_nested_in_an_each_directory() { - // A core chapter must be a bare file directly under a numbered - // chapter directory, not a claim on another kind's _Each entry. - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Core, - layer_root.join("core/fireball.md"), - "name: Fireball\ntype: core\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - let universal = validate_universal(&file, &layer_root, &mut failures); - - assert!(universal.is_none()); - assert!( - failures - .iter() - .any(|f| f.message.contains("core") && f.message.contains("vendored directory")) - ); - } - - #[test] - fn validate_universal_rejects_a_source_outside_the_vendored_tree() { - let layer_root = unique_temp_dir(); - write_file(&layer_root.join("elsewhere.md"), "content"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/fireball.md"), - "name: Fireball\ntype: spell\nsource: elsewhere.md", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!(failures.iter().any(|f| f.message.contains("is not under"))); - } - - #[test] - fn validate_universal_rejects_a_slug_mismatch() { - let layer_root = - layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); - let file = corpus_file( - Kind::Spell, - layer_root.join("spells/not-fireball.md"), - "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", - ); - let mut failures = Vec::new(); - - validate_universal(&file, &layer_root, &mut failures); - - assert!( - failures - .iter() - .any(|f| f.message.contains("does not match the slug")) - ); - } - - #[test] - fn check_slug_collisions_is_empty_for_unique_names() { - let names = vec![ - (PathBuf::from("spells/fireball.md"), "Fireball".to_string()), - (PathBuf::from("spells/wish.md"), "Wish".to_string()), - ]; - - assert_eq!(check_slug_collisions(&names), vec![]); - } - - #[test] - fn check_slug_collisions_reports_every_colliding_file() { - let names = vec![ - (PathBuf::from("spells/light.md"), "Light".to_string()), - (PathBuf::from("magic-items/light.md"), "Light".to_string()), - ]; - - let failures = check_slug_collisions(&names); - - assert_eq!(failures.len(), 2); - assert!(failures.iter().all(|f| f.message.contains("collides with"))); - } - - #[test] - fn is_core_chapter_source_rejects_a_path_outside_the_vendored_tree() { - assert!(!is_core_chapter_source(Path::new("elsewhere/file.md"))); - } - - #[test] - fn is_core_chapter_source_rejects_the_vendored_root_itself() { - assert!(!is_core_chapter_source(Path::new(VENDORED_PREFIX))); - } - - #[test] - fn is_core_chapter_source_rejects_a_non_numbered_top_level_directory() { - assert!(!is_core_chapter_source(Path::new( - "sources/dnd.srd.5.2.1/Legal.md" - ))); - } - - #[test] - fn is_core_chapter_source_rejects_a_numbered_directory_with_no_file() { - assert!(!is_core_chapter_source(Path::new( - "sources/dnd.srd.5.2.1/01_Playing_The_Game" - ))); - } - - #[test] - fn is_core_chapter_source_accepts_a_bare_file_under_a_numbered_directory() { - assert!(is_core_chapter_source(Path::new( - "sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md" - ))); - } -} +#[path = "schema_tests.rs"] +mod tests; diff --git a/src/srd/verify/schema_tests.rs b/src/srd/verify/schema_tests.rs new file mode 100644 --- /dev/null +++ b/src/srd/verify/schema_tests.rs @@ -0,0 +1,468 @@ +//! Tests for `schema.rs`, split out to keep the production file under +//! the project's file-length guideline. + +use super::*; +use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; + +fn corpus_file(kind: Kind, path: PathBuf, frontmatter_yaml: &str) -> CorpusFile { + CorpusFile { + path, + kind, + frontmatter: serde_yaml_ng::from_str(frontmatter_yaml).unwrap(), + body: String::new(), + } +} + +fn layer_with_source(source_relative: &str) -> PathBuf { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join(source_relative), "content"); + layer_root +} + +#[test] +fn allowed_keys_for_core_is_universal_only() { + assert_eq!(allowed_keys(Kind::Core), vec!["name", "type", "source"]); +} + +#[test] +fn allowed_keys_for_spell_includes_spell_fields() { + assert!(allowed_keys(Kind::Spell).contains(&"school")); +} + +#[test] +fn allowed_keys_for_animal_is_monster_shaped() { + assert!(allowed_keys(Kind::Animal).contains(&"abilities")); + assert!(allowed_keys(Kind::Animal).contains(&"ac")); +} + +#[test] +fn allowed_keys_for_glossary_equipment_species_and_background_is_universal_only() { + for kind in [ + Kind::Glossary, + Kind::Equipment, + Kind::Species, + Kind::Background, + ] { + assert_eq!(allowed_keys(kind), vec!["name", "type", "source"]); + } +} + +#[test] +fn validate_universal_rejects_an_animal_sourced_from_monsters_each() { + // Adversarial probe: an animal is monster-shaped, but it must + // still only claim from 12_Animals/Animals_Each/, not from + // 11_Monsters/Monsters_Each/ just because the schema matches. + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Wolf.md"); + let file = corpus_file( + Kind::Animal, + layer_root.join("animals/wolf.md"), + "name: Wolf\ntype: animal\nsource: sources/dnd.srd.5.2.1/11_Monsters/Monsters_Each/Wolf.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!( + failures + .iter() + .any(|f| f.message.contains("animal") && f.message.contains("vendored directory")) + ); +} + +#[test] +fn validate_universal_accepts_an_animal_sourced_from_animals_each() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/12_Animals/Animals_Each/Wolf.md"); + let file = corpus_file( + Kind::Animal, + layer_root.join("animals/wolf.md"), + "name: Wolf\ntype: animal\nsource: sources/dnd.srd.5.2.1/12_Animals/Animals_Each/Wolf.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_accepts_a_species_source_from_its_family() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/04_Character_Origins/Species/Species_Each/Elf.md"); + let file = corpus_file( + Kind::Species, + layer_root.join("species/elf.md"), + "name: Elf\ntype: species\nsource: sources/dnd.srd.5.2.1/04_Character_Origins/Species/Species_Each/Elf.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_accepts_a_background_source_from_its_family() { + let layer_root = layer_with_source( + "sources/dnd.srd.5.2.1/04_Character_Origins/Backgrounds/Backgrounds_Each/Acolyte.md", + ); + let file = corpus_file( + Kind::Background, + layer_root.join("backgrounds/acolyte.md"), + "name: Acolyte\ntype: background\nsource: sources/dnd.srd.5.2.1/04_Character_Origins/Backgrounds/Backgrounds_Each/Acolyte.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_accepts_a_glossary_source_from_its_family() { + let layer_root = layer_with_source( + "sources/dnd.srd.5.2.1/08_Rules_Glossary/Rules_Glossary_Each/Advantage.md", + ); + let file = corpus_file( + Kind::Glossary, + layer_root.join("glossary/advantage.md"), + "name: Advantage\ntype: glossary\nsource: sources/dnd.srd.5.2.1/08_Rules_Glossary/Rules_Glossary_Each/Advantage.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_accepts_an_equipment_source_from_its_family() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/06_Equipment/Equipment_Each/Armor.md"); + let file = corpus_file( + Kind::Equipment, + layer_root.join("equipment/armor.md"), + "name: Armor\ntype: equipment\nsource: sources/dnd.srd.5.2.1/06_Equipment/Equipment_Each/Armor.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_accepts_a_well_formed_file() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert_eq!(universal.unwrap().name, "Fireball"); +} + +#[test] +fn validate_universal_rejects_a_missing_name() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "type: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!(failures.iter().any(|f| f.message.contains("'name'"))); +} + +#[test] +fn validate_universal_rejects_a_missing_type() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("'type'"))); +} + +#[test] +fn validate_universal_rejects_a_missing_source_key() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!(failures.iter().any(|f| f.message.contains("'source'"))); +} + +#[test] +fn validate_universal_rejects_an_unknown_key() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\nfoo: bar", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("unknown frontmatter key 'foo'")) + ); +} + +#[test] +fn validate_universal_rejects_a_non_string_key() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md\n5: bar", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("keys must be strings")) + ); +} + +#[test] +fn validate_universal_rejects_a_non_mapping_frontmatter() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "just a string", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!( + failures + .iter() + .any(|f| f.message.contains("must be a YAML mapping")) + ); +} + +#[test] +fn validate_universal_rejects_a_type_directory_mismatch() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: monster\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not match its directory")) + ); +} + +#[test] +fn validate_universal_rejects_a_missing_source_file() { + let layer_root = unique_temp_dir(); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not exist")) + ); +} + +#[test] +fn validate_universal_rejects_a_source_from_the_wrong_kind_family() { + // A fabricated feat sourced from the top-level Legal.md: it exists + // and is under the vendored tree, but Legal.md is nowhere near + // 05_Feats/Feats_Each/, so it must not be usable to inflate the + // feat count. + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/Legal.md"); + let file = corpus_file( + Kind::Feat, + layer_root.join("feats/legal-information.md"), + "name: Legal Information\ntype: feat\nsource: sources/dnd.srd.5.2.1/Legal.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!( + failures + .iter() + .any(|f| f.message.contains("feat") && f.message.contains("vendored directory")) + ); +} + +#[test] +fn validate_universal_accepts_a_core_source_directly_under_a_numbered_chapter() { + let layer_root = + layer_with_source("sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md"); + let file = corpus_file( + Kind::Core, + layer_root.join("core/playing-the-game.md"), + "name: Playing the Game\ntype: core\nsource: sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert_eq!(failures, vec![]); + assert!(universal.is_some()); +} + +#[test] +fn validate_universal_rejects_a_core_source_nested_in_an_each_directory() { + // A core chapter must be a bare file directly under a numbered + // chapter directory, not a claim on another kind's _Each entry. + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Core, + layer_root.join("core/fireball.md"), + "name: Fireball\ntype: core\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + let universal = validate_universal(&file, &layer_root, &mut failures); + + assert!(universal.is_none()); + assert!( + failures + .iter() + .any(|f| f.message.contains("core") && f.message.contains("vendored directory")) + ); +} + +#[test] +fn validate_universal_rejects_a_source_outside_the_vendored_tree() { + let layer_root = unique_temp_dir(); + write_file(&layer_root.join("elsewhere.md"), "content"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/fireball.md"), + "name: Fireball\ntype: spell\nsource: elsewhere.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!(failures.iter().any(|f| f.message.contains("is not under"))); +} + +#[test] +fn validate_universal_rejects_a_slug_mismatch() { + let layer_root = layer_with_source("sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md"); + let file = corpus_file( + Kind::Spell, + layer_root.join("spells/not-fireball.md"), + "name: Fireball\ntype: spell\nsource: sources/dnd.srd.5.2.1/07_Spells/Spells_Each/Fireball.md", + ); + let mut failures = Vec::new(); + + validate_universal(&file, &layer_root, &mut failures); + + assert!( + failures + .iter() + .any(|f| f.message.contains("does not match the slug")) + ); +} + +#[test] +fn check_slug_collisions_is_empty_for_unique_names() { + let names = vec![ + (PathBuf::from("spells/fireball.md"), "Fireball".to_string()), + (PathBuf::from("spells/wish.md"), "Wish".to_string()), + ]; + + assert_eq!(check_slug_collisions(&names), vec![]); +} + +#[test] +fn check_slug_collisions_reports_every_colliding_file() { + let names = vec![ + (PathBuf::from("spells/light.md"), "Light".to_string()), + (PathBuf::from("magic-items/light.md"), "Light".to_string()), + ]; + + let failures = check_slug_collisions(&names); + + assert_eq!(failures.len(), 2); + assert!(failures.iter().all(|f| f.message.contains("collides with"))); +} + +#[test] +fn is_core_chapter_source_rejects_a_path_outside_the_vendored_tree() { + assert!(!is_core_chapter_source(Path::new("elsewhere/file.md"))); +} + +#[test] +fn is_core_chapter_source_rejects_the_vendored_root_itself() { + assert!(!is_core_chapter_source(Path::new(VENDORED_PREFIX))); +} + +#[test] +fn is_core_chapter_source_rejects_a_non_numbered_top_level_directory() { + assert!(!is_core_chapter_source(Path::new( + "sources/dnd.srd.5.2.1/Legal.md" + ))); +} + +#[test] +fn is_core_chapter_source_rejects_a_numbered_directory_with_no_file() { + assert!(!is_core_chapter_source(Path::new( + "sources/dnd.srd.5.2.1/01_Playing_The_Game" + ))); +} + +#[test] +fn is_core_chapter_source_accepts_a_bare_file_under_a_numbered_directory() { + assert!(is_core_chapter_source(Path::new( + "sources/dnd.srd.5.2.1/01_Playing_The_Game/Playing_The_Game.md" + ))); +}