| name | card-test |
| description | Canonical recipe for writing engine cast-pipeline tests. Use GameScenario + GameRunner::cast(...).resolve() and assert via CastOutcome deltas. Covers the six test-harness foot-guns (hand-written TargetRef vectors, incomplete modal target submission, wrong-point hand baseline, inline-keyword cards, AST-internal flag assertions, vacuous negative assertions) and the right-way fix for each. Use whenever writing or porting a runtime test that casts a spell and checks its effect. |
card-test — the canonical cast-pipeline test recipe
This skill gives ONE rigid recipe for runtime engine tests that cast a spell and
assert its effect. It exists because six test-harness foot-guns recur; the
[SpellCast] driver and [CastOutcome] (in crates/engine/src/game/scenario.rs)
make them structurally impossible when you follow this recipe.
Reference ports that demonstrate the pattern:
crates/engine/tests/chord_of_calling.rs — X-spell, convoke, final_waiting_for() boundary.
crates/engine/src/game/casting.rs — exsanguinate_* (life deltas, 2- and 3-player), vicious_rivalry_* (X-cost filter + zones), kozileks_command_modes_* (modal + X + multi-target).
The recipe
use engine::game::scenario::{GameScenario, GameRunner};
#[test]
fn my_card_does_the_thing() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
let spell = scenario
.add_spell_to_hand_from_oracle(P0, "My Card", true, ORACLE)
.id();
let mut runner = scenario.build();
let outcome = runner
.cast(spell)
.modes(&[0, 2])
.x(3)
.target_player(P1)
.target_objects(&[victim])
.convoke_with(&[creature])
.resolve();
outcome.assert_life_delta(P1, -3);
outcome.assert_zone(&[victim], Zone::Exile);
outcome.assert_hand_drawn(P0, 1);
assert!(matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }));
}
What the driver does for you
resolve() runs a bounded state-machine over state.waiting_for (CR 601.2a–h):
ModeChoice → submits .modes(..) (panics if modal but no modes declared).
ChooseXValue → submits .x(..) (panics if X needed but not declared).
ManaPayment { convoke_mode } → taps each .convoke_with(..) creature with
mana of its color, then finalizes (CR 702.51b). Pool-funded casts auto-pay and
never surface this window.
TargetSelection → answers one slot at a time, in written order
(CR 601.2c). Object intent is consumed (one declared object per slot); player
intent is reusable (one player may be targeted by several modes).
Priority (post-cast) → captures the per-player hand baseline at stack commit
(CR 601.2a) and proceeds to resolution.
- Resolution auto-answers
OrderTriggers (CR 603.3b) and ScryChoice (keeps
looked-at cards on top, CR 701.22a); it stops at stack-empty or at any prompt
it is not taught to answer (e.g. SearchChoice) so you can assert on it via
final_waiting_for().
- Any unhandled prompt → a clear
extend the driver or drive this case manually
panic. Extending the driver is the correct response; never assert around a
silent skip.
CastOutcome accessors
| Accessor | Returns |
|---|
hand_drawn(p) / assert_hand_drawn(p, n) | net cards drawn since stack commit (the clean resolution delta) |
zone_of(o) / assert_zone(&[o], zone) | an object's current zone |
life_delta(p) / assert_life_delta(p, n) | net life change since before the cast |
final_waiting_for() | the state the pipeline halted in |
state() | read-only &GameState for assertions the typed accessors don't cover |
Anti-patterns — the six foot-guns and the right-way fix
-
Hand-written TargetRef vectors. Building a flat Vec<TargetRef> and
submitting SelectTargets { targets } is fragile (TargetRef is non-Copy;
.copied() won't compile, and the order must match the slots exactly).
Fix: declare intent with .target_object(..) / .target_player(..); the
driver matches each intent to its slot.
-
Incomplete modal target submission. Omitting one mode's slot yields
InvalidAction("Illegal target selected") because targets are validated one
per slot, in written order (ability_utils::choose_target).
Fix: the driver answers every slot via ChooseTarget in order — you cannot
forget a slot.
-
Hand/zone baseline captured at the wrong point. handle_cast_spell does
NOT remove the spell from hand; CR 601.2a removes it only when the cast
commits to the stack (the Priority window). A baseline taken before that is
off by one.
Fix: CastOutcome::hand_drawn is measured against the stack-commit
baseline the driver captures for you. Use assert_hand_drawn, never a
hand-picked let hand_before = ....
-
Keywords fed as inline Oracle reminder text. Writing "Convoke (Your creatures ...)" as plain text parses to Effect::Unimplemented.
Fix: build keyworded cards via
from_oracle_text_with_keywords(&["Convoke"], text), and pay convoke via
.convoke_with(&[..]) (which the driver routes through TapForConvoke).
-
Asserting representation-internal dual-encoded flags. Asserting an
AST field such as ChangeZone.up_to couples the test to one encoding of the
spec rather than the behavior.
Fix: assert behavior via CastOutcome deltas (zone_of, life_delta,
hand_drawn). When a SHAPE assertion is genuinely needed (parser structure),
label the test SHAPE and assert via semantic accessors (e.g. a
MultiTargetSpec), not internal bools.
-
Vacuous negative assertions. A negative assertion (!detector(...),
"no longer parses to X", "counter is NOT applied") passes for the wrong
reason when an upstream guard short-circuits before the code under test
runs. Canonical instance: check_swallowed_clauses returns early when any
parsed ability contains Effect::Unimplemented, so
!has_swallowed_clause(...) passes on a card that failed to parse at all —
the single most common review finding on contributor PRs.
Fix: every negative assertion must be paired with a positive reach-guard
in the same test proving the input got past the short-circuit (parse
succeeded, zero Effect::Unimplemented entries, the expected positive AST
shape, or a positive runtime delta on the sibling path). Better still,
replace the negative with a runtime regression whose assertion flips when
the fix is reverted.
Hard rules
- Never call the raw
resolve() stack function directly. Drive through the
apply() pipeline (via runner.cast(..).resolve() or runner.act(..)).
Calling stack::resolve_top / effect::resolve directly bypasses the
intervening-if recheck and the cast_from_zone carry-through, so cast-trigger
bugs (cascade/storm/casualty) go invisible. See the memory landmine
project_cast_from_zone_stack_wipe.
- Prefer runtime-behavior tests. Reserve AST-SHAPE tests for parser
structure; label them SHAPE and assert via semantic accessors, not internal
flags. The two Kozilek/Chord SHAPE tests
(
kozileks_command_full_four_mode_parse,
chord_of_calling_parses_x_mana_value_search_shape) are the correct pattern
for that and are intentionally NOT driven through cast().
- CR-annotate any assertion that encodes a rule (verify the number against
docs/MagicCompRules.txt before writing it).
- Use the card's verbatim Oracle text, never a paraphrase. Build the test
card from the real card's exact Oracle text (
add_spell_to_hand_from_oracle
with the verbatim string, or add_real_card for integration fixtures).
Paraphrased text can take a different parser branch than the real card, so
the test goes green while the actual card stays broken until card-data is
regenerated (see project_parser_fix_inert_until_data_regen).
- Regenerate the integration fixture when your parser change touches a card
it contains.
add_real_card reads the committed
crates/engine/tests/fixtures/integration_cards.json snapshot.
python3 scripts/gen-test-fixture.py --check verifies coverage only — that every
referenced card is PRESENT (a key-set comparison). It never compares the stored parse
VALUES against a fresh export. So a parser change that alters a fixture card's parse
leaves the fixture stale while the gate stays green: the integration test then asserts
against the old parse and proves nothing about the code you shipped. If your change
alters how any fixture card parses, re-run python3 scripts/gen-test-fixture.py (no
--check) and commit the regenerated fixture in the same PR.