Files
systemic-game-design-course/course/10-spellgrid/reference/rust/tests/cli.rs
T
Lucas WintherandClaude Opus 5.5 85959ba5a0
CI / Tool tests and sync-check (push) Successful in 16s
CI / Verify references (all modules, Rust and TS) (push) Successful in 2m4s
CI / Verify Expansion Drop solutions (all modules, Rust and TS) (push) Successful in 2m1s
CI / Coupling audit on references (push) Successful in 5s
CI / Differential fuzzing of the Rust and TS references (300 seeds) (push) Successful in 53s
CI / Mutation check (tools/mutants.json against the highest TS reference) (push) Successful in 34s
CI / Verify the SpellGrid level proofs (solver) (push) Successful in 5m25s
CI / Site build (push) Successful in 15s
CI / Naive engine tests (Module 0) (push) Successful in 20s
fix: final review (engine errors end a Sequence cleanly; nesting guard labelled as the course's; Node 22.15; minor lesson and tool fixes)
I1: run-scenario and both SpellGrid CLIs check moves first and treat any other engine
error as fatal (same text in both tracks); trigger blocks close on error (try/finally,
catch_unwind in Rust); unknown summons rejected. I2, I3, M1-M7, T3, T9a-c, T10a-b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:58:27 +01:00

181 lines
7.3 KiB
Rust

// The SpellGrid command line, played from a script (the reference's own check; not a course test).
use std::cell::RefCell;
use std::io::{Cursor, Write};
use std::rc::Rc;
use engine::cli::{run, run_with_cards, Outcome};
use engine::harness::{new_spell_grid, CardDef, LevelDef};
/// Collects what the CLI prints.
#[derive(Clone, Default)]
struct Screen(Rc<RefCell<Vec<u8>>>);
impl Write for Screen {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn play(level: &str, input: &[&str]) -> (Outcome, String) {
let screen = Screen::default();
let input = Box::new(Cursor::new(input.join("\n") + "\n"));
let outcome = run(level, input, Box::new(screen.clone()));
let out = String::from_utf8(screen.0.borrow().clone()).unwrap();
(outcome, out)
}
#[test]
fn cli_wins_level_1() {
let input = ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"];
let (outcome, out) = play(include_str!("../levels/l1-double-tap.json"), &input);
assert_eq!(outcome, Outcome::Won);
assert!(out.contains("DEA IS DOU"));
assert!(out.contains("You win!"));
}
#[test]
fn cli_wins_level_2_with_choices_and_play() {
let input = [
"c Graft",
"Ember Moth",
"Rune Scribe",
"bottom",
"c Ember Moth (Assembled) 4 3",
"a",
"a",
];
let (outcome, out) = play(include_str!("../levels/l2-spliced-corpse.json"), &input);
assert_eq!(outcome, Outcome::Won);
assert!(out.contains("Choose the base"));
assert!(out.contains("Hand: Ember Moth (Assembled)"));
}
#[test]
fn cli_prints_the_log_tree_of_each_command() {
let level = include_str!("../levels/l1-double-tap.json");
let (_, out) = play(level, &["w", "w", "c Whirlwind", "q"]);
// The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested.
assert_eq!(out.matches("Log:\n").count(), 1, "{out}");
let tree = [
"Log:",
"SpellCast{card: \"Whirlwind\"}",
"Damaged{target: 32, amount: 1}",
"Damaged{target: 33, amount: 1}",
"Destroyed{id: 32}",
"DeathPhase{round: 1}",
"Triggered{source: 32, trigger: \"deathrattle\"}",
" Damaged{target: 33, amount: 1}",
"Triggered{source: 32, trigger: \"deathrattle\"}",
" Damaged{target: 33, amount: 1}",
"Destroyed{id: 33}",
"DeathPhase{round: 2}",
"",
];
assert!(out.contains(&tree.join("\n")), "{out}");
}
#[test]
fn cli_reports_errors_and_quits() {
let input = ["c Spark", "x", "q"];
let (outcome, out) = play(
include_str!("../levels/l3-simultaneous-paradox.json"),
&input,
);
assert_eq!(outcome, Outcome::Quit);
assert!(out.contains("\"Spark\" is not in the hand"));
assert!(out.contains("Commands:"));
}
// The two reference CLIs print the same text for the same input (tools/diff-tracks.mjs checks it).
#[test]
fn cli_prints_card_names_as_typed() {
let level = include_str!("../levels/l3-simultaneous-paradox.json");
let (_, out) = play(level, &["c a\"b", "q"]);
assert!(out.contains("\"a\"b\" is not in the hand"), "{out}");
}
#[test]
fn cli_numbers_are_plain_digits() {
// "-1" is not a coordinate, so it is part of the card's name.
let level = include_str!("../levels/l1-double-tap.json");
let (_, out) = play(level, &["c Whirlwind -1 0", "q"]);
assert!(
out.contains("\"Whirlwind -1 0\" is not in the hand"),
"{out}"
);
// "+2" is not a number, so the chooser takes the first option.
let level = include_str!("../levels/l2-spliced-corpse.json");
let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]);
assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}");
}
// Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs).
const ZAP: &str = r#"{"name": "Zap", "type": "spell", "cost": 1, "split": 0,
"text": [{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#;
// Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end.
const LOOP_EMBER: &str = r#"{"name": "Loop Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1,
"noun": "ROCK", "split": 1, "text": [{"kind": "trigger", "on": "deathrattle"},
{"kind": "effect", "effect": {"type": "summon", "card": "Loop Ember", "count": 1}},
{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#;
// Its trigger names a card that does not exist, so it fails part-way through.
const BAD_GOLEM: &str = r#"{"name": "Bad Golem", "type": "minion", "cost": 1, "attack": 1, "health": 3,
"noun": "KEY", "split": 1, "text": [{"kind": "trigger", "on": "selfDamaged"},
{"kind": "effect", "effect": {"type": "summon", "card": "No Such Card", "count": 1}}]}"#;
/// A 6x3 level with BABA IS YOU, three Zaps in the hand, and `card` on (3, 0).
fn row(card: &str) -> String {
format!(
r#"{{"width": 6, "height": 3, "objects": [{{"kind": "baba", "x": 0, "y": 0}},
{{"kind": "text:BABA", "x": 0, "y": 2}}, {{"kind": "text:IS", "x": 1, "y": 2}}, {{"kind": "text:YOU", "x": 2, "y": 2}}],
"cards": [{{"card": "{card}", "x": 3, "y": 0}}], "hand": ["Zap", "Zap", "Zap"]}}"#
)
}
fn cards(json: &[&str]) -> Vec<CardDef> {
json.iter()
.map(|j| serde_json::from_str(j).unwrap())
.collect()
}
#[test]
fn cli_stops_when_a_sequence_does_not_finish() {
let screen = Screen::default();
let input = Box::new(Cursor::new("c Zap\nd\nq\n"));
let extra = cards(&[ZAP, LOOP_EMBER]);
let outcome = run_with_cards(&row("Loop Ember"), extra, input, Box::new(screen.clone()));
let out = String::from_utf8(screen.0.borrow().clone()).unwrap();
assert_eq!(outcome, Outcome::Failed);
// The error, then the end: no log tree of a half-done Sequence, no further commands read.
let end = "> Death Phase limit: more than 64 Death Phases in one Sequence\n\
The Sequence did not finish, so the game cannot go on.\n";
assert!(out.ends_with(end), "{out}");
assert!(!out.contains("Log:"), "{out}");
}
#[test]
fn an_error_mid_sequence_leaves_no_trigger_resolving() {
let level: LevelDef = serde_json::from_str(&row("Bad Golem")).unwrap();
let mut g = new_spell_grid(level, cards(&[ZAP, BAD_GOLEM]));
let failed = |g: &mut engine::harness::SpellGrid| {
let cause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Zap", None)))
.expect_err("the trigger names no card");
cause.downcast::<String>().map(|m| *m).unwrap_or_default()
};
assert!(failed(&mut g).contains("unknown card \"No Such Card\""));
// The failed trigger closed its block: the next Sequence's log starts at depth 0, and
// the golem's trigger is queued again (the nesting guard does not think it is still resolving).
g.clear_log();
assert!(failed(&mut g).contains("unknown card \"No Such Card\""));
let tree = g.log_tree();
let lines: Vec<&str> = tree.lines().collect();
assert_eq!(lines.len(), 3, "{tree}");
assert_eq!(lines[0], "SpellCast{card: \"Zap\"}");
assert!(lines[1].starts_with("Damaged{target: "), "{tree}");
assert!(lines[2].starts_with("Triggered{source: "), "{tree}");
assert!(lines[2].ends_with(", trigger: \"damaged\"}"), "{tree}");
}