327 lines
12 KiB
Rust
327 lines
12 KiB
Rust
pub mod config;
|
||
pub mod describe;
|
||
pub mod levels;
|
||
pub mod progress;
|
||
pub mod similarity;
|
||
pub mod tui;
|
||
|
||
#[cfg(test)]
|
||
mod smoke {
|
||
//! End-to-end wiring test with one hardcoded level (no `Level` trait yet).
|
||
|
||
use super::*;
|
||
use serde::Serialize;
|
||
use serde_yaml::{Mapping, Value};
|
||
|
||
#[derive(Serialize)]
|
||
struct JunctionCtx {
|
||
directions: Vec<Direction>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct Direction {
|
||
name: &'static str,
|
||
feature: &'static str,
|
||
}
|
||
|
||
#[test]
|
||
fn one_level_round_trip() {
|
||
// (1) Describer renders prose for a fake "junction" level.
|
||
let mut d = describe::Describer::new();
|
||
d.register(
|
||
"junction",
|
||
"You stand at a junction:\n\
|
||
{% for x in directions %}- {{ x.name }} leads to a {{ x.feature }}\n{% endfor %}",
|
||
)
|
||
.unwrap();
|
||
let prose = d
|
||
.render(
|
||
"junction",
|
||
&JunctionCtx {
|
||
directions: vec![
|
||
Direction { name: "left", feature: "door" },
|
||
Direction { name: "right", feature: "tunnel" },
|
||
],
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(prose.contains("left leads to a door"));
|
||
assert!(prose.contains("right leads to a tunnel"));
|
||
|
||
// (2) Canonical target via serde_yaml — what a generator will do.
|
||
// A reordered candidate parses to the same Value, so the semantic
|
||
// short-circuit must score it 1.0.
|
||
let mut m = Mapping::new();
|
||
m.insert(Value::String("left".into()), Value::String("door".into()));
|
||
m.insert(Value::String("right".into()), Value::String("tunnel".into()));
|
||
let target = serde_yaml::to_string(&Value::Mapping(m)).unwrap();
|
||
let candidate = "right: tunnel\nleft: door\n";
|
||
assert_eq!(
|
||
similarity::semantic_or_textual(&target, candidate),
|
||
1.0,
|
||
"reordered keys should still be a perfect semantic match"
|
||
);
|
||
|
||
// (3) Textually-different, semantically-different → ratio in (0, 1).
|
||
let near_miss = similarity::similarity_ratio("a: 1\nb: 2\n", "a: 1\nb: 3\n");
|
||
assert!(near_miss > 0.0 && near_miss < 1.0);
|
||
|
||
// (4) Progress round-trips through the same YAML pipeline that disk
|
||
// save/load will use.
|
||
let p = progress::Progress {
|
||
nuggets: vec![(1, levels::Nugget::Silver), (2, levels::Nugget::Gold)],
|
||
current_level: 3,
|
||
current_seed: 0xCAFE,
|
||
attempts: 1,
|
||
};
|
||
let s = serde_yaml::to_string(&p).unwrap();
|
||
let loaded: progress::Progress = serde_yaml::from_str(&s).unwrap();
|
||
assert_eq!(loaded.nuggets, p.nuggets);
|
||
assert_eq!(loaded.current_level, p.current_level);
|
||
assert_eq!(loaded.current_seed, p.current_seed);
|
||
}
|
||
|
||
// ---- Per-level tests --------------------------------------------
|
||
//
|
||
// One test per level. `checked_level` runs the invariants every
|
||
// level must satisfy (correct id, valid & deterministic canonical
|
||
// YAML, self-similarity 1.0); each test then pins the *shape* its
|
||
// design note promises and, where it is the whole lesson, the
|
||
// semantic forgiveness.
|
||
|
||
/// 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:?}"))
|
||
}
|
||
|
||
/// 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")
|
||
}
|
||
|
||
/// Generate level `index` (0-based) with `seed`, assert the invariants
|
||
/// every level must satisfy, and return its output for shape checks:
|
||
/// - the level reports `id == index + 1`,
|
||
/// - the target is valid YAML,
|
||
/// - generation is deterministic for a given seed,
|
||
/// - the canonical target scores 1.0 against itself.
|
||
fn checked_level(index: usize, seed: u64) -> levels::Generated {
|
||
let registry = levels::registry();
|
||
let level = ®istry[index];
|
||
let want_id = index as u8 + 1;
|
||
assert_eq!(
|
||
level.id(),
|
||
want_id,
|
||
"registry index {index} must hold level {want_id}"
|
||
);
|
||
|
||
let g = level.generate(seed);
|
||
serde_yaml::from_str::<Value>(&g.target_yaml)
|
||
.unwrap_or_else(|e| panic!("level {want_id} target is not valid YAML: {e}"));
|
||
|
||
let again = level.generate(seed);
|
||
assert_eq!(
|
||
g.target_yaml, again.target_yaml,
|
||
"level {want_id} must be deterministic for a given seed"
|
||
);
|
||
|
||
assert_eq!(
|
||
similarity::semantic_or_textual(&g.target_yaml, &g.target_yaml),
|
||
1.0,
|
||
"level {want_id} target must score 1.0 against itself"
|
||
);
|
||
g
|
||
}
|
||
|
||
#[test]
|
||
fn registry_lists_all_eleven_levels() {
|
||
assert_eq!(
|
||
levels::registry().len(),
|
||
11,
|
||
"all 11 levels must be registered"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn level_01_minimum_is_a_null_document() {
|
||
// Any null-equivalent passes via the semantic short-circuit.
|
||
let g = checked_level(0, 0);
|
||
assert!(parse(&g.target_yaml).is_null(), "the minimum YAML is null");
|
||
assert_eq!(
|
||
similarity::semantic_or_textual(&g.target_yaml, "---"),
|
||
1.0,
|
||
"`---` should be accepted as the minimum YAML"
|
||
);
|
||
assert_eq!(
|
||
similarity::semantic_or_textual(&g.target_yaml, "null"),
|
||
1.0,
|
||
"`null` should be accepted as the minimum YAML"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn level_02_key_value_is_a_non_empty_mapping() {
|
||
let g = checked_level(1, 42);
|
||
let v = parse(&g.target_yaml);
|
||
assert!(
|
||
!v.as_mapping()
|
||
.expect("level 2 produces a mapping")
|
||
.is_empty(),
|
||
"level 2 yields a non-empty mapping"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn level_03_dict_nests_typed_mappings() {
|
||
// A mapping of mappings; each inner mapping carries a `type` key.
|
||
let g = checked_level(2, 123);
|
||
let v = parse(&g.target_yaml);
|
||
let m = v.as_mapping().expect("level 3 produces a mapping");
|
||
assert!(!m.is_empty(), "at least one direction");
|
||
for (_dir, feature) in m {
|
||
feature.as_mapping().expect("level 3 inner is a mapping");
|
||
assert!(
|
||
feature.get("type").is_some(),
|
||
"each direction must carry a `type` key"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn level_04_chest_is_a_list() {
|
||
// `chest:` is a list of 3–5 item strings.
|
||
let v = parse(&checked_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(&checked_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(&checked_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(&checked_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(&checked_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(&checked_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 = checked_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(&checked_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");
|
||
}
|
||
}
|
||
}
|