` 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, 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"
);
}
}
/// **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 ``: 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,
crate::doc::Account::of(&[]),
&[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,
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("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",
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("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",
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'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;