clay-borg/tools/cb-play/src/inspect.rs
tegwick 84d688688d CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.

  games-ground shipped:  23 third-party crates
  cb-render-html:        23 third-party crates
  new crates introduced:  0

Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.

What shipped:
  crates/cb-render-html  doc.rs (HTML/SVG emission, incl. the relationship
                         graph), input.rs (pointer facts -> commands),
                         serve.rs (Guard, Request, loopback bind)
  tools/cb-play          hotseat.rs + `--serve PORT`

Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.

The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):

  1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
  4   a token-less request is refused, in the unit AND over a real socket
  5   JS may not construct commands — the page reports pointer facts, Rust
      resolves them against the legal list the aggregate already offered,
      and a test asserts the emitted script contains no game vocabulary
  6   the coverage gate crosses the language boundary: it walks the
      serialized view for leaf paths and requires each token to appear in
      the PARSED emitted document, with a test that the parse really is a
      parse (script/style contents must not count as rendered)

The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.

Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00

879 lines
32 KiB
Rust

//! 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::<Vec<_>>()
.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<String> = 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<String> = 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::<Vec<_>>()
.join(", "),
c.score,
));
}
out.push_str(&format!(
" winners {}\n",
o.winners
.iter()
.map(|w| seat_name(*w))
.collect::<Vec<_>>()
.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<Self, String> {
if raw.eq_ignore_ascii_case("spectator") {
return Ok(Eyes::Spectator);
}
raw.parse::<u8>()
.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<String>,
}
/// 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<W: std::io::Write>(
source: &std::path::Path,
eyes: Eyes,
out: &mut W,
) -> Result<Walk, String> {
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<cb_game_runtime::CommandStep>,
Option<String>,
),
String,
> {
if source.is_dir() {
let (manifest, state, steps) = cb_game_runtime::replay::open::<GroundState>(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<String>) {
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<String> = 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<String> {
let json = serde_json::to_value(fixture()).expect("view serializes");
let mut raw = Vec::new();
paths(&json, "", &mut raw);
let mut all: Vec<String> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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());
}
}