//! The inspector: one seat's whole picture, rendered as text //! (CB-WP-0011 T01). //! //! Moved out of `table.rs`, where it was born in stage 0 as a prompt //! header. That is exactly what was wrong with it: written to show a //! human their legal moves, it showed the six fields a chooser needs and //! silently dropped the rest of the projection — the whole DARVO state //! machine, the whole GROUND practice, the scoring mode, the Focus //! tokens, the discard pile, and every part of the outcome except the //! headline. //! //! **No test could have caught that**, which is the point worth writing //! down. Every assertion a renderer test naturally makes — "the output //! mentions round", "the output is non-empty", "P2's hand does not //! appear" — is satisfied by a render that shows a third of the state. //! The harness-does-nothing class, in its presentation form. //! //! So the renderer ships with [`tests::every_view_field_is_classified`], //! which walks the *serialized shape* of `GroundView` and requires every //! leaf path to be listed as either rendered or deliberately omitted. A //! field added to the projection and forgotten here fails the build. //! //! **Stated non-goal, unchanged from stage 0:** no TUI, no colour, no //! readline. This is the inspectable half of INTENT stage 1; the 2D half //! needs a rendering port, which needs an ADR, which this pass does not //! have (see the workplan's tier declaration). use cb_game_runtime::{Project, ScenarioGame}; use cb_kernel::{Aggregate, PlayerId}; use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; use games_ground::GroundState; pub fn suit_name(s: games_ground::Suit) -> &'static str { match s { games_ground::Suit::Clarify => "Clarify", games_ground::Suit::Repair => "Repair", games_ground::Suit::Boundary => "Boundary", games_ground::Suit::Change => "Change", } } pub fn seat_name(p: PlayerId) -> String { format!("P{}", p.0 + 1) } fn cards(list: &[games_ground::SolutionCard]) -> String { if list.is_empty() { return "-".into(); } list.iter() .map(|c| suit_name(c.suit)) .collect::>() .join(", ") } /// GR-A11/A12: the sub-choice, with its argument. Rendered from the /// variant rather than `{:?}` so the argument seat reads as `P3`, not /// `PlayerId(2)` — the inspector speaks the table's vocabulary. fn ground_choice(c: &games_ground::GroundChoice) -> String { use games_ground::GroundChoice as G; match c { G::RestoreProblem { problem } => format!("restore [{problem}]"), G::CancelAttack { attacker } => format!("cancel attack from {}", seat_name(*attacker)), G::ProtectProblem { problem } => format!("protect [{problem}]"), G::RemoveBlame { owner } => format!("remove blame from {}", seat_name(*owner)), G::BreakRelation { with } => format!("break relation with {}", seat_name(*with)), G::RejectReverse => "reject reverse".into(), } } /// The per-seat social line: everything the seat has *declared* this /// round, as opposed to what it *is*. Empty for a seat that has declared /// nothing, so a quiet table stays readable. fn render_declarations(id: PlayerId, view: &GroundView) -> String { let mut parts: Vec = Vec::new(); if let Some(target) = view.focus.get(&id) { parts.push(format!("focus→{}", seat_name(*target))); } if let Some(mode) = view.ground_modes.get(&id) { let name = match mode { games_ground::GroundMode::Gr => "GR", games_ground::GroundMode::Ou => "OU", games_ground::GroundMode::Nd => "ND", }; parts.push(format!("ground {name}")); } if let Some(choice) = view.ground_choices.get(&id) { parts.push(ground_choice(choice)); } if let Some(r) = view.support_responses.get(&id) { parts.push(format!("support {r:?}")); } if let Some(t) = view.darvo_targets.get(&id) { let mut bits: Vec = Vec::new(); if let Some(p) = t.problem { bits.push(format!("[{p}]")); } if let Some(p) = t.player { bits.push(seat_name(p)); } parts.push(format!( "darvo target {}", if bits.is_empty() { "none".into() } else { bits.join(" ") } )); } if parts.is_empty() { String::new() } else { format!(" {}\n", parts.join(" ")) } } fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String { let hand = match &p.hand { Some(list) => format!("hand [{}]", cards(list)), None => format!("hand {} card(s)", p.hand_size), }; format!( " {}{} stress {} freedom {}{} darvo {:?} protect {} blame {} {hand}\n", seat_name(id), if is_viewer { " (you)" } else { " " }, p.stress, if p.freedom_ready { "READY" } else { "SPENT" }, if p.freedom_gate_lifted { " gate-lifted" } else { "" }, p.darvo, p.protection, p.blame_from.len(), ) } /// One seat's whole picture, from the projection and nothing else. pub fn render(view: &GroundView) -> String { let mut out = String::new(); out.push_str(&format!( "\nround {} step {:?} lead {} mode {:?} deck {} discard [{}]\n", view.round, view.step, seat_name(view.lead), view.mode, view.solution_deck_len, cards(&view.solution_discard), )); for (id, p) in &view.players { out.push_str(&render_player(*id, p, Some(*id) == view.viewer)); out.push_str(&render_declarations(*id, view)); } out.push_str(" problems:"); for (priority, problem) in &view.problems { match problem { ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")), ProblemView::FaceUp { suit, value, denied, claimed_by, protected_this_round, } => { out.push_str(&format!( " [{priority}] {} {}{}{}{}", suit_name(*suit), value, if *denied { " DENIED" } else { "" }, if *protected_this_round { " PROTECTED" } else { "" }, match claimed_by { Some(p) => format!(" claimed by {}", seat_name(*p)), None => String::new(), } )); } } } out.push('\n'); if !view.relations.is_empty() { out.push_str(" relations:"); for (pair, relation) in &view.relations { out.push_str(&format!(" {pair} {relation:?}")); } out.push('\n'); } if !view.selections.is_empty() { out.push_str(" selections:"); for (id, sel) in &view.selections { match sel { SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))), SelectionView::Shown(s) => out.push_str(&format!( " {} {:?}{}{}", seat_name(*id), s.action, match s.target { Some(t) => format!("→{}", seat_name(t)), None => String::new(), }, match s.problem { Some(p) => format!("→[{p}]"), None => String::new(), } )), } } out.push('\n'); } if let Some(o) = &view.outcome { out.push_str(&format!( " OUTCOME total {} / threshold {} group {}\n", o.total, o.threshold, if o.group_success { "SUCCESS" } else { "failure" }, )); out.push_str(" personal:"); for (id, score) in &o.personal { out.push_str(&format!(" {} {score}", seat_name(*id))); } out.push('\n'); if let Some(m) = o.mastery { out.push_str(&format!(" mastery {m}\n")); } for c in &o.coalitions { out.push_str(&format!( " coalition [{}] score {}\n", c.members .iter() .map(|m| seat_name(*m)) .collect::>() .join(", "), c.score, )); } out.push_str(&format!( " winners {}\n", o.winners .iter() .map(|w| seat_name(*w)) .collect::>() .join(", "), )); } out } // ------------------------------------------------------------------ walk /// Which seat's projection a walk renders. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Eyes { Seat(PlayerId), Spectator, } impl Eyes { pub fn parse(raw: &str) -> Result { if raw.eq_ignore_ascii_case("spectator") { return Ok(Eyes::Spectator); } raw.parse::() .map(|n| Eyes::Seat(PlayerId(n))) .map_err(|_| format!("--as expects a 0-based seat or 'spectator', got {raw:?}")) } fn label(self) -> String { match self { Eyes::Seat(p) => format!("{} (their hand only)", seat_name(p)), Eyes::Spectator => "a spectator (no hands)".into(), } } fn viewer(self) -> cb_game_runtime::Viewer { match self { Eyes::Seat(p) => cb_game_runtime::Viewer::Player(p), Eyes::Spectator => cb_game_runtime::Viewer::Spectator, } } } /// What a completed walk reports. #[derive(Debug)] pub struct Walk { pub source: String, pub steps: usize, /// Steps the aggregate refused. A recorded game may legitimately /// contain them — a scenario can assert a rejection — so they are /// counted and shown, not treated as an error. pub rejected: usize, pub end_state_hash: String, /// `Some` only for a bundle, which records the hash its own producer /// computed. A scenario file may or may not pin one. pub expected_hash: Option, } /// Replay a recorded game and render the table after every step. /// /// This is the answer to *"what did the table look like when it went /// wrong?"* that previously required adding a `dbg!` and re-running. /// /// The bundle path goes through `replay::open`, the same reader /// `make replay-test` uses, so the states shown here are the states a /// replay reaches — structurally, not by assertion. The hash is then /// checked anyway, because a structural argument that is never executed /// is the class of claim this project keeps finding to be wrong. pub fn walk( source: &std::path::Path, eyes: Eyes, out: &mut W, ) -> Result { let (mut state, steps, expected_hash) = load(source)?; let name = source.display().to_string(); let _ = writeln!( out, "inspecting {name} as {} — {} step(s)", eyes.label(), steps.len() ); let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); let mut rejected = 0usize; for (i, step) in steps.iter().enumerate() { let (actor, command) = GroundState::parse_command(step)?; match state.validate(actor, &command) { Ok(produced) => { for event in produced { state.fold(&event); } let _ = writeln!(out, "\n[{}] {} {}", i + 1, step.actor, describe_step(step)); } Err(rejection) => { rejected += 1; let _ = writeln!( out, "\n[{}] {} {} — REJECTED: {rejection:?}", i + 1, step.actor, describe_step(step) ); } } let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); } let end_state_hash = cb_events::state_hash_hex(&state); if let Some(expected) = &expected_hash { if &end_state_hash != expected { return Err(format!( "the walk did not reproduce the recorded end state: {end_state_hash} != {expected}" )); } } let _ = writeln!( out, "\nend-state hash {end_state_hash}{}", match &expected_hash { Some(_) => " (matches the recording)", None => " (the source pins no hash)", } ); Ok(Walk { source: name, steps: steps.len(), rejected, end_state_hash, expected_hash, }) } /// A `.cbreplay` bundle or a scenario YAML. Both already reconstruct a /// command sequence; neither needed a new format for this. fn load( source: &std::path::Path, ) -> Result< ( GroundState, Vec, Option, ), String, > { if source.is_dir() { let (manifest, state, steps) = cb_game_runtime::replay::open::(source)?; return Ok((state, steps, Some(manifest.end_state_hash))); } let yaml = std::fs::read_to_string(source).map_err(|e| format!("read {}: {e}", source.display()))?; let file = cb_game_runtime::ScenarioFile::from_yaml(&yaml) .map_err(|e| format!("parse {}: {e}", source.display()))?; let state = GroundState::setup(&file.setup, file.seed)?; Ok((state, file.commands, file.expect.state_hash)) } fn describe_step(step: &cb_game_runtime::CommandStep) -> String { let mut out = step.cmd.clone(); for (key, value) in &step.args { let rendered = match value { serde_yaml::Value::String(s) => s.clone(), other => serde_yaml::to_string(other) .unwrap_or_default() .trim() .to_string(), }; out.push_str(&format!(" {key}={rendered}")); } out } #[cfg(test)] mod tests { use super::*; use games_ground::view::OutcomeView; use games_ground::*; use std::collections::BTreeMap; /// Every leaf path in the serialized `GroundView`, with map keys and /// array indices collapsed to `*`. /// /// Paths, not keys: `problem` appears under a DARVO target, a GROUND /// choice and a Selection, and a key-set walk would let one of the /// three vouch for the other two. fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { match v { serde_json::Value::Object(map) => { for (k, child) in map { let next = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; paths(child, &next, out); } } serde_json::Value::Array(items) => { for item in items { paths(item, &format!("{prefix}.*"), out); } } _ => out.push(prefix.to_string()), } } /// A `BTreeMap` serializes as an object, so its *keys* look like /// struct fields. Collapse any path segment that is a map key — /// seat names, Problem priorities, `Pair` keys — to `*`. fn normalize(path: &str) -> String { let maps = [ "players", "relations", "problems", "focus", "selections", "ground_modes", "ground_choices", "support_responses", "darvo_targets", "personal", ]; let mut parts: Vec = Vec::new(); let mut collapse_next = false; for seg in path.split('.') { if collapse_next { parts.push("*".into()); collapse_next = false; continue; } parts.push(seg.to_string()); collapse_next = maps.contains(&seg); } parts.join(".") } /// What the inspector shows, and the token that proves it does. The /// token is asserted against the fixture's output — delete a field /// from `render` and its row goes red naming the field. const RENDERED: &[(&str, &str)] = &[ ("round", "round 3"), ("step", "step Resolve"), ("lead", "lead P2"), ("mode", "mode BondedCoalitions"), ("viewer", "(you)"), ("solution_deck_len", "deck 11"), ("solution_discard.*.suit", "discard [Repair, Change]"), ("players.*.stress", "stress 4"), ("players.*.freedom_ready", "freedom SPENT"), ("players.*.freedom_gate_lifted", "gate-lifted"), ("players.*.darvo", "darvo Attack"), ("players.*.protection", "protect 2"), ("players.*.blame_from.*", "blame 2"), ("players.*.hand.*.suit", "hand [Clarify, Boundary]"), ("players.*.hand_size", "hand 4 card(s)"), ("relations.*", "Rivalry"), ("problems.*.state", "face-down"), ("problems.*.suit", "Boundary"), ("problems.*.value", "Boundary 9"), ("problems.*.denied", "DENIED"), ("problems.*.protected_this_round", "PROTECTED"), ("problems.*.claimed_by", "claimed by P3"), ("focus.*", "focus→P3"), ("ground_modes.*", "ground OU"), ("ground_choices.*.choice", "cancel attack from"), ("ground_choices.*.attacker", "cancel attack from P2"), ("support_responses.*", "support FlipToBond"), ("darvo_targets.*.problem", "darvo target [7]"), ("darvo_targets.*.player", "darvo target [7] P1"), ("selections.*.state", "face-down"), ("selections.*.action", "Investigate"), ("selections.*.target", "Investigate→P2"), ("selections.*.problem", "→[7]"), ("outcome.total", "total 18"), ("outcome.threshold", "threshold 15"), ("outcome.group_success", "group SUCCESS"), ("outcome.personal.*", "P1 6"), ("outcome.mastery", "mastery 3"), ("outcome.coalitions.*.members.*", "coalition [P1, P2]"), ("outcome.coalitions.*.score", "score 11"), ("outcome.winners.*", "winners P1, P2"), ]; /// Deliberately not shown, each with the reason. /// /// **This list is an unchecked claim**, and saying so is cheaper than /// pretending otherwise: the test proves a `RENDERED` row is really /// rendered, and proves nothing about an `OMITTED` one. What it does /// enforce is that the claim was *made* — a new field cannot arrive /// silently in either list. const OMITTED: &[(&str, &str)] = &[ // `viewer: null` is a spectator, rendered by the *absence* of // "(you)" — there is no token for it, and adding one would mean // printing a line that says nothing. // `null` for every seat but the viewer — GR-S02. There is no // content to show, and the absence is exactly what the count // form (`hand 4 card(s)`) renders. A token here would assert // that the inspector prints something about a thing it must not // print anything about. ( "players.*.hand", "null for a non-viewer seat; the absence is rendered as a count", ), ]; /// Round 3, mid-DARVO, mid-GROUND, scored. Built by hand rather than /// played: at deal time two thirds of these fields are empty, and a /// coverage test run against absent fields is the same lie in a /// different costume. fn fixture() -> GroundView { let (p1, p2, p3) = (PlayerId(0), PlayerId(1), PlayerId(2)); let card = |suit| SolutionCard { suit }; let mut players = BTreeMap::new(); players.insert( p1, PlayerView { stress: 4, freedom_ready: false, freedom_gate_lifted: true, darvo: DarvoStage::Attack, protection: 2, blame_from: vec![p2, p3], hand: Some(vec![card(Suit::Clarify), card(Suit::Boundary)]), hand_size: 2, }, ); for (id, stress) in [(p2, 1u8), (p3, 0)] { players.insert( id, PlayerView { stress, freedom_ready: true, freedom_gate_lifted: false, darvo: DarvoStage::Off, protection: 0, blame_from: vec![], hand: None, hand_size: 4, }, ); } let mut problems = BTreeMap::new(); problems.insert(1, ProblemView::FaceDown); problems.insert( 7, ProblemView::FaceUp { suit: Suit::Boundary, value: 9, denied: true, claimed_by: Some(p3), protected_this_round: true, }, ); GroundView { viewer: Some(p1), round: 3, lead: p2, step: RoundStep::Resolve, mode: ScoringMode::BondedCoalitions, players, relations: BTreeMap::from([ (Pair::new(p1, p2), Relation::Bond), (Pair::new(p2, p3), Relation::Rivalry), ]), problems, focus: BTreeMap::from([(p1, p3)]), selections: BTreeMap::from([ ( p1, SelectionView::Shown(Selection { action: Action::Investigate, target: Some(p2), problem: Some(7), }), ), (p2, SelectionView::Hidden), ]), ground_modes: BTreeMap::from([(p1, GroundMode::Ou)]), ground_choices: BTreeMap::from([(p1, GroundChoice::CancelAttack { attacker: p2 })]), support_responses: BTreeMap::from([(p2, SupportResponse::FlipToBond)]), darvo_targets: BTreeMap::from([( p1, DarvoTarget { problem: Some(7), player: Some(p1), }, )]), solution_deck_len: 11, solution_discard: vec![card(Suit::Repair), card(Suit::Change)], outcome: Some(OutcomeView { total: 18, threshold: 15, group_success: true, personal: BTreeMap::from([(p1, 6), (p2, 5), (p3, 7)]), coalitions: vec![Coalition { members: vec![p1, p2], score: 11, }], mastery: Some(3), winners: vec![p1, p2], }), } } fn fixture_paths() -> Vec { let json = serde_json::to_value(fixture()).expect("view serializes"); let mut raw = Vec::new(); paths(&json, "", &mut raw); let mut all: Vec = raw.iter().map(|p| normalize(p)).collect(); all.sort(); all.dedup(); all } /// The gate: every field the projection carries is classified, and /// every field claimed rendered really is. #[test] fn every_view_field_is_classified() { let out = render(&fixture()); let all = fixture_paths(); // EXPECT-VACUOUS control. A coverage test over an empty path set // passes trivially, and that is precisely how this check would // rot — a serde change, a flattened field, a walk that stops at // the first map. `GroundView` has 17 fields and the fixture // populates all of them; 30 leaves is a floor, not a count, so // adding a field never fails this line for the wrong reason. assert!( all.len() >= 30, "the walk found {} leaf path(s) — it is not walking the view", all.len() ); let rendered: Vec<&str> = RENDERED.iter().map(|(p, _)| *p).collect(); let omitted: Vec<&str> = OMITTED.iter().map(|(p, _)| *p).collect(); let unclassified: Vec<&String> = all .iter() .filter(|p| !rendered.contains(&p.as_str()) && !omitted.contains(&p.as_str())) .collect(); assert!( unclassified.is_empty(), "new field(s) in GroundView are neither rendered nor declared omitted: {unclassified:?}\n\ add each to RENDERED (with a token the inspector prints) or to OMITTED (with a reason)" ); let stale: Vec<&str> = rendered .iter() .chain(omitted.iter()) .filter(|p| !all.contains(&p.to_string())) .copied() .collect(); assert!( stale.is_empty(), "classified path(s) no longer exist in GroundView: {stale:?}" ); for (path, token) in RENDERED { assert!( out.contains(token), "{path} is claimed rendered, but the output has no {token:?}\n--- output ---\n{out}" ); } } /// The projection decides what is visible; the inspector must not /// widen it. P1 is the viewer, so P2's and P3's hands are `None` and /// only their sizes may appear. #[test] fn the_inspector_never_widens_the_projection() { let out = render(&fixture()); assert!(out.contains("hand [Clarify, Boundary]"), "{out}"); assert_eq!( out.matches("hand 4 card(s)").count(), 2, "both non-viewer seats show a count and nothing more\n{out}" ); // P2 selected face-down; the tag carries no Selection to leak, // and the render must not invent one. assert!(out.contains("P2 face-down"), "{out}"); } /// A spectator sees no hand at all and is not told they are anyone. #[test] fn a_spectator_view_renders_without_a_seat() { let mut view = fixture(); view.viewer = None; view.players.get_mut(&PlayerId(0)).unwrap().hand = None; let out = render(&view); assert!(!out.contains("(you)"), "{out}"); assert!(!out.contains("hand ["), "{out}"); } // ------------------------------------------------------- the walk /// A recorded game, produced the way a user produces one: play it /// with bots and ask for a bundle. Fixtures written by hand would /// test the reader against the writer's assumptions rather than /// against what the writer actually writes. fn recorded(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) { let dir = std::env::temp_dir().join(format!("cb-inspect-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("tmp"); let config = crate::table::Config { seed: 42, players: 3, human_seats: vec![], bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), serve: None, }; let mut sink: Vec = Vec::new(); let summary = crate::table::play(&config, "".as_bytes(), &mut sink).expect("the bot game runs"); ( summary.bundle.expect("bundle"), summary.recorded.expect("scenario"), summary.end_state_hash, ) } /// The acceptance: an inspector that shows a state the replay never /// reached is worse than no inspector. Both source kinds are walked, /// because "it works for bundles" was the shape of the last three /// half-checked claims in this repo. #[test] fn a_walk_reproduces_the_recorded_end_state() { let (bundle, scenario, hash) = recorded("walk"); let mut out: Vec = Vec::new(); let report = super::walk(&bundle, Eyes::Spectator, &mut out).expect("bundle walks"); assert_eq!(report.end_state_hash, hash); assert_eq!(report.expected_hash.as_deref(), Some(hash.as_str())); assert!( report.steps > 5, "a 3-player game is more than {} steps", report.steps ); // The render must have run once per step plus once for the // initial state — otherwise the walk "succeeded" having shown // nothing, which is the harness-does-nothing shape. let text = String::from_utf8(out).expect("utf8"); assert_eq!( text.matches(" problems:").count(), report.steps + 1, "one table per step plus the opening one\n{text}" ); let mut out: Vec = Vec::new(); let from_yaml = super::walk(&scenario, Eyes::Spectator, &mut out).expect("scenario walks"); assert_eq!(from_yaml.end_state_hash, hash); let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); } /// The control for the assertion above. A bundle whose recorded hash /// has been altered must make the walk fail, or the hash comparison /// is decoration. #[test] fn a_walk_that_does_not_reproduce_fails() { let (bundle, _, hash) = recorded("tamper"); let manifest = bundle.join("manifest.yaml"); let text = std::fs::read_to_string(&manifest).expect("read manifest"); std::fs::write( &manifest, text.replace( &hash, "0000000000000000000000000000000000000000000000000000000000000000", ), ) .expect("write manifest"); let mut out: Vec = Vec::new(); let err = super::walk(&bundle, Eyes::Spectator, &mut out).expect_err("tampered bundle must fail"); assert!(err.contains("did not reproduce"), "wrong reason: {err}"); let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); } /// `--as` is a projection, not a filter applied afterwards. Seat 2 /// (P3) sees its own hand and counts for the rest. /// /// Seated deliberately at the *last* seat: the equivalent stage-0 /// test was vacuous twice (CB-EV-0007) because it inspected a /// position where the hidden thing had not yet been written. #[test] fn a_seat_walk_shows_that_seat_and_no_other() { let (bundle, _, _) = recorded("eyes"); let mut out: Vec = Vec::new(); super::walk(&bundle, Eyes::Seat(PlayerId(2)), &mut out).expect("walks"); let seated = String::from_utf8(out).expect("utf8"); let mut out: Vec = Vec::new(); super::walk(&bundle, Eyes::Spectator, &mut out).expect("walks"); let spectator = String::from_utf8(out).expect("utf8"); // Exactly one seat shows cards, and it is P3. let tables = seated.matches(" problems:").count(); assert_eq!( seated.matches("hand [").count(), tables, "P3 shows a hand in every table and nobody else does\n{seated}" ); for line in seated.lines().filter(|l| l.contains("hand [")) { assert!(line.contains("P3 (you)"), "a hand leaked: {line}"); } // A spectator sees none at all — the same walk, one argument // apart, so a render that ignored `Eyes` would fail here. assert!(!spectator.contains("hand ["), "{spectator}"); assert!(!spectator.contains("(you)"), "{spectator}"); assert_ne!(seated, spectator); let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); } #[test] fn eyes_parse_and_bad_ones_are_refused() { assert_eq!(Eyes::parse("2").unwrap(), Eyes::Seat(PlayerId(2))); assert_eq!(Eyes::parse("SPECTATOR").unwrap(), Eyes::Spectator); assert!(Eyes::parse("P3").is_err()); assert!(Eyes::parse("").is_err()); } }