clay-borg/crates/cb-render-html/src/lib.rs
tegwick 51efe55d14
Some checks failed
ci / check (push) Failing after 4s
fix: the controls sit below the log, and ending a session greys the page
Tier S (fixes inside a boundary; chaos d8=3, no override). Two
observations from play, both about what the page says has happened.

The controls moved BELOW the log: you read what happened, then decide what
to do next. They were above it, which asks for the decision first.

And sealing now marks the WHOLE PAGE inert, not just the controls. A
greyed-out button beside a full-colour table still reads as a live game
with one broken control; the session has ended and everything on screen is
a record of it. The status line stays legible on purpose -- it is the one
thing still worth reading.

THE STUB NEEDED A BODY classList TO MAKE THAT TESTABLE. Without it "the
session visibly ended" would have been a claim about CSS with nothing
checking it, which is precisely CB-WP-0016's finding: a stub too thin to
express a failure is how the failure survives. The harness reports the
sealed page through the status channel with a NUL-separated marker --
ugly, deliberate, and documented, because widening the return type would
touch every caller for one boolean.

Both directions asserted: a `closed` reply seals the page, an `ok:
dealing` reply does NOT -- otherwise the seal test would pass for a page
that greys itself whenever it is touched, breaking `play again`.

63 render tests, 26 cb-play, check and loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:32 +02:00

1505 lines
56 KiB
Rust

//! `cb-render-html` — stage 1's renderer (ADR-0007).
//!
//! Emits HTML with inline SVG and one small piece of inline JavaScript;
//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no
//! third-party dependencies at all, and adding one requires an argument
//! against ADR-0007 §Decision 3.
//!
//! ## What this is not
//!
//! It is **not** `cb-render-api`, and there is no `cb-render-null`.
//! ADR-0007 Decision 2 withdrew the port: a capability port designed
//! against one implementation that emits whole documents acquires a
//! document's shape, and stage 2's `wgpu` renderer would find it
//! unimplementable. INTENT's rule is the one that binds —
//!
//! > *No concept becomes canonical merely because it looks general. It
//! > becomes canonical after surviving a second concrete use.*
//!
//! — so this renders against the existing [`cb_game_runtime::Project`]
//! trait, which is a real interface with real implementations, and the
//! port is declared at stage 2 when there are two.
//!
//! ## The controls, and why they exist
//!
//! CB-WP-0011 established that a renderer's defect class is **silent
//! omission**: every assertion a renderer test naturally makes is
//! satisfied by a renderer showing a third of the state. This crate moves
//! the interactive half of stage 1 into a language `cargo test`,
//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that
//! finding across the boundary:
//!
//! * [`input`] — **JavaScript may not construct commands.** The page
//! reports raw pointer facts; Rust decides what they mean, against the
//! legal list the aggregate already offered.
//! * the coverage gate below — asserts over the **parsed emitted
//! document**, not over the Rust that emits it. Asserting over the
//! emitting code would reproduce CB-WP-0011's original defect one layer
//! up.
//!
//! And [`serve`] carries the four that make a loopback listener safe to
//! have, of which the load-bearing one is a test that a token-less request
//! is refused.
pub mod doc;
pub mod input;
#[cfg(any(test, feature = "js-harness"))]
pub mod jsrun;
pub mod serve;
pub use doc::{document, text_of, Endpoints};
/// The endpoint pair every test in this crate posts to.
#[cfg(test)]
const TEST_ENDPOINTS: Endpoints<'static> = Endpoints {
command: "/command?t=x",
note: "/note?t=x",
};
pub use input::{resolve, Note, PointerFact};
pub use serve::{Guard, Refusal, Request};
#[cfg(test)]
mod coverage {
//! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s
//! `every_view_field_is_classified`.
//!
//! Same shape as CB-WP-0011's gate, one layer further out: walk the
//! serialized `GroundView` for every leaf *path*, and require each to
//! be either rendered — with a token that must appear in the **parsed
//! document** — or omitted with a stated reason. A new field on
//! `GroundView` that is in neither list fails the build.
//!
//! Paths, not keys: `problem` occurs under a DARVO target, a GROUND
//! choice and a Selection, and a key-set walk would let one vouch for
//! the other two.
use cb_kernel::PlayerId;
use games_ground::view::GroundView;
use crate::doc::{document, text_of};
/// Every leaf path in the serialized view.
fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec<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",
"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"),
("mode", "scoring BondedCoalitions"),
("viewer", "viewing as P1"),
("solution_deck_len", "draw pile: 17 remaining"),
("solution_discard.*.suit", "discard Repair Change"),
("players.*.stress", "stress 5"),
("players.*.protection", "protect 2"),
("players.*.darvo", "darvo Reverse"),
("players.*.freedom_ready", "READY"),
("players.*.freedom_gate_lifted", "gate lifted"),
("players.*.blame_from.*", "blamed by P3"),
// The empty case is a leaf path of its own, and renders as an
// explicit absence rather than as nothing at all.
("players.*.blame_from", "blamed by none"),
("players.*.hand.*.suit", "hand Clarify Boundary"),
("players.*.hand_size", "cards)"),
("relations.*", "Rivalry"),
("problems.*.state", "face down"),
("problems.*.suit", "Change 6"),
("problems.*.value", "Change 6"),
("problems.*.denied", "denied"),
("problems.*.claimed_by", "claimed by P2"),
("problems.*.protected_this_round", "protected"),
("focus.*", "\u{2192}P3"),
("selections.*.state", "face down"),
// CB-WP-0020 T04: words, not Debug. These tokens were
// `action: Attack` / `target: Some(PlayerId(1))` /
// `problem: Some(7)` — the shape a player was being shown.
("selections.*.action", "selected Attack"),
("selections.*.target", "Attack on P2"),
("selections.*.problem", "for problem 7"),
("ground_modes.*", "ground mode Gr"),
("ground_choices.*.choice", "ProtectProblem"),
("ground_choices.*.problem", "problem: 7 }"),
("support_responses.*", "support AcceptBond"),
("darvo_targets.*.problem", "problem: Some(1)"),
("darvo_targets.*.player", "player: Some(PlayerId(1))"),
("outcome.total", "total 9"),
("outcome.threshold", "of 12"),
("outcome.group_success", "failure"),
("outcome.personal.*", "P1 +3"),
("outcome.coalitions.*.members.*", "members: [PlayerId(1)"),
("outcome.coalitions.*.score", "score: 4"),
("outcome.mastery", "mastery +1"),
("outcome.winners.*", "winners P1"),
];
/// Deliberate omissions, each with a reason.
const OMITTED: &[(&str, &str)] = &[(
"players.*.hand",
"null for a non-viewer seat; the absence is rendered as a count",
)];
fn fixture() -> GroundView {
crate::testfix::view(Some(PlayerId(0)))
}
fn rendered_document(view: &GroundView) -> String {
text_of(&document(
view,
&[],
crate::TEST_ENDPOINTS.command,
Some(PlayerId(0)),
false,
))
}
#[test]
fn every_view_field_is_classified_in_the_emitted_document() {
let view = fixture();
let json = serde_json::to_value(&view).expect("view serializes");
let mut raw = Vec::new();
paths(&json, "", &mut raw);
let all: std::collections::BTreeSet<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, LogLine};
use cb_kernel::PlayerId;
fn line(effects: &[&str]) -> LogLine {
LogLine {
who: "P1".into(),
what: "select_action action=SOLVE problem=1".into(),
effects: effects.iter().map(|s| (*s).to_string()).collect(),
}
}
fn page(log: &[LogLine]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
log,
&[],
)
}
/// **The case the pass was reported for.** A SOLVE that cannot be
/// fulfilled produces no events, and a log built only from events
/// would render nothing for it — reproducing the silence the
/// maintainer hit when the same move did nothing three rounds
/// running.
#[test]
fn a_command_that_produced_nothing_says_so() {
let html = page(&[line(&[])]);
assert!(
html.contains("no effect"),
"a command with no events rendered as if it had done something"
);
let text = crate::text_of(&html);
assert!(text.contains("select_action action=SOLVE problem=1"));
}
#[test]
fn effects_are_listed_and_an_empty_log_says_it_is_empty() {
let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])]));
assert!(text.contains("problem 1 claimed by P1"));
assert!(
!text.contains("no effect"),
"a command WITH effects was marked as having none"
);
assert!(crate::text_of(&page(&[])).contains("nothing has happened yet"));
}
}
/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table.
///
/// The maintainer asked for the piles to be visible and for the discard
/// to show a shuffle when the draw runs out. **The reshuffle is real** —
/// `games/ground/src/lib.rs::draw_solution` implements the U4 default
/// (deterministic reshuffle of the discard; skip the draw if both are
/// empty), which ground-game confirmed on 2026-08-03. So this renders the
/// state in which the next draw triggers it, rather than inventing a rule.
#[cfg(test)]
mod piles {
use cb_kernel::PlayerId;
use games_ground::view::GroundView;
use crate::doc::document;
fn view() -> GroundView {
crate::testfix::view(Some(PlayerId(0)))
}
fn html(v: &GroundView) -> String {
document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
}
/// The counts must come from the projection, never be recomputed.
#[test]
fn both_counts_are_the_views_own_numbers() {
let mut v = view();
v.solution_deck_len = 5;
let doc = crate::text_of(&html(&v));
assert!(
doc.contains("draw pile: 5 remaining"),
"the drawn deck count is not the view's: {doc}"
);
let n = v.solution_discard.len();
assert!(
doc.contains(&format!("discard pile: {n} remaining")),
"the drawn discard count is not the view's ({n}): {doc}"
);
}
/// An empty discard is an EMPTY pile, not a missing one. A absent slot
/// reads as "this game has no discard", which is a different claim.
#[test]
fn an_empty_pile_is_drawn_rather_than_omitted() {
let mut v = view();
v.solution_discard.clear();
let doc = crate::text_of(&html(&v));
assert!(
doc.contains("discard pile: 0 remaining"),
"an empty discard vanished instead of rendering as empty: {doc}"
);
}
/// The U4 state: deck empty, discard holding cards. The next draw
/// reshuffles, and the table should say so.
#[test]
fn an_exhausted_deck_says_the_discard_shuffles_back_in() {
let mut v = view();
v.solution_deck_len = 0;
assert!(!v.solution_discard.is_empty(), "fixture needs a discard");
let doc = crate::text_of(&html(&v));
assert!(
doc.contains("shuffles in on next draw"),
"an exhausted deck did not announce the U4 reshuffle: {doc}"
);
// And the negative half: with cards left, no shuffle is promised.
let mut full = view();
full.solution_deck_len = 12;
assert!(
!crate::text_of(&html(&full)).contains("shuffles in on next draw"),
"a stocked deck claimed a reshuffle was coming"
);
}
}
/// CB-WP-0028 T03 — one overhead table, not three diagrams.
#[cfg(test)]
mod overhead_table {
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
use cb_kernel::PlayerId;
use games_ground::GroundState;
/// Seat circle centres, read out of the emitted SVG.
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
html.match_indices("<circle cx=\"")
.filter_map(|(i, _)| {
let rest = &html[i + 12..];
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.
///
/// Asserted on the declared height, which is the only thing a test
/// without a browser can see, and that limit is said out loud rather
/// than implied.
#[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 <= 460.0,
"the table is {h}px tall; the action cards end up off-screen and \
dragging to a Problem becomes impossible"
);
}
/// 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,
&[],
&[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,
&[],
&[],
);
assert!(
html.contains("action=\"/note?t=x\""),
"the form must carry the session token"
);
assert!(
html.contains("method=\"post\""),
"a plain form, so it works with the script disabled"
);
}
}
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
#[cfg(test)]
mod two_columns {
use cb_kernel::PlayerId;
use crate::doc::document_with_log;
fn page(meta: &[String]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
crate::TEST_ENDPOINTS,
Some(PlayerId(0)),
false,
&[],
meta,
)
}
/// The columns exist, and the game is in the left one.
#[test]
fn the_table_is_in_the_game_column_and_the_log_is_not() {
let html = page(&[]);
let game = html
.split("class=\"cb-game\"")
.nth(1)
.and_then(|s| s.split("class=\"cb-meta\"").next())
.expect("a game column followed by a meta column");
assert!(
game.contains("<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", &[], &[])
}
/// 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", &[], &[]),
doc::document(
&crate::testfix::view(Some(PlayerId(0))),
&[games_ground::GroundCommand::SelectAction {
action: games_ground::Action::Investigate,
target: None,
problem: Some(2),
}],
"/command?t=x",
Some(PlayerId(0)),
true,
),
];
for html in &pages {
for key in ["again", "done", "pass"] {
let Some(i) = html.find(&format!("data-drop=\"{key}\"")) else {
continue;
};
let tag = &html[html[..i].rfind('<').expect("an opening tag")..i];
assert!(
!tag.contains("pick"),
"`{key}` is a click target wearing `.pick`, which is cursor:grab — \
it invites a drag, and a drag onto nothing posts nothing"
);
assert!(
tag.contains("tap"),
"`{key}` must still look pressable: {tag}"
);
}
}
// The inverse, or this passes for a page with no affordances at
// all: a real draggable still carries `.pick`.
assert!(
pages[1].contains("class=\"card act pick\""),
"action cards must still be draggable"
);
}
/// **CB-WP-0028 T06, asserted both ways.** A test that only checked
/// the win case would pass for a page that always says "solved".
#[test]
fn a_won_game_is_solved_and_a_lost_one_is_over() {
let mut won = crate::testfix::view(None);
if let Some(o) = won.outcome.as_mut() {
o.group_success = true;
}
let mut lost = crate::testfix::view(None);
if let Some(o) = lost.outcome.as_mut() {
o.group_success = false;
}
let head = |v: &games_ground::view::GroundView| {
crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[]))
};
assert!(
head(&won).contains("game solved"),
"a won game said otherwise"
);
assert!(
!head(&won).contains("game over"),
"\"game over\" is arcade vocabulary for a failure state"
);
assert!(
head(&lost).contains("game over"),
"a lost game said otherwise"
);
assert!(!head(&lost).contains("game solved"));
// And a game with no outcome claims neither.
let none = crate::text_of(&doc::ending(
None,
"P1 ran out of input",
"/command?t=x",
&[],
&[],
));
assert!(!none.contains("game solved") && !none.contains("game over"));
}
/// **CB-WP-0028 T07.** Every ranking names its source, and the
/// co-operative mode is not ranked at all — the game says its
/// tiebreak is "Not applicable", and ranking it anyway would invent
/// scoring the rules do not have.
#[test]
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::SharedGround;
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
assert!(
text.contains("problems solved"),
"the countable fact is missing"
);
assert!(
text.contains("not ranked"),
"SHARED GROUND must not be ranked: {text}"
);
assert!(
!text.contains("clay-borg's reading"),
"a reading must not be offered where the game defines no ranking"
);
}
/// And the inverse, or the test above passes for a page that never
/// ranks anything. A ranked mode cites the GAME's tiebreak and marks
/// anything derived as ours.
#[test]
fn a_ranked_mode_cites_the_games_own_tiebreak() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::BondedCoalitions;
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
assert!(
text.contains("Lower combined Stress"),
"the tiebreak shown must be the edition's own words: {text}"
);
assert!(
text.contains("clay-borg&#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;