clay-borg/crates/cb-render-html/src/lib.rs
tegwick d30938b259
Some checks failed
ci / check (push) Failing after 3s
CB-WP-0047: all four boards, and every mode named on the page
The modes were already implemented; nothing had ever COMPARED them. The
scenarios were not implemented at all: edition::deal has taken a
scenario_id since it was written and the only caller passed the literal
"SCN_01", so 15 of 20 Problem cards had never been dealt by anything.
The seam was the whole mechanism and it sat unused, with nothing red
because nothing asked.

Scenario is now state (serde default SCN_01, so all 26 recordings replay
unchanged), selected by preset `scn-03-4p` with `standard-Np` still
meaning SCN_01, and by --scenario/SCENARIO= accepting ids, numbers or
titles, validated against the edition rather than a pattern.

The threshold now comes off the Scenario card, closing F25's hardcoded
5/7/9. The first version of that control was worthless and mutation said
so: all four scenarios print 5/7/9, so reverting to the bands left it
green. Split threshold_from() so it can be handed a card that disagrees.

The header read `scoring CommonProblem` where the Mode card is titled
COMMON PROBLEM, PERSONAL EDGE -- the defect CB-WP-0034 deleted from the
move buttons, still standing on the line that says what winning means.
The coverage probe was matching that Debug output and went red when it
was fixed: third instance (CB-WP-0024, CB-WP-0034). Page now carries the
premise, the mode's rules text, and the tiebreak.

scenario-panel plays 4x3x3. Findings: SCN_01 and SCN_02 are the same
board (identical cells, pinned by a characterisation test); SCN_04 is
the hard board at 2p (52% vs 67/73%, the only deck needing two Repair);
and group success is EXACTLY equal across all three modes in all 36
cells, because greedy never reads state.mode -- filed F27, the two
competitive modes are scoring lenses over cooperative play.

F28: SHARED GROUND's mastery subtracts penalties from the claimed COUNT
where the mode card's shared score is claimed VALUE. Raised, not fixed;
scoring is ground-game's to rule on.

Also fixes design.py reporting a backticked path as no reproduction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:51:46 +02:00

2289 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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 {
alive: "/alive?t=x",
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<String>) {
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",
"problem_markers",
"focus",
"selections",
"ground_modes",
"ground_choices",
"support_responses",
"darvo_targets",
"personal",
];
let mut parts: Vec<String> = 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"),
// Was "scoring BondedCoalitions" — the Rust variant's name.
// **The probe was certifying the defect**, and went red the
// moment the defect was fixed. Third time this exact shape has
// been found (CB-WP-0024, CB-WP-0034): a probe that names a
// rendering holds that rendering in place, so probes name FACTS.
("mode", "BONDED COALITIONS"),
("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"),
// CB-WP-0034: words, not Debug — and these probes are the reason
// it survived so long. They matched `problem: 7 }`,
// `player: Some(PlayerId(1))` and `members: [PlayerId(1)`, so the
// gate that exists to prove every field reaches the PLAYER was
// proving it by matching Rust struct syntax. It certified the
// defect as coverage.
//
// Second confirmation of CB-WP-0024's finding, from the other
// side: a probe naming a PRESENTATION does not survive a
// rendering change, and one naming Debug output actively pins it.
("ground_modes.*", "Ground & Restate"),
("ground_choices.*.choice", "protect Problem"),
("ground_choices.*.problem", "Problem 7 from Deny"),
("support_responses.*", "accepted P3\u{2019}s Support"),
("darvo_targets.*.problem", "Problem 1"),
("darvo_targets.*.player", "darvo target P2"),
// CB-WP-0043: H2's owner marker. The scope is expressed as
// POSITION, which no text probe can see — so each scoped Problem
// also says whose it is, and that is what these match. A probe
// that could only be satisfied by geometry would have to be
// OMITTED, and the page would be illegible to `text_of` and to a
// screen reader alike.
("variant", "ground-darvo-r0"),
// The fixture plays SCN_03, so the token is that card's TITLE.
// Not "SCN_03": a probe that matches an id would go green
// against a page showing the id, which is the machine's name for
// the board and not the player's (CB-WP-0034's finding).
("scenario", "Decision Without Consent"),
("problem_markers.*.owner", "P1\u{2019}s alone"),
("problem_markers.*.scope", "P2\u{2019}s Bond network"),
("outcome.total", "total 9"),
("outcome.threshold", "of 12"),
("outcome.group_success", "failure"),
("outcome.personal.*", "P1 +3"),
("outcome.coalitions.*.members.*", "P2 + P3"),
("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<String> = 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 = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
<script>var s='SCRIPTTOKEN';</script>";
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("<script>")
.and_then(|(_, r)| r.rsplit_once("</script>"))
.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 `<div>` 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<RefCell<usize>>,
}
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 `<div>` 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<Box<dyn Policy>> = (0..3)
.map(|i| {
Box::new(CheckingPolicy {
inner: RandomPolicy::new(seed * 10 + i),
checked: checked.clone(),
}) as Box<dyn Policy>
})
.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<Box<dyn Policy>> = (0..3)
.map(|i| {
Box::new(AdvertisedPolicy {
inner: RandomPolicy::new(seed * 7 + i),
checked: checked.clone(),
}) as Box<dyn Policy>
})
.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<RefCell<usize>>,
}
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, Account, 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(),
}
}
/// CB-WP-0035. A server that has gone must be NOTICED.
///
/// The script's `fetch` had no rejection handler, so when the process
/// had exited the promise rejected and the chain never ran — not even
/// the status line moved. Reported twice: *"play again does not
/// report that the session is no longer available"*, and the linger
/// timeout going unnoticed.
///
/// **Both from one cause**, so both are asserted: the click path and
/// the heartbeat that needs no click.
#[test]
fn a_session_that_answers_nothing_is_reported_and_sealed() {
let page = crate::doc::ending(
None,
"the game ended",
"/command?t=x",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
);
for (beat, what) in [(false, "pressing a control"), (true, "the heartbeat")] {
let (live, status) = crate::jsrun::unanswered(&page, beat).expect("run the page");
assert!(
status.contains("no longer available"),
"{what} said nothing about the session: {status:?}"
);
assert!(
status.contains("[sealed]"),
"{what} left the page looking live: {status:?}"
);
assert!(
live.is_empty(),
"{what} left {live:?} still droppable on a dead session"
);
}
}
/// **Both** pages must carry the heartbeat (CB-WP-0035).
///
/// The first cut wired it into the ending page only, and the gate
/// caught it as an unused variable — which is luck, not a control.
/// The timeout matters MOST during play: that is when a player walks
/// away, and the table is the thing that must stop looking live.
#[test]
fn every_page_can_tell_the_session_has_gone() {
let playing = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account::of(&[]),
&[],
);
let ended = crate::doc::ending(
None,
"m",
"/command?t=x",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
);
for (name, page) in [("the table", &playing), ("the ending page", &ended)] {
assert!(
page.contains("CB_ALIVE"),
"{name} cannot notice the session ending"
);
// With the token: an unauthenticated heartbeat is refused,
// and a refusal every 5s would seal a LIVE session.
assert!(
page.contains("/alive?t="),
"{name}'s heartbeat carries no token"
);
}
}
/// **The page names the Mode by its printed title** (CB-WP-0047).
///
/// The header read `scoring CommonProblem` — the Rust variant's
/// name, where the Mode card is titled *"COMMON PROBLEM, PERSONAL
/// EDGE"*. CB-WP-0034 deleted this exact defect from the move
/// buttons and it was still standing on the one line that says what
/// winning means.
#[test]
fn every_mode_is_named_and_explained_in_the_editions_words() {
use games_ground::ScoringMode as M;
for mode in [M::SharedGround, M::CommonProblem, M::BondedCoalitions] {
let mut v = crate::testfix::view(Some(PlayerId(0)));
v.mode = mode;
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(
!text.contains(&format!("{mode:?}")),
"{mode:?}: the page shows the Rust variant's name"
);
let (card, _) = games_ground::edition::modes()
.expect("Modes.csv")
.into_iter()
.find(|(c, _)| {
c.id == match mode {
M::SharedGround => "MODE_COOP",
M::CommonProblem => "MODE_SEMI",
M::BondedCoalitions => "MODE_COALITION",
}
})
.expect("the mode card");
assert!(
text.contains(&card.title),
"{mode:?}: the Mode card's title {:?} is not on the page",
card.title
);
// **And what it MEANS.** Three modes define winning three
// different ways and the page had never stated any of them.
let head: String = card.rules_text.chars().take(50).collect();
assert!(
text.contains(&head),
"{mode:?}: nothing on the page says how this game is won"
);
}
}
/// **The page names which of the four boards this is** (CB-WP-0047).
#[test]
fn every_scenario_is_named_with_its_premise() {
for s in games_ground::edition::scenarios().expect("Scenarios.csv") {
let mut v = crate::testfix::view(Some(PlayerId(0)));
v.scenario = s.id.clone();
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(text.contains(&s.title), "{}: the board is unnamed", s.id);
let head: String = s.premise.chars().take(50).collect();
assert!(
text.contains(&head),
"{}: the page does not say what the table is arguing about",
s.id
);
}
}
/// **A scope says what it does** (CB-WP-0046).
///
/// CB-WP-0045 shipped the placement and recorded the gap with the
/// wrong reason — that scoping is our Variant so no printed sentence
/// exists. `Rules_Text.csv` was in the H2 package the whole time and
/// nothing read it. A player could see a card on their Bond lines
/// and have no way to learn what that meant.
#[test]
fn a_scoped_table_says_what_a_scope_does() {
let mut v = crate::testfix::view(Some(PlayerId(0)));
v.variant = games_ground::Variant::H2ScopedProblemStress;
// One card that is not everyone's — the condition for the rule
// to be worth stating at all.
if let Some(m) = v.problem_markers.values_mut().next() {
m.scope = Some(games_ground::edition::StressScope::Bond);
m.owner = Some(PlayerId(1));
}
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(
text.contains("scope does"),
"Problems are placed by scope and nothing says what a scope does"
);
// The EDITION's sentence. A paraphrase of ours would drift from
// the rule the kernel implements (ADR-0015).
let rule = games_ground::edition::stress_scope_rule()
.expect("h2 Rules_Text.csv carries the scope rule");
let head: String = rule.chars().take(60).collect();
assert!(
text.contains(&head),
"the scope explanation is not the edition's own words: {head:?}"
);
}
/// The baseline is not told about a distinction it does not have.
#[test]
fn an_unscoped_table_does_not_explain_scopes() {
let mut v = crate::testfix::view(Some(PlayerId(0)));
for m in v.problem_markers.values_mut() {
m.scope = Some(games_ground::edition::StressScope::Global);
}
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(
!text.contains("scope does"),
"the baseline table explains a scope distinction that is not in play"
);
}
/// **The number is on the card** (CB-WP-0045).
///
/// Every move label says "Problem 7"; nothing on the table said which
/// card that was. In the old row, position was a hint — once
/// Problems are placed by scope the row is gone and the hint with it.
/// The number was only ever a fallback for a missing title, so it
/// appeared exactly when it was least useful.
#[test]
fn every_problem_card_shows_its_number() {
let v = crate::testfix::view(Some(PlayerId(0)));
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
for key in v.problems.keys() {
assert!(
text.contains(&format!("{key}")),
"Problem {key} is on the table with no number, and the move \
labels call it by one"
);
}
}
/// **The game's own words for a DARVO stage** (CB-WP-0045).
///
/// Reported as *"the GROUND and DARVO logic is far from self
/// explanatory"*. `DARVO.csv` carries `mandatory_effect` per stage —
/// vendored for tripwires and never shown, so the page said "DARVO
/// Reverse" and left the player to guess. F18's shape again: the data
/// was present and could not fire.
#[test]
fn a_darvo_stage_says_what_it_does() {
let v = crate::testfix::view(Some(PlayerId(0)));
let text = crate::text_of(&crate::doc::document(
&v,
&[],
"/c",
Some(PlayerId(0)),
false,
));
// The fixture has a seat at Reverse.
assert!(
text.contains("what Reverse does"),
"a seat in a DARVO stage does not say what the stage does"
);
// And it is the EDITION's sentence, not a paraphrase of ours.
let edition = games_ground::edition::darvo_stages()
.expect("DARVO.csv")
.into_iter()
.find(|s| s.stage == "REVERSE")
.expect("REVERSE");
let head: String = edition.mandatory_effect.chars().take(40).collect();
assert!(
text.contains(&head),
"the stage text is not the edition's own words: {head:?}"
);
}
/// **The GROUND modes explain themselves at the decision** (CB-WP-0045).
///
/// By the Reveal step the action card has left the screen, so the
/// player was choosing GR / OU / ND from three names alone.
#[test]
fn the_ground_modes_explain_themselves_where_they_are_chosen() {
use games_ground::{GroundCommand, GroundMode};
let v = crate::testfix::view(Some(PlayerId(0)));
let legal = vec![GroundCommand::ChooseGroundMode {
mode: GroundMode::Gr,
choice: None,
}];
let with = crate::text_of(&document_with_log(
&v,
&legal,
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account::of(&[]),
&[],
));
assert!(
with.contains("what the GROUND modes do"),
"the mode choice is offered with no account of the modes"
);
assert!(
with.contains("Ground & Restate"),
"the explanation is not the card's own text"
);
// Absent when no mode is on offer — an always-on wall of rules is
// how a player stops reading them.
let without = crate::text_of(&document_with_log(
&v,
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account::of(&[]),
&[],
));
assert!(
!without.contains("what the GROUND modes do"),
"the mode explanation shows when no mode is being chosen"
);
}
/// **A Problem is drawn where its Stress lands** (CB-WP-0043).
///
/// H2 scopes End-of-Round Stress, so a row across the middle — true
/// under the baseline — becomes a lie: three of five cards belong to
/// particular seats. This checks the placement rule rather than the
/// fact that something was drawn.
#[test]
fn a_problem_sits_where_its_stress_lands() {
use games_ground::edition::StressScope;
use games_ground::view::ProblemMarker;
let v = crate::testfix::view(Some(PlayerId(0)));
let svg = crate::doc::document(&v, &[], "/c", Some(PlayerId(0)), false);
// Each scope says whose it is — position alone is invisible to
// `text_of` and to a screen reader.
let text = crate::text_of(&svg);
assert!(
text.contains("everyone\u{2019}s"),
"the global Problem is unlabelled: {text}"
);
assert!(
text.contains("P1\u{2019}s alone"),
"the personal Problem is unlabelled"
);
assert!(
text.contains("P2\u{2019}s Bond network"),
"the bond Problem is unlabelled"
);
// And the anchors really differ: a global Problem must not be
// drawn where a personal one is.
let mut baseline = v.clone();
baseline.problem_markers.clear();
let row = crate::doc::document(&baseline, &[], "/c", Some(PlayerId(0)), false);
assert_ne!(
row, svg,
"the scoped layout is identical to the baseline row — scope is not \
reaching the placement"
);
assert!(
!crate::text_of(&row).contains("P1\u{2019}s alone"),
"the baseline page labels Problems with owners it does not have"
);
// A bond Problem whose owner has no Bond says so, because the
// RULE falls back to personal at degree 0 and the picture must
// agree with the arithmetic.
let mut lonely = v.clone();
lonely.relations.clear();
lonely.problem_markers.insert(
7,
ProblemMarker {
owner: Some(PlayerId(1)),
scope: Some(StressScope::Bond),
},
);
let alone = crate::text_of(&crate::doc::document(
&lonely,
&[],
"/c",
Some(PlayerId(0)),
false,
));
assert!(
alone.contains("no Bond to share it"),
"a bond Problem with no Bonds should read as personal: {alone}"
);
}
/// CB-WP-0034. The move button must name **who**.
///
/// Reported three times across three sessions, two days apart, and it
/// survived a whole UI rebuild: *"RespondToSupport is unclear as i
/// cant see whos support i accept"*, then twice more about bonding.
///
/// The cause was not styling. The label was `format!("{c:?}")`, so
/// the button read `RespondToSupport { response: AcceptBond }` — Rust
/// struct syntax with no name in it — and the command **does not
/// carry** the counterparty, so nothing rendering the command alone
/// could have said who. It comes off the view.
#[test]
fn a_support_response_names_the_seat_that_offered_it() {
use games_ground::view::SelectionView;
use games_ground::{Action, GroundCommand, Selection, SupportResponse};
let me = PlayerId(0);
let them = PlayerId(1);
let mut v = crate::testfix::view(Some(me));
// P2 played Support at P1, revealed.
v.selections.insert(
them,
SelectionView::Shown(Selection {
action: Action::Support,
target: Some(me),
problem: None,
}),
);
let legal = vec![
GroundCommand::RespondToSupport {
response: SupportResponse::AcceptBond,
},
GroundCommand::RespondToSupport {
response: SupportResponse::DeclineBond,
},
];
let html = document_with_log(
&v,
&legal,
crate::TEST_ENDPOINTS,
Some(me),
false,
Account::of(&[]),
&[],
);
let text = crate::text_of(&html);
assert!(text.contains("accept P2"), "the offer is anonymous: {text}");
assert!(
text.contains("decline P2"),
"the refusal is anonymous: {text}"
);
// The defect itself, named: no Rust struct syntax on a button.
assert!(
!text.contains("RespondToSupport"),
"the raw command name is still on the page: {text}"
);
assert!(
!text.contains("AcceptBond"),
"the raw variant name is still on the page: {text}"
);
}
/// When the offer is not visible in this view, the page says so.
///
/// **It must not invent a name and must not drop the question.** A
/// confident wrong seat is worse than an honest gap — that is the
/// family ADR-0018 exists for.
#[test]
fn an_unseen_offer_is_not_given_an_invented_name() {
use games_ground::{GroundCommand, SupportResponse};
let me = PlayerId(0);
let mut v = crate::testfix::view(Some(me));
v.selections.clear();
let legal = vec![GroundCommand::RespondToSupport {
response: SupportResponse::AcceptBond,
}];
let text = crate::text_of(&document_with_log(
&v,
&legal,
crate::TEST_ENDPOINTS,
Some(me),
false,
Account::of(&[]),
&[],
));
assert!(
text.contains("this view does not show which seat"),
"it should say the offer is not visible: {text}"
);
for name in ["P1", "P2", "P3"] {
assert!(
!text.contains(&format!("accept {name}")),
"it named {name} with nothing to go on: {text}"
);
}
}
/// No move button may carry Rust `Debug` output (CB-WP-0034).
///
/// The guard against this returning is that `command_label` has no
/// catch-all arm, so a new variant stops the build. This asserts the
/// visible half over every command the fixture can offer.
#[test]
fn no_move_button_shows_rust_struct_syntax() {
use games_ground::*;
let me = PlayerId(0);
let legal = vec![
GroundCommand::SpendFreedom,
GroundCommand::ChooseGroundMode {
mode: GroundMode::Ou,
choice: Some(GroundChoice::ProtectProblem { problem: 7 }),
},
GroundCommand::ChooseDarvoTarget {
target: DarvoTarget {
problem: Some(1),
player: Some(PlayerId(1)),
},
},
GroundCommand::RespondToSupport {
response: SupportResponse::FlipToBond,
},
];
let text = crate::text_of(&document_with_log(
&crate::testfix::view(Some(me)),
&legal,
crate::TEST_ENDPOINTS,
Some(me),
false,
Account::of(&[]),
&[],
));
// The tells of a `{:?}` label reaching a player.
for tell in ["{ ", " }", "Some(", "None", "PlayerId("] {
assert!(
!text.contains(tell),
"Debug output {tell:?} reached the page: {text}"
);
}
// And the words are actually there. `SpendFreedom` is not among
// them: it carries a spatial affordance, so it is a drag target
// rather than a button -- which is why this asserts on the
// commands that DO become buttons.
assert!(text.contains("Observe & Uphold"), "{text}");
assert!(text.contains("DARVO target: P2, Problem 1"), "{text}");
}
fn note(after: usize, text: &str) -> crate::doc::LogNote {
crate::doc::LogNote {
after,
round: 2,
step: "Select".into(),
text: text.into(),
}
}
/// CB-WP-0032. A comment appears where it was written.
///
/// **Position is the whole feature.** A remark like *"why did that do
/// nothing?"* is about the move above it; collected at the bottom it
/// is a list of sentences with no subjects.
#[test]
fn comments_appear_in_the_log_where_they_were_written() {
let lines = vec![line(&["e0"]), line(&["e1"]), line(&["e2"])];
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account {
lines: &lines,
notes: &[
note(0, "before I moved"),
note(2, "why did that do nothing"),
],
},
&[],
);
let text = crate::text_of(&html);
let at = |needle: &str| {
text.find(needle)
.unwrap_or_else(|| panic!("missing {needle:?}"))
};
// Written before any move: above the first effect.
assert!(at("before I moved") < at("e0"), "{text}");
// Written after two commands: after the second, before the third.
assert!(at("e1") < at("why did that do nothing"), "{text}");
assert!(at("why did that do nothing") < at("e2"), "{text}");
}
/// A comment must not be readable as something the game did.
///
/// The log is the RECORDER's vocabulary — what the scenario file will
/// say. A note styled like a log line would be a sentence the game
/// never produced, sitting in the account of what the game produced,
/// and `ADR-0014 D4` turns on that distinction being visible.
#[test]
fn a_comment_is_not_dressed_as_a_game_event() {
let lines = vec![line(&["e0"])];
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account {
lines: &lines,
notes: &[note(1, "select_action action=SOLVE")],
},
&[],
);
assert!(
html.contains("class=\"note\""),
"the note had no marking of its own"
);
let text = crate::text_of(&html);
// Attributed to the player, and to a position.
assert!(text.contains("you \u{2014} round 2, Select"), "{text}");
}
/// A post-game note is written when every command has been played, so
/// its `after` can exceed a log this page happens to be showing. It
/// must still appear — a dropped comment is the failure the whole
/// note channel exists to avoid.
#[test]
fn a_comment_past_the_end_of_the_log_is_still_shown() {
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account {
lines: &[],
notes: &[note(9, "so that is why it failed")],
},
&[],
);
assert!(
crate::text_of(&html).contains("so that is why it failed"),
"a comment was silently dropped"
);
}
fn page(log: &[LogLine]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
Account::of(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 `<circle>`: 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("<circle class=\"seat\" cx=\"")
.filter_map(|(i, _)| {
let rest = &html[i + 25..];
let (x, rest) = rest.split_once("\" cy=\"")?;
let (y, _) = rest.split_once('"')?;
Some((x.parse().ok()?, y.parse().ok()?))
})
.collect()
}
fn view_of(players: u8) -> 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("</svg>").next())
.expect("one table svg");
assert!(
table.contains("data-drop=\"table\""),
"the drop target is not on the drawn table"
);
assert!(
table.contains("<ellipse"),
"the drop target should be the surface itself"
);
}
/// **The reason the game became unplayable.** The table was 620px
/// tall, so the action cards sat a screen below the Problems — and you
/// cannot drag between two things never on screen together.
///
/// The limit is **derived, not guessed**. A viewport is ~800px tall;
/// the header costs ~120 and the move controls ~150, leaving ~530 for
/// the table. The version that broke dragging declared 620. 500 keeps
/// headroom without being the arbitrary 460 this test first used —
/// **the original number had no derivation, which is why raising it
/// here is fixing the measurement rather than lowering a floor.**
#[test]
fn the_table_is_short_enough_to_drag_from() {
let v = view_of(6);
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
let vb = html
.split("viewBox=\"0 0 760 ")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("the table declares a viewBox");
let h: f64 = vb.parse().expect("a number");
assert!(
h <= 500.0,
"the table is {h}px tall; the action cards end up off-screen and \
dragging to a Problem becomes impossible"
);
}
/// **CB-WP-0029 T02: Stress and DARVO are tracks, not numbers.**
///
/// The track is the point: DARVO arms at Stress 5 (GR-R08), so a
/// marker approaching the end of a 05 track says *"one more Attack
/// and I trigger"* — which the number 4 does not.
#[test]
fn stress_and_darvo_are_tracks_a_player_can_read_ahead_on() {
let mut v = view_of(3);
let seats: Vec<PlayerId> = 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("<title>Stress 4 of 5</title>"),
"the Stress track must say where the marker is, out of what"
);
assert!(
html.contains("<title>DARVO Deny</title>"),
"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("<circle cx=").count();
assert!(
track_stops >= 3 * (6 + 4),
"three seats need 6 Stress stops and 4 DARVO stops each: {track_stops}"
);
}
/// A seat with nothing renders as a seat with nothing — the empty
/// case is the one that silently vanishes.
#[test]
fn a_seat_with_no_tokens_still_has_its_tracks() {
let mut v = view_of(2);
let seats: Vec<PlayerId> = 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("<title>Stress 0 of 5</title>"));
assert!(html.contains("<title>DARVO Off</title>"));
assert!(
html.contains("<title>Freedom spent</title>"),
"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("</svg>").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("</svg>").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<String> = 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<u32> = 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<games_ground::GroundCommand> = 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 = "<script>alert(1)</script> & \"quoted\"";
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
crate::doc::Account::of(&[]),
&[hostile.to_string()],
);
assert!(
!html.contains("<script>alert(1)</script>"),
"a note's markup reached the document unescaped"
);
assert!(
html.contains("&lt;script&gt;"),
"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,
crate::doc::Account::of(&[]),
&[],
);
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,
crate::doc::Account::of(&[]),
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("<h2>the table</h2>"),
"the table must be in the game column"
);
assert!(
!game.contains("<h2>log</h2>"),
"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("<title>face down</title>")
.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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
)
}
/// 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("<h2>log</h2>").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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
),
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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
))
};
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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
));
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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
));
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",
None,
"/alive?t=x",
crate::doc::Account::of(&[]),
&[],
));
assert!(
text.contains("Lower combined Stress"),
"the tiebreak shown must be the edition's own words: {text}"
);
assert!(
text.contains("clay-borg&#39;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;