diff --git a/src/lib.rs b/src/lib.rs index 36100ee..3c5f54b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -149,4 +149,164 @@ mod smoke { ); } } + + // ---- Per-level shape checks ------------------------------------- + // + // One test per level. Each pins the *shape* its design note promises + // and, where it is the whole lesson, the semantic forgiveness. The + // generic invariants (valid YAML, determinism, self-similarity, id + // ordering) are covered once for every level by + // `levels_generate_canonical_yaml` above. + + /// Look up a string key in a YAML mapping, panicking with a clear + /// message if it is missing — keeps the per-level assertions terse. + fn field<'a>(v: &'a Value, key: &str) -> &'a Value { + v.get(key) + .unwrap_or_else(|| panic!("expected key `{key}` in {v:?}")) + } + + /// Generate level `index` (0-based) with the given seed. + fn gen_level(index: usize, seed: u64) -> levels::Generated { + levels::registry()[index].generate(seed) + } + + /// Parse a target YAML string, asserting it is well-formed. + fn parse(yaml: &str) -> Value { + serde_yaml::from_str(yaml).expect("target is valid YAML") + } + + #[test] + fn level_04_chest_is_a_list() { + // `chest:` is a list of 3–5 item strings. + let v = parse(&gen_level(3, 4).target_yaml); + let chest = field(&v, "chest").as_sequence().expect("chest is a list"); + assert!((3..=5).contains(&chest.len()), "chest holds 3–5 items"); + assert!(chest.iter().all(Value::is_string), "every item is a string"); + } + + #[test] + fn level_05_chambers_is_a_dict_of_lists() { + // `chambers:` is a dict of 2–3 lists, 2–3 items each. + let v = parse(&gen_level(4, 5).target_yaml); + let chambers = field(&v, "chambers") + .as_mapping() + .expect("chambers is a dict"); + assert!((2..=3).contains(&chambers.len()), "2–3 chambers"); + for (_name, items) in chambers { + let items = items.as_sequence().expect("each chamber holds a list"); + assert!((2..=3).contains(&items.len()), "2–3 items per chamber"); + } + } + + #[test] + fn level_06_trap_repeats_in_every_room() { + // `trap:` is defined once; every room repeats it verbatim, so a + // player using `&anchor`/`*alias` parses to the same Value. + let v = parse(&gen_level(5, 6).target_yaml); + let trap = field(&v, "trap"); + for key in ["type", "depth", "spikes"] { + assert!(trap.get(key).is_some(), "trap carries `{key}`"); + } + let rooms = field(&v, "rooms").as_mapping().expect("rooms is a dict"); + assert!(!rooms.is_empty(), "at least one room"); + for (_room, payload) in rooms { + assert_eq!(payload, trap, "every room repeats the trap definition"); + } + } + + #[test] + fn level_07_floor_map_nests_maps_and_lists() { + // `floor:` int + `rooms:` dict; each room nests two lists. + let v = parse(&gen_level(6, 7).target_yaml); + assert!(field(&v, "floor").is_i64(), "floor is an integer"); + let rooms = field(&v, "rooms").as_mapping().expect("rooms is a dict"); + assert!(!rooms.is_empty(), "at least one room"); + for (_name, room) in rooms { + assert!(field(room, "type").is_string(), "room.type is a string"); + assert!(field(room, "locked").is_bool(), "room.locked is a bool"); + assert!(field(room, "exits").is_sequence(), "room.exits is a list"); + assert!( + field(room, "contents").is_sequence(), + "room.contents is a list" + ); + } + } + + #[test] + fn level_08_scroll_keeps_explicit_types() { + // Explicit types: a multi-line string, a float, and a digit-only + // string that must NOT collapse to an integer. + let v = parse(&gen_level(7, 8).target_yaml); + let scroll = field(&v, "scroll").as_str().expect("scroll is a string"); + assert!(scroll.contains('\n'), "scroll preserves its newlines"); + assert!(field(&v, "weight").is_f64(), "weight is a float"); + let title = field(&v, "title"); + assert!(title.is_string(), "title stays a string, not an int"); + assert!( + title.as_str().unwrap().chars().all(|c| c.is_ascii_digit()), + "title is digit-only — the point of the `!!str` lesson" + ); + } + + #[test] + fn level_09_doors_merge_shared_defaults() { + // Merge keys: each door carries a literal `<<` whose value is the + // shared defaults dict, plus its own override. + let v = parse(&gen_level(8, 9).target_yaml); + let defaults = field(&v, "door_defaults"); + for door in ["north_door", "south_door"] { + assert_eq!( + field(field(&v, door), "<<"), + defaults, + "{door} merges the shared defaults via `<<`" + ); + } + assert!(field(field(&v, "north_door"), "locked").is_bool()); + assert!(field(field(&v, "south_door"), "material").is_string()); + } + + #[test] + fn level_10_ledger_forgives_numeric_forms() { + // The ledger: int gold/silver, float experience, ISO date. The + // lesson is numeric forgiveness, so hex must score a perfect match. + let g = gen_level(9, 10); + let v = parse(&g.target_yaml); + let vault = field(&v, "vault"); + let gold = field(vault, "gold").as_i64().expect("gold is an integer"); + assert!(field(vault, "silver").is_i64(), "silver is an integer"); + assert!(field(vault, "experience").is_f64(), "experience is a float"); + assert!(field(vault, "date").is_string(), "date is an ISO string"); + let hex_candidate = g + .target_yaml + .replace(&format!("gold: {gold}"), &format!("gold: 0x{gold:X}")); + assert_ne!(hex_candidate, g.target_yaml, "hex rewrite must apply"); + assert_eq!( + similarity::semantic_or_textual(&g.target_yaml, &hex_candidate), + 1.0, + "writing gold in hex must still score a perfect match" + ); + } + + #[test] + fn level_11_copies_alias_defined_shapes() { + // `shapes:` defines polygons; `copies:` reuses them, so each copy + // is structurally identical to one of the definitions. + let v = parse(&gen_level(10, 11).target_yaml); + let shapes = field(&v, "shapes").as_sequence().expect("shapes is a list"); + assert!((2..=3).contains(&shapes.len()), "2–3 defined shapes"); + for s in shapes { + assert!(field(s, "name").is_string(), "shape.name is a string"); + let sides = field(s, "sides").as_i64().expect("shape.sides is an int"); + assert_eq!( + field(s, "interior").as_i64().expect("shape.interior is an int"), + (sides - 2) * 180, + "interior angle sum follows (n-2)·180" + ); + } + let copies = field(&v, "copies").as_sequence().expect("copies is a list"); + assert!((3..=4).contains(&copies.len()), "3–4 copies"); + for c in copies { + assert!(shapes.contains(c), "every copy aliases a defined shape"); + } + } }