//! `cb-render-html` — stage 1's renderer (ADR-0007). //! //! Emits HTML with inline SVG and one small piece of inline JavaScript; //! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no //! third-party dependencies at all, and adding one requires an argument //! against ADR-0007 §Decision 3. //! //! ## What this is not //! //! It is **not** `cb-render-api`, and there is no `cb-render-null`. //! ADR-0007 Decision 2 withdrew the port: a capability port designed //! against one implementation that emits whole documents acquires a //! document's shape, and stage 2's `wgpu` renderer would find it //! unimplementable. INTENT's rule is the one that binds — //! //! > *No concept becomes canonical merely because it looks general. It //! > becomes canonical after surviving a second concrete use.* //! //! — so this renders against the existing [`cb_game_runtime::Project`] //! trait, which is a real interface with real implementations, and the //! port is declared at stage 2 when there are two. //! //! ## The controls, and why they exist //! //! CB-WP-0011 established that a renderer's defect class is **silent //! omission**: every assertion a renderer test naturally makes is //! satisfied by a renderer showing a third of the state. This crate moves //! the interactive half of stage 1 into a language `cargo test`, //! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that //! finding across the boundary: //! //! * [`input`] — **JavaScript may not construct commands.** The page //! reports raw pointer facts; Rust decides what they mean, against the //! legal list the aggregate already offered. //! * the coverage gate below — asserts over the **parsed emitted //! document**, not over the Rust that emits it. Asserting over the //! emitting code would reproduce CB-WP-0011's original defect one layer //! up. //! //! And [`serve`] carries the four that make a loopback listener safe to //! have, of which the load-bearing one is a test that a token-less request //! is refused. pub mod doc; pub mod input; #[cfg(any(test, feature = "js-harness"))] pub mod jsrun; pub mod serve; pub use doc::{document, text_of, Endpoints}; /// The endpoint pair every test in this crate posts to. #[cfg(test)] const TEST_ENDPOINTS: Endpoints<'static> = Endpoints { command: "/command?t=x", note: "/note?t=x", }; pub use input::{resolve, Note, PointerFact}; pub use serve::{Guard, Refusal, Request}; #[cfg(test)] mod coverage { //! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s //! `every_view_field_is_classified`. //! //! Same shape as CB-WP-0011's gate, one layer further out: walk the //! serialized `GroundView` for every leaf *path*, and require each to //! be either rendered — with a token that must appear in the **parsed //! document** — or omitted with a stated reason. A new field on //! `GroundView` that is in neither list fails the build. //! //! Paths, not keys: `problem` occurs under a DARVO target, a GROUND //! choice and a Selection, and a key-set walk would let one vouch for //! the other two. use cb_kernel::PlayerId; use games_ground::view::GroundView; use crate::doc::{document, text_of}; /// Every leaf path in the serialized view. fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { match v { serde_json::Value::Object(m) if !m.is_empty() => { for (k, val) in m { let p = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; paths(val, &p, out); } } serde_json::Value::Array(a) if !a.is_empty() => { for item in a { paths(item, &format!("{prefix}.*"), out); } } _ => out.push(prefix.to_string()), } } /// Collapse map keys to `*` so the classification is over fields, not /// over whichever seats and priorities the fixture happens to hold. fn normalize(path: &str) -> String { const MAPS: &[&str] = &[ "players", "relations", "problems", "focus", "selections", "ground_modes", "ground_choices", "support_responses", "darvo_targets", "personal", ]; let mut parts: Vec = Vec::new(); let mut prev_is_map = false; for seg in path.split('.') { if prev_is_map && seg != "*" { parts.push("*".to_string()); } else { parts.push(seg.to_string()); } prev_is_map = MAPS.contains(&seg); } parts.join(".") } /// `(leaf path, a token the parsed document must contain)`. const RENDERED: &[(&str, &str)] = &[ ("round", "round 3"), ("lead", "lead P2"), ("step", "step Select"), ("mode", "scoring BondedCoalitions"), ("viewer", "viewing as P1"), ("solution_deck_len", "draw pile: 17 remaining"), ("solution_discard.*.suit", "discard Repair Change"), ("players.*.stress", "stress 5"), ("players.*.protection", "protect 2"), ("players.*.darvo", "darvo Reverse"), ("players.*.freedom_ready", "READY"), ("players.*.freedom_gate_lifted", "gate lifted"), ("players.*.blame_from.*", "blamed by P3"), // The empty case is a leaf path of its own, and renders as an // explicit absence rather than as nothing at all. ("players.*.blame_from", "blamed by none"), ("players.*.hand.*.suit", "hand Clarify Boundary"), ("players.*.hand_size", "cards)"), ("relations.*", "Rivalry"), ("problems.*.state", "face down"), ("problems.*.suit", "Change 6"), ("problems.*.value", "Change 6"), ("problems.*.denied", "denied"), ("problems.*.claimed_by", "claimed by P2"), ("problems.*.protected_this_round", "protected"), ("focus.*", "\u{2192}P3"), ("selections.*.state", "face down"), // CB-WP-0020 T04: words, not Debug. These tokens were // `action: Attack` / `target: Some(PlayerId(1))` / // `problem: Some(7)` — the shape a player was being shown. ("selections.*.action", "selected Attack"), ("selections.*.target", "Attack on P2"), ("selections.*.problem", "for problem 7"), ("ground_modes.*", "ground mode Gr"), ("ground_choices.*.choice", "ProtectProblem"), ("ground_choices.*.problem", "problem: 7 }"), ("support_responses.*", "support AcceptBond"), ("darvo_targets.*.problem", "problem: Some(1)"), ("darvo_targets.*.player", "player: Some(PlayerId(1))"), ("outcome.total", "total 9"), ("outcome.threshold", "of 12"), ("outcome.group_success", "failure"), ("outcome.personal.*", "P1 +3"), ("outcome.coalitions.*.members.*", "members: [PlayerId(1)"), ("outcome.coalitions.*.score", "score: 4"), ("outcome.mastery", "mastery +1"), ("outcome.winners.*", "winners P1"), ]; /// Deliberate omissions, each with a reason. const OMITTED: &[(&str, &str)] = &[( "players.*.hand", "null for a non-viewer seat; the absence is rendered as a count", )]; fn fixture() -> GroundView { crate::testfix::view(Some(PlayerId(0))) } fn rendered_document(view: &GroundView) -> String { text_of(&document( view, &[], crate::TEST_ENDPOINTS.command, Some(PlayerId(0)), false, )) } #[test] fn every_view_field_is_classified_in_the_emitted_document() { let view = fixture(); let json = serde_json::to_value(&view).expect("view serializes"); let mut raw = Vec::new(); paths(&json, "", &mut raw); let all: std::collections::BTreeSet = raw.iter().map(|p| normalize(p)).collect(); // EXPECT-VACUOUS floor. A coverage test over zero paths passes // trivially, and that is precisely how this check would rot. assert!( all.len() >= 30, "the walk found {} leaf path(s) — it is not walking the view", all.len() ); let claimed: std::collections::BTreeSet<&str> = RENDERED .iter() .map(|(p, _)| *p) .chain(OMITTED.iter().map(|(p, _)| *p)) .collect(); let unclassified: Vec<&String> = all .iter() .filter(|p| !claimed.contains(p.as_str())) .collect(); assert!( unclassified.is_empty(), "unclassified path(s) in GroundView: {unclassified:?} \ — add each to RENDERED with a token, or to OMITTED with a reason" ); let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect(); assert!( stale.is_empty(), "classified path(s) no longer exist in GroundView: {stale:?}" ); // The half that makes it a rendering gate rather than a bookkeeping // one: the token must be in the *parsed document*. let text = rendered_document(&view); let missing: Vec<&str> = RENDERED .iter() .filter(|(_, tok)| !text.contains(tok)) .map(|(p, _)| *p) .collect(); assert!( missing.is_empty(), "claimed rendered, but absent from the emitted document: {missing:?}" ); } /// The parse must be a parse. If `text_of` returned the raw source, a /// token hiding in a comment, a style rule or the script would count /// as rendered — which is the loophole control 6 exists to close. #[test] fn the_parse_does_not_see_script_or_style_contents() { let html = "

seen

\ "; let text = text_of(html); assert!(text.contains("seen")); assert!(text.contains("pid"), "element ids are addressable surface"); assert!( !text.contains("STYLETOKEN"), "style contents leaked into the text" ); assert!( !text.contains("SCRIPTTOKEN"), "script contents leaked into the text" ); } /// Control 5, asserted at the document level: the emitted JavaScript /// must contain no game vocabulary. If a rule ever needs to live in /// the page, ADR-0007 says revisit the decision, not widen this. #[test] fn the_emitted_javascript_contains_no_game_vocabulary() { let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false); let script = doc .split_once("")) .map(|(s, _)| s.to_string()) .expect("the document carries a script"); for word in [ "Investigate", "Solve", "Support", "Attack", "Ground", "darvo", "DARVO", "stress", "freedom", "problem", "coalition", "reveal", "resolve", ] { assert!( !script.contains(word), "the emitted JavaScript mentions {word:?} — control 5 is breached" ); } // And it must still be doing its one job. assert!(script.contains("pointerdown") && script.contains("pointerup")); } /// The renderer must not invent a hand it was never given. /// /// Scoped deliberately: the *projection's* hiding rules are asserted /// where they live, and re-asserting them here would be a duplicated /// fact that drifts. What this checks is the renderer's own failure /// mode — that `hand: None` renders as an absence, for every seat, and /// that a spectator's document contains no open hand at all. #[test] fn the_renderer_never_invents_a_hand_it_was_not_given() { for seat in [0u8, 1, 2] { let view = crate::testfix::view(Some(PlayerId(seat))); let text = rendered_document(&view); let shown = text.matches("cards)").count(); assert_eq!( shown, 1, "P{} sees {shown} open hands in the document; it was given exactly one", seat + 1 ); assert_eq!( text.matches("card(s), hidden").count(), 2, "the other two seats' hands must render as an absence with a count" ); } let text = rendered_document(&crate::testfix::view(None)); assert_eq!( text.matches("cards)").count(), 0, "a spectator document shows an open hand" ); assert!(text.contains("a spectator (no hands)")); } } /// CB-WP-0016 T03: every affordance the page offers must name an element /// the page actually contains. /// /// **This is the check that closes the class the human check found.** On /// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an /// action onto a seat. Every test in the repo was green. The cause: the /// visible seat cards carried no `id`, so `seat-{n}` existed only on the /// 26 px circles inside the relationship graph, while every action card /// read *"drag Attack onto a seat…"*. /// /// Nothing could catch it. `jsrun::gesture` feeds element ids straight /// into a synthetic `{target:{id}}` and never hit-tests, so it establishes /// *"the script posts the ids it was given"* — never *"there is an element /// there to give."* The 42-path coverage gate asserts each view field is /// present in the parsed document, which a `
` with no id satisfies. /// /// So: drive a real game, and at every decision point require that both /// halves of every offered affordance appear as real `id` attributes. #[cfg(test)] mod affordances { use std::cell::RefCell; use std::rc::Rc; use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer}; use cb_kernel::PlayerId; use games_ground::bot::{play, Choice, Policy, RandomPolicy}; use games_ground::{GroundCommand, GroundState}; use crate::{doc, input}; fn fresh(seed: u64) -> GroundState { GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: std::collections::BTreeMap::new(), }, seed, ) .expect("a standard 3p deal") } /// Renders the page at every real decision point and checks it, then /// delegates the actual choice. /// /// Hooking `Policy` rather than re-driving the game by hand matters: /// these are the *same* decision points `cb-play --serve` renders at, /// with the same `legal` list. A hand-rolled walk would be a second /// implementation of the loop, and could agree with itself while /// disagreeing with the thing shipped. struct CheckingPolicy { inner: RandomPolicy, checked: Rc>, } impl Policy for CheckingPolicy { fn name(&self) -> &'static str { "affordance-checking" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { let view = state.project(Viewer::Player(seat)); let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass); let present = doc::drop_keys(&html); for cmd in legal { let Some((from, to)) = input::affordance(cmd, seat) else { // A command with no affordance is offered through the // numbered-button path instead. That is a stated // shape, not a missing element. continue; }; assert!( present.contains(&from), "the page offers {cmd:?} whose GRAB id {from:?} is not an \ element in the document (seat {seat:?}, step {:?})", state.step ); assert!( present.contains(&to), "the page offers {cmd:?} whose DROP id {to:?} is not an \ element in the document (seat {seat:?}, step {:?}). \ Present ids: {present:?}", state.step ); } *self.checked.borrow_mut() += 1; self.inner.choose(state, seat, legal, may_pass) } } /// **The check that closes the class the human check found.** /// /// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not /// drag an action onto a seat. Every test in the repo was green. The /// cause: the visible seat cards carried no `id`, so `seat-{n}` existed /// only on the 26 px circles inside the relationship graph, while every /// action card read *"drag Attack onto a seat…"*. /// /// Nothing could catch it. `jsrun::gesture` feeds element ids straight /// into a synthetic `{target:{id}}` and never hit-tests, so it /// establishes *"the script posts the ids it was given"* — never /// *"there is an element there to give."* The coverage gate asserts /// each view field appears in the parsed document, which a `
` with /// no id satisfies perfectly. /// /// An affordance naming an element that does not exist is the /// harness-does-nothing shape in the presentation layer, and until now /// it had no detector at all. #[test] fn every_offered_affordance_names_an_element_that_exists() { let checked = Rc::new(RefCell::new(0usize)); for seed in 0..4u64 { let mut policies: Vec> = (0..3) .map(|i| { Box::new(CheckingPolicy { inner: RandomPolicy::new(seed * 10 + i), checked: checked.clone(), }) as Box }) .collect(); play(fresh(seed), &mut policies).expect("a bot game completes"); } // Positive control: a run that rendered nothing would assert // nothing and read as a pass — the exact failure this test exists // to catch, one level up. let n = *checked.borrow(); assert!(n >= 50, "checked only {n} decision point(s)"); } /// **ADR-0010 Decision 2, property 2.** A target the page marks legal /// must resolve. /// /// If the page can advertise a drop that Rust then refuses, the two /// have drifted and the highlighting is *worse* than none — it teaches /// the player something false. This walks real games and checks the /// emitted `data-targets` against `resolve` itself, so the page's /// promise and the referee's answer cannot disagree. #[test] fn everything_the_page_advertises_actually_resolves() { let checked = Rc::new(RefCell::new(0usize)); for seed in 0..4u64 { let mut policies: Vec> = (0..3) .map(|i| { Box::new(AdvertisedPolicy { inner: RandomPolicy::new(seed * 7 + i), checked: checked.clone(), }) as Box }) .collect(); play(fresh(seed), &mut policies).expect("a bot game completes"); } let n = *checked.borrow(); assert!(n >= 50, "checked only {n} advertised target(s)"); } struct AdvertisedPolicy { inner: RandomPolicy, checked: Rc>, } impl Policy for AdvertisedPolicy { fn name(&self) -> &'static str { "advertised-target-checking" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { let view = state.project(Viewer::Player(seat)); let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass); for (grab, targets) in crate::jsrun::droppables(&html) { let Some(spec) = targets else { continue }; for drop in spec.split(' ').filter(|s| !s.is_empty()) { let fact = crate::PointerFact::new(&grab, drop); assert!( crate::resolve(&fact, legal, seat).is_ok(), "the page advertises {grab} -> {drop} but resolve refuses it \ (seat {seat:?}, step {:?})", state.step ); *self.checked.borrow_mut() += 1; } } self.inner.choose(state, seat, legal, may_pass) } } /// **The regression test for the defect actually reported**, and the /// reason the check above is not sufficient on its own. /// /// `every_offered_affordance_names_an_element_that_exists` passes on /// the broken tree. `seat-0` *did* exist — on the 26 px circle in the /// relationship graph — so an existence check over the whole document /// cannot see that the seat *card*, which is what the instruction text /// points at, was not droppable. /// /// A seat is drawn twice and both drawings are the seat. This asserts /// the card specifically, by requiring the drop key on the element /// that also carries `data-viewer` — the card, and nothing else. #[test] fn every_seat_card_is_a_drop_target_not_only_the_graph_node() { let view = crate::testfix::view(Some(PlayerId(0))); let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false); for seat in view.players.keys() { let card = format!( "data-viewer=\"{}\" data-drop=\"seat-{}\"", view.viewer == Some(*seat), seat.0 ); assert!( html.contains(&card), "seat {seat:?} has a card that is not a drop target; looked for {card:?}" ); } // And the graph node keeps working — someone will have learned to // aim at the circle, and this fix must not take that away. let keys = doc::drop_keys(&html); for seat in view.players.keys() { assert!(keys.contains(&format!("seat-{}", seat.0))); } assert_eq!( html.matches("data-drop=\"seat-0\"").count(), 2, "seat 0 should be droppable in exactly two places: card and graph node" ); } } #[cfg(test)] mod gamelog { //! CB-WP-0018 T02. use crate::doc::{document_with_log, LogLine}; use cb_kernel::PlayerId; fn line(effects: &[&str]) -> LogLine { LogLine { who: "P1".into(), what: "select_action action=SOLVE problem=1".into(), effects: effects.iter().map(|s| (*s).to_string()).collect(), } } fn page(log: &[LogLine]) -> String { document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, log, &[], ) } /// **The case the pass was reported for.** A SOLVE that cannot be /// fulfilled produces no events, and a log built only from events /// would render nothing for it — reproducing the silence the /// maintainer hit when the same move did nothing three rounds /// running. #[test] fn a_command_that_produced_nothing_says_so() { let html = page(&[line(&[])]); assert!( html.contains("no effect"), "a command with no events rendered as if it had done something" ); let text = crate::text_of(&html); assert!(text.contains("select_action action=SOLVE problem=1")); } #[test] fn effects_are_listed_and_an_empty_log_says_it_is_empty() { let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])])); assert!(text.contains("problem 1 claimed by P1")); assert!( !text.contains("no effect"), "a command WITH effects was marked as having none" ); assert!(crate::text_of(&page(&[])).contains("nothing has happened yet")); } } /// CB-WP-0024 T02 — the draw and discard stacks as objects on the table. /// /// The maintainer asked for the piles to be visible and for the discard /// to show a shuffle when the draw runs out. **The reshuffle is real** — /// `games/ground/src/lib.rs::draw_solution` implements the U4 default /// (deterministic reshuffle of the discard; skip the draw if both are /// empty), which ground-game confirmed on 2026-08-03. So this renders the /// state in which the next draw triggers it, rather than inventing a rule. #[cfg(test)] mod piles { use cb_kernel::PlayerId; use games_ground::view::GroundView; use crate::doc::document; fn view() -> GroundView { crate::testfix::view(Some(PlayerId(0))) } fn html(v: &GroundView) -> String { document(v, &[], "/command?t=x", Some(PlayerId(0)), false) } /// The counts must come from the projection, never be recomputed. #[test] fn both_counts_are_the_views_own_numbers() { let mut v = view(); v.solution_deck_len = 5; let doc = crate::text_of(&html(&v)); assert!( doc.contains("draw pile: 5 remaining"), "the drawn deck count is not the view's: {doc}" ); let n = v.solution_discard.len(); assert!( doc.contains(&format!("discard pile: {n} remaining")), "the drawn discard count is not the view's ({n}): {doc}" ); } /// An empty discard is an EMPTY pile, not a missing one. A absent slot /// reads as "this game has no discard", which is a different claim. #[test] fn an_empty_pile_is_drawn_rather_than_omitted() { let mut v = view(); v.solution_discard.clear(); let doc = crate::text_of(&html(&v)); assert!( doc.contains("discard pile: 0 remaining"), "an empty discard vanished instead of rendering as empty: {doc}" ); } /// The U4 state: deck empty, discard holding cards. The next draw /// reshuffles, and the table should say so. #[test] fn an_exhausted_deck_says_the_discard_shuffles_back_in() { let mut v = view(); v.solution_deck_len = 0; assert!(!v.solution_discard.is_empty(), "fixture needs a discard"); let doc = crate::text_of(&html(&v)); assert!( doc.contains("shuffles in on next draw"), "an exhausted deck did not announce the U4 reshuffle: {doc}" ); // And the negative half: with cards left, no shuffle is promised. let mut full = view(); full.solution_deck_len = 12; assert!( !crate::text_of(&html(&full)).contains("shuffles in on next draw"), "a stocked deck claimed a reshuffle was coming" ); } } /// CB-WP-0028 T03 — one overhead table, not three diagrams. #[cfg(test)] mod overhead_table { use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer}; use cb_kernel::PlayerId; use games_ground::GroundState; /// Seat circle centres, read out of the emitted SVG. /// /// Keyed on `class="seat"`, not on ``: CB-WP-0029 put token /// discs and track stops on the table, which are also circles, and a /// looser match reported them as overlapping seats. fn seat_centres(html: &str) -> Vec<(f64, f64)> { html.match_indices(" games_ground::view::GroundView { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, 7, ) .expect("preset") .project(Viewer::Player(PlayerId(0))) } /// **Every seat count lays out, and no two seats land on each other.** /// Asserted per count rather than eyeballed at three, which is the /// only count anyone ever looks at. #[test] fn two_through_six_seats_all_lay_out_without_overlap() { for players in 2..=6u8 { let v = view_of(players); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); let seats = seat_centres(&html); assert_eq!( seats.len(), players as usize, "{players}p: expected one circle per seat, got {seats:?}" ); for (i, a) in seats.iter().enumerate() { for b in &seats[i + 1..] { let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt(); assert!( d > 64.0, "{players}p: two seats are {d:.0}px apart and the circles are r=34 — \ they overlap" ); } } } } /// A table where you cannot find yourself is worse than a list. #[test] fn the_viewers_own_seat_is_marked() { let v = view_of(4); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); assert!( crate::text_of(&html).contains("P1 (you)"), "the viewer's seat is not identifiable" ); assert!( html.contains("stroke=\"#9cf\" stroke-width=\"3\""), "the viewer's seat should also be visually distinct, not only labelled" ); // A spectator has no seat to mark, and must not claim one. let spec = GroundState::setup( &Setup { players: 4, preset: "standard-4p".into(), patch: Default::default(), }, 7, ) .expect("preset") .project(Viewer::Spectator); assert!( !crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"), "a spectator was given a seat" ); } /// **Observation 1: the seats were ON the table, not around it.** /// Every seat circle must lie outside the ellipse. #[test] fn the_seats_sit_outside_the_table_not_on_it() { for players in 2..=6u8 { let v = view_of(players); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64); for (x, y) in seat_centres(&html) { // Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the // seat's own radius kept clear of the rim. let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2); assert!( d > 1.0, "{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table" ); } } } /// **Observations 3 and 4: the table you can see is the table you drop /// on, and the game must be playable.** A `table` drop target that is /// not the drawn table is why a player could not find where to drop. #[test] fn the_drawn_table_is_the_drop_target_and_there_is_only_one() { let v = view_of(3); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); assert_eq!( html.matches("data-drop=\"table\"").count(), 1, "two elements claim to be the table; a player cannot tell which to use" ); let table = html .split("aria-label=\"the table, seen from above\"") .nth(1) .and_then(|s| s.split("").next()) .expect("one table svg"); assert!( table.contains("data-drop=\"table\""), "the drop target is not on the drawn table" ); assert!( table.contains(" = v.players.keys().copied().collect(); if let Some(p) = v.players.get_mut(&seats[0]) { p.stress = 4; p.darvo = games_ground::DarvoStage::Deny; } let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false); assert!( html.contains("Stress 4 of 5"), "the Stress track must say where the marker is, out of what" ); assert!( html.contains("DARVO Deny"), "the DARVO pawn must name its stage" ); // Six stops for Stress, four for DARVO — a track with the wrong // number of stops is a picture, not a track. let track_stops = html.matches(" = v.players.keys().copied().collect(); if let Some(p) = v.players.get_mut(&seats[0]) { p.stress = 0; p.protection = 0; p.blame_from.clear(); p.freedom_ready = false; p.darvo = games_ground::DarvoStage::Off; } let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false); assert!(html.contains("Stress 0 of 5")); assert!(html.contains("DARVO Off")); assert!( html.contains("Freedom spent"), "a spent Freedom disc is still a disc — it flips, it does not vanish" ); } /// Lead and Round belong to the table, not to a seat (ADR-0016 D4). #[test] fn the_lead_and_round_markers_are_on_the_table() { let v = view_of(4); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); let table = html .split("aria-label=\"the table, seen from above\"") .nth(1) .and_then(|s| s.split("").next()) .expect("one table svg"); assert!( table.contains("Lead marker:"), "the Lead marker is not on the table" ); assert!( table.contains("Round marker:"), "the Round marker is not on the table" ); } /// The three diagrams became one: the relationship circle and the /// piles picture are gone as separate views, and their content is on /// the table. #[test] fn the_stacks_and_the_relations_are_on_the_table() { let v = view_of(3); let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false); let table = html .split("aria-label=\"the table, seen from above\"") .nth(1) .and_then(|s| s.split("").next()) .expect("one table svg"); assert!( table.contains("draw pile:"), "the draw stack is not on the table" ); assert!( table.contains("discard pile:"), "the discard is not on the table" ); assert!( !html.contains("relationship graph"), "the old separate relationship diagram is still being drawn" ); } } /// CB-WP-0028 T02 — the cards say what they do, in the edition's words. #[cfg(test)] mod card_words { use cb_kernel::PlayerId; use games_ground::{Action, GroundCommand}; /// The GROUND card's own tagline reaches the page. This is finding /// F18's acceptance test and it has a person attached to it: the /// maintainer said he did not understand this card. #[test] fn the_ground_card_explains_itself_on_the_page() { let legal = vec![GroundCommand::SelectAction { action: Action::Ground, target: None, problem: None, }]; let html = crate::doc::document( &crate::testfix::view(Some(PlayerId(0))), &legal, "/command?t=x", Some(PlayerId(0)), false, ); let text = crate::text_of(&html); assert!( text.contains("Regulate. Restore the frame. Decide."), "the GROUND card's tagline did not reach the page: {text}" ); assert!( text.contains("Ground & Restate"), "its rules text must be available on demand" ); // From the dataset, not from us. let from_edition = games_ground::edition::actions() .expect("actions") .into_iter() .any(|c| c.title == "GROUND" && text.contains(&c.tagline)); assert!(from_edition, "the tagline on the page is not the edition's"); } /// A Problem shows its own name. The page said `Repair 2` for a card /// that reads "Missed Deadline", using columns vendored eight days /// earlier and discarded at parse time (ADR-0015 D1). #[test] fn a_problem_shows_the_name_the_card_has() { let titles: Vec = games_ground::edition::problem_texts("SCN_01") .expect("SCN_01") .into_iter() .map(|t| t.title) .collect(); assert!(!titles.is_empty()); let mut view = crate::testfix::view(Some(PlayerId(0))); // Priorities the fixture uses must exist in the edition for the // lookup to resolve; use the edition's own. let real = games_ground::edition::problem_texts("SCN_01").expect("SCN_01"); let keys: Vec = view.problems.keys().copied().collect(); for (k, t) in keys.iter().zip(real.iter()) { if let Some(p) = view.problems.remove(k) { view.problems.insert(u32::from(t.priority), p); } } let text = crate::text_of(&crate::doc::document( &view, &[], "/command?t=x", Some(PlayerId(0)), false, )); assert!( titles.iter().any(|t| text.contains(t.as_str())), "no Problem title from the edition appears on the page: {text}" ); } } /// CB-WP-0027 T03 — the note channel, and the two things it must not do. #[cfg(test)] mod notes { use cb_kernel::PlayerId; use crate::doc::{document_with_log, text_of}; use crate::input::{resolve, Note, PointerFact}; /// **The load-bearing control (ADR-0014 D1).** A note whose text is a /// perfectly well-formed pointer fact must not become a move. /// /// Structural, not vigilance: `Note::parse` returns a `Note`, /// `resolve` takes a `PointerFact`, and nothing converts between /// them. This asserts the behaviour anyway, because "the types don't /// connect" is a claim about code layout until something checks it. #[test] fn a_note_that_looks_like_a_move_is_not_one() { let hostile = "note=down%3Daction-solve%26up%3Dproblem-1"; let note = Note::parse(hostile).expect("it parses as a note"); assert_eq!( note.text, "down=action-solve&up=problem-1", "the text is stored verbatim, uninterpreted" ); // And the command parser refuses the same body outright — the two // channels do not overlap even at the wire level. assert!( PointerFact::parse(hostile).is_err(), "the command channel accepted a note body" ); // The reverse, so this is not vacuous: a real pointer fact IS a // command, and is NOT a note. assert!(PointerFact::parse("down=a&up=b").is_ok()); assert!( Note::parse("down=a&up=b").is_err(), "the note channel accepted a pointer fact" ); // Nothing in the crate turns a Note into a command. `resolve`'s // signature is the proof; this pins it against a careless change. let legal: Vec = vec![]; assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err()); } /// An empty note is refused, not stored — a blank row is noise in the /// register. #[test] fn an_empty_note_is_refused() { assert!(Note::parse("note=").is_err()); assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty"); assert_eq!( Note::parse("note=%20hello%20").expect("real text").text, "hello", "surrounding whitespace is trimmed" ); } /// Percent and `+` decoding, since the form posts urlencoded. #[test] fn the_players_words_survive_the_wire() { let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses"); assert_eq!(n.text, "why is SOLVE doing nothing?"); } /// **The first hostile input this renderer has handled.** Until now /// `esc()` escaped suit names. #[test] fn a_note_containing_markup_renders_as_text() { let hostile = " & \"quoted\""; let html = document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], &[hostile.to_string()], ); assert!( !html.contains(""), "a note's markup reached the document unescaped" ); assert!( html.contains("<script>"), "the note should still be visible, escaped" ); assert!( text_of(&html).contains("alert(1)"), "escaping must not eat the player's words — they still read what they wrote" ); } /// The comment box is always offered, and the page works without it /// being used. A control that appears only sometimes trains a player /// not to look for it. #[test] fn the_comment_box_is_always_there() { let html = document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], &[], ); assert!( html.contains("action=\"/note?t=x\""), "the form must carry the session token" ); assert!( html.contains("method=\"post\""), "a plain form, so it works with the script disabled" ); } } /// CB-WP-0027 T02 — the table on the left, the meta on the right. #[cfg(test)] mod two_columns { use cb_kernel::PlayerId; use crate::doc::document_with_log; fn page(meta: &[String]) -> String { document_with_log( &crate::testfix::view(Some(PlayerId(0))), &[], crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, &[], meta, ) } /// The columns exist, and the game is in the left one. #[test] fn the_table_is_in_the_game_column_and_the_log_is_not() { let html = page(&[]); let game = html .split("class=\"cb-game\"") .nth(1) .and_then(|s| s.split("class=\"cb-meta\"").next()) .expect("a game column followed by a meta column"); assert!( game.contains("

the table

"), "the table must be in the game column" ); assert!( !game.contains("

log

"), "the log belongs in the meta column — it is commentary on the game, not part of it" ); } /// **A player who writes nothing must not be worse off.** An empty /// meta panel renders as nothing, not as an empty heading. #[test] fn an_empty_meta_panel_adds_no_furniture() { assert!( !page(&[]).contains("this session"), "an empty session panel drew a heading with nothing under it" ); assert!( page(&["2 games this session".into()]).contains("this session"), "a non-empty panel must appear — otherwise the test above passes for a panel that never renders" ); } /// The single-column fallback is deliberate, not incidental. Asserted /// on the stylesheet because there is no browser here to resize — /// which is a weaker test than laying it out, and is said so rather /// than dressed up. #[test] fn the_layout_collapses_to_one_column_on_a_narrow_viewport() { let html = page(&[]); assert!( html.contains("@media (max-width:64rem)"), "no narrow-viewport rule: the two-column layout would overlap on a laptop" ); assert!( html.contains("minmax(0,1fr)"), "grid children default to min-content width; without minmax(0,…) the SVG table \ refuses to shrink and pushes the meta column off-screen" ); } } /// CB-WP-0024 T03 — what the other seats played, drawn as cards. /// /// The maintainer could follow the other players only by reading the log. /// The data was already projected and already rendered — as sentences. /// This adds the picture without touching the hiding rule, which is the /// only part that could do harm. #[cfg(test)] mod played_cards { use cb_kernel::PlayerId; use games_ground::view::{GroundView, SelectionView}; use games_ground::{Action, Selection}; use crate::doc::document; fn html(v: &GroundView) -> String { document(v, &[], "/command?t=x", Some(PlayerId(0)), false) } /// A seat's revealed play is drawn, not only written. #[test] fn a_revealed_play_is_drawn_as_a_card() { let mut v = crate::testfix::view(Some(PlayerId(0))); v.selections.insert( PlayerId(1), SelectionView::Shown(Selection { action: Action::Solve, target: None, problem: Some(3), }), ); let doc = html(&v); assert!( doc.contains("played Solve"), "the revealed play was not drawn as a card" ); assert!( crate::text_of(&doc).contains("selected Solve"), "the sentence must survive alongside the picture — the log is the record" ); } /// **The leak test.** Nothing in the emitted document may vary with /// another seat's hidden selection. /// /// The shape is `view.rs`'s own /// `a_seat_never_sees_another_seats_face_down_selection`: assert the /// absence, then assert the same view AFTER reveal shows it — /// otherwise the first assertion passes for a renderer that draws /// nothing at all. #[test] fn a_hidden_play_renders_identically_whatever_it_is() { let render_hidden = |action, problem| { let mut v = crate::testfix::view(Some(PlayerId(0))); // The seat HAS chosen; the viewer may not see what. v.selections.insert(PlayerId(1), SelectionView::Hidden); // A different real choice underneath, which must not reach us. v.selections.insert( PlayerId(2), SelectionView::Shown(Selection { action, target: None, problem, }), ); html(&v) }; // Two different hidden situations must produce the same markup for // the hidden seat. Compare the card backs directly. let a = render_hidden(Action::Solve, Some(1)); let b = render_hidden(Action::Attack, Some(9)); let back = |h: &str| { let i = h .find("face down") .expect("a face-down card"); h[i.saturating_sub(200)..i + 200].to_string() }; assert_eq!( back(&a), back(&b), "the face-down card differed between two games — it varies with something" ); // The other half: revealed, the same renderer DOES show it. let mut shown = crate::testfix::view(Some(PlayerId(0))); shown.selections.insert( PlayerId(1), SelectionView::Shown(Selection { action: Action::Attack, target: Some(PlayerId(2)), problem: None, }), ); assert!( html(&shown).contains("played Attack"), "the assertion above would pass for a renderer that draws nothing" ); } } /// CB-WP-0024 T01 — the ending page's one control. /// /// The maintainer reported it as *"the button says 'I need to read this' /// — why? the UI is not closing."* Two defects wearing one button: the /// label described a reading while the control stopped a server, and /// acknowledging it changed nothing on screen, leaving a live-looking /// table and a `play again` pointing at a closed port. #[cfg(test)] mod ending_page { use cb_kernel::PlayerId; use crate::{doc, jsrun}; fn page() -> String { doc::ending(None, "the game ended", "/command?t=x", &[], &[]) } /// The label must say what the control DOES. Asserted on the rendered /// text so reverting the wording turns this red — a comment would not. #[test] fn the_control_is_labelled_by_its_effect_not_by_a_reading() { let text = crate::text_of(&page()); assert!( text.contains("end session") && text.contains("stops the game server"), "the ending control must name its effect: {text}" ); assert!( !text.contains("I have read this"), "the label claimed the player had read something; it stops a server" ); } /// The defect the maintainer actually saw. After the server says the /// session is closed, `play again` must stop being offered — it now /// points at a port nobody is listening on. #[test] fn acknowledging_the_end_stops_the_page_offering_anything() { let html = page(); let before = doc::drop_keys(&html); assert!( before.contains("again") && before.contains("done"), "fixture must start with both controls: {before:?}" ); let (live, status) = jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended") .expect("run the page"); assert!( !live.contains(&"again".to_string()), "`play again` survived the session ending and would post to a closed port: {live:?}" ); assert!( live.is_empty(), "every control must be sealed once the server stops, not only `again`: {live:?}" ); assert!( status.contains("session has ended"), "the page must say what happened: {status:?}" ); // The WHOLE page goes inert, not only the controls. A greyed // button beside a full-colour table reads as a live game with one // broken control. assert!( status.contains("sealed-page"), "the page itself was not marked ended: {status:?}" ); } /// The controls sit **below** the log: you read what happened, then /// decide what to do next. #[test] fn the_controls_come_after_the_log() { let html = page(); let log = html.find("

log

").expect("a log section"); let again = html .find("data-drop=\"again\"") .expect("a play-again control"); assert!( again > log, "the controls are above the log; the reader decides before reading" ); } /// **A thing you click must not look like a thing you drag.** /// /// Reported two ways at once: *"the button shows a hand to pick up /// that it probably shouldn't"* and *"I can't start another game"*. /// They are one defect — a grab cursor invites a drag, and a drag /// released over nothing posts nothing, so the button looks dead. #[test] fn click_targets_do_not_wear_the_drag_affordance() { let pages = [ doc::ending(None, "m", "/command?t=x", &[], &[]), doc::document( &crate::testfix::view(Some(PlayerId(0))), &[games_ground::GroundCommand::SelectAction { action: games_ground::Action::Investigate, target: None, problem: Some(2), }], "/command?t=x", Some(PlayerId(0)), true, ), ]; for html in &pages { for key in ["again", "done", "pass"] { let Some(i) = html.find(&format!("data-drop=\"{key}\"")) else { continue; }; let tag = &html[html[..i].rfind('<').expect("an opening tag")..i]; assert!( !tag.contains("pick"), "`{key}` is a click target wearing `.pick`, which is cursor:grab — \ it invites a drag, and a drag onto nothing posts nothing" ); assert!( tag.contains("tap"), "`{key}` must still look pressable: {tag}" ); } } // The inverse, or this passes for a page with no affordances at // all: a real draggable still carries `.pick`. assert!( pages[1].contains("class=\"card act pick\""), "action cards must still be draggable" ); } /// **CB-WP-0028 T06, asserted both ways.** A test that only checked /// the win case would pass for a page that always says "solved". #[test] fn a_won_game_is_solved_and_a_lost_one_is_over() { let mut won = crate::testfix::view(None); if let Some(o) = won.outcome.as_mut() { o.group_success = true; } let mut lost = crate::testfix::view(None); if let Some(o) = lost.outcome.as_mut() { o.group_success = false; } let head = |v: &games_ground::view::GroundView| { crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[])) }; assert!( head(&won).contains("game solved"), "a won game said otherwise" ); assert!( !head(&won).contains("game over"), "\"game over\" is arcade vocabulary for a failure state" ); assert!( head(&lost).contains("game over"), "a lost game said otherwise" ); assert!(!head(&lost).contains("game solved")); // And a game with no outcome claims neither. let none = crate::text_of(&doc::ending( None, "P1 ran out of input", "/command?t=x", &[], &[], )); assert!(!none.contains("game solved") && !none.contains("game over")); } /// **CB-WP-0028 T07.** Every ranking names its source, and the /// co-operative mode is not ranked at all — the game says its /// tiebreak is "Not applicable", and ranking it anyway would invent /// scoring the rules do not have. #[test] fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() { let mut v = crate::testfix::view(None); v.mode = games_ground::ScoringMode::SharedGround; let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); assert!( text.contains("problems solved"), "the countable fact is missing" ); assert!( text.contains("not ranked"), "SHARED GROUND must not be ranked: {text}" ); assert!( !text.contains("clay-borg's reading"), "a reading must not be offered where the game defines no ranking" ); } /// And the inverse, or the test above passes for a page that never /// ranks anything. A ranked mode cites the GAME's tiebreak and marks /// anything derived as ours. #[test] fn a_ranked_mode_cites_the_games_own_tiebreak() { let mut v = crate::testfix::view(None); v.mode = games_ground::ScoringMode::BondedCoalitions; let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); assert!( text.contains("Lower combined Stress"), "the tiebreak shown must be the edition's own words: {text}" ); assert!( text.contains("clay-borg's reading") || text.contains("clay-borg's reading"), "a derived superlative must be marked as ours, not as a rule" ); } /// The negative control. If `seal` fired on any reply, this test would /// pass for a page that tears itself down whenever it is touched — /// which would break `play again` in the ordinary case. #[test] fn a_dealing_reply_leaves_the_controls_alone() { let html = page(); let (live, _) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing") .expect("run the page"); assert!( live.contains(&"again".to_string()) && live.contains(&"done".to_string()), "an 'ok' reply must not seal the page: {live:?}" ); let (_, status) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run"); assert!( !status.contains("sealed-page"), "a dealing reply greyed out a page that is about to be reused" ); } } #[cfg(test)] mod testfix;