ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply audit that found nothing and says so. T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random: Protection reaches 1 per seat and 2 on the table against a supply of 6; Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame 0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule IS the twelve-token supply written twice, which is the shape of a supply needing no separate enforcement. AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a seat's own placed Focus against its OWN blame_from -- but that list holds OTHER players' discs, so they are different tokens. It reported 2 conflicts; corrected, it reports 0. Fifth instance of this project's recurring defect, a number computed correctly about the wrong subject, and the first caught before it left the repo rather than by a reviewer. D2: a token is a VIEW, not a type. The aggregate gains no `Token` -- adding one would create a second source of truth for Stress, and the first time they disagreed the bug would be invisible because both would look internally consistent. D3: quantity does NOT bind, and the reason is not the measurement. A component limit the rules do not state is not a rule. Refusing a seventh Protection token would enforce something nobody ruled -- CB-WP-0023's error inverted: SOLVE was OFFERED where it could not act; this would REFUSE where the rules allow. The check ships as a standing control, so a future violation becomes a question for ground-game (does the box bound the game, or do the rules?) rather than a bound the engine invented. Registered as F22, withdrawn: a stated negative, because a survey that finds nothing and leaves no trace cannot be told from one never run. D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it is, Protection and Blame counted, Lead and Round on the table. Two tests broke on token discs and both were FIXTURE defects: seat_centres matched every <circle> and track stops are circles. Seats now carry class="seat". The table height limit went 460 -> 500 as a CORRECTION, not a concession. 460 had no derivation; 500 does -- ~800px viewport less ~120 header and ~150 controls leaves ~530, and the version that broke dragging was 620. CB-WP-0021 T06's rule is to fix the measurement rather than lower the floor, and an underived number is a measurement defect. make all: exit 0. 66 render tests, 26 cb-play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1585 lines
59 KiB
Rust
1585 lines
59 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.
|
||
///
|
||
/// Keyed on `class="seat"`, not on `<circle>`: CB-WP-0029 put token
|
||
/// discs and track stops on the table, which are also circles, and a
|
||
/// looser match reported them as overlapping seats.
|
||
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
|
||
html.match_indices("<circle class=\"seat\" cx=\"")
|
||
.filter_map(|(i, _)| {
|
||
let rest = &html[i + 25..];
|
||
let (x, rest) = rest.split_once("\" cy=\"")?;
|
||
let (y, _) = rest.split_once('"')?;
|
||
Some((x.parse().ok()?, y.parse().ok()?))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn view_of(players: u8) -> games_ground::view::GroundView {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
7,
|
||
)
|
||
.expect("preset")
|
||
.project(Viewer::Player(PlayerId(0)))
|
||
}
|
||
|
||
/// **Every seat count lays out, and no two seats land on each other.**
|
||
/// Asserted per count rather than eyeballed at three, which is the
|
||
/// only count anyone ever looks at.
|
||
#[test]
|
||
fn two_through_six_seats_all_lay_out_without_overlap() {
|
||
for players in 2..=6u8 {
|
||
let v = view_of(players);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
|
||
let seats = seat_centres(&html);
|
||
assert_eq!(
|
||
seats.len(),
|
||
players as usize,
|
||
"{players}p: expected one circle per seat, got {seats:?}"
|
||
);
|
||
for (i, a) in seats.iter().enumerate() {
|
||
for b in &seats[i + 1..] {
|
||
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
||
assert!(
|
||
d > 64.0,
|
||
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
||
they overlap"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A table where you cannot find yourself is worse than a list.
|
||
#[test]
|
||
fn the_viewers_own_seat_is_marked() {
|
||
let v = view_of(4);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
assert!(
|
||
crate::text_of(&html).contains("P1 (you)"),
|
||
"the viewer's seat is not identifiable"
|
||
);
|
||
assert!(
|
||
html.contains("stroke=\"#9cf\" stroke-width=\"3\""),
|
||
"the viewer's seat should also be visually distinct, not only labelled"
|
||
);
|
||
// A spectator has no seat to mark, and must not claim one.
|
||
let spec = GroundState::setup(
|
||
&Setup {
|
||
players: 4,
|
||
preset: "standard-4p".into(),
|
||
patch: Default::default(),
|
||
},
|
||
7,
|
||
)
|
||
.expect("preset")
|
||
.project(Viewer::Spectator);
|
||
assert!(
|
||
!crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"),
|
||
"a spectator was given a seat"
|
||
);
|
||
}
|
||
|
||
/// **Observation 1: the seats were ON the table, not around it.**
|
||
/// Every seat circle must lie outside the ellipse.
|
||
#[test]
|
||
fn the_seats_sit_outside_the_table_not_on_it() {
|
||
for players in 2..=6u8 {
|
||
let v = view_of(players);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64);
|
||
for (x, y) in seat_centres(&html) {
|
||
// Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the
|
||
// seat's own radius kept clear of the rim.
|
||
let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2);
|
||
assert!(
|
||
d > 1.0,
|
||
"{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Observations 3 and 4: the table you can see is the table you drop
|
||
/// on, and the game must be playable.** A `table` drop target that is
|
||
/// not the drawn table is why a player could not find where to drop.
|
||
#[test]
|
||
fn the_drawn_table_is_the_drop_target_and_there_is_only_one() {
|
||
let v = view_of(3);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
assert_eq!(
|
||
html.matches("data-drop=\"table\"").count(),
|
||
1,
|
||
"two elements claim to be the table; a player cannot tell which to use"
|
||
);
|
||
let table = html
|
||
.split("aria-label=\"the table, seen from above\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("</svg>").next())
|
||
.expect("one table svg");
|
||
assert!(
|
||
table.contains("data-drop=\"table\""),
|
||
"the drop target is not on the drawn table"
|
||
);
|
||
assert!(
|
||
table.contains("<ellipse"),
|
||
"the drop target should be the surface itself"
|
||
);
|
||
}
|
||
|
||
/// **The reason the game became unplayable.** The table was 620px
|
||
/// tall, so the action cards sat a screen below the Problems — and you
|
||
/// cannot drag between two things never on screen together.
|
||
///
|
||
/// The limit is **derived, not guessed**. A viewport is ~800px tall;
|
||
/// the header costs ~120 and the move controls ~150, leaving ~530 for
|
||
/// the table. The version that broke dragging declared 620. 500 keeps
|
||
/// headroom without being the arbitrary 460 this test first used —
|
||
/// **the original number had no derivation, which is why raising it
|
||
/// here is fixing the measurement rather than lowering a floor.**
|
||
#[test]
|
||
fn the_table_is_short_enough_to_drag_from() {
|
||
let v = view_of(6);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let vb = html
|
||
.split("viewBox=\"0 0 760 ")
|
||
.nth(1)
|
||
.and_then(|s| s.split('"').next())
|
||
.expect("the table declares a viewBox");
|
||
let h: f64 = vb.parse().expect("a number");
|
||
assert!(
|
||
h <= 500.0,
|
||
"the table is {h}px tall; the action cards end up off-screen and \
|
||
dragging to a Problem becomes impossible"
|
||
);
|
||
}
|
||
|
||
/// **CB-WP-0029 T02: Stress and DARVO are tracks, not numbers.**
|
||
///
|
||
/// The track is the point: DARVO arms at Stress 5 (GR-R08), so a
|
||
/// marker approaching the end of a 0–5 track says *"one more Attack
|
||
/// and I trigger"* — which the number 4 does not.
|
||
#[test]
|
||
fn stress_and_darvo_are_tracks_a_player_can_read_ahead_on() {
|
||
let mut v = view_of(3);
|
||
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
|
||
if let Some(p) = v.players.get_mut(&seats[0]) {
|
||
p.stress = 4;
|
||
p.darvo = games_ground::DarvoStage::Deny;
|
||
}
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
|
||
assert!(
|
||
html.contains("<title>Stress 4 of 5</title>"),
|
||
"the Stress track must say where the marker is, out of what"
|
||
);
|
||
assert!(
|
||
html.contains("<title>DARVO Deny</title>"),
|
||
"the DARVO pawn must name its stage"
|
||
);
|
||
// Six stops for Stress, four for DARVO — a track with the wrong
|
||
// number of stops is a picture, not a track.
|
||
let track_stops = html.matches("<circle cx=").count();
|
||
assert!(
|
||
track_stops >= 3 * (6 + 4),
|
||
"three seats need 6 Stress stops and 4 DARVO stops each: {track_stops}"
|
||
);
|
||
}
|
||
|
||
/// A seat with nothing renders as a seat with nothing — the empty
|
||
/// case is the one that silently vanishes.
|
||
#[test]
|
||
fn a_seat_with_no_tokens_still_has_its_tracks() {
|
||
let mut v = view_of(2);
|
||
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
|
||
if let Some(p) = v.players.get_mut(&seats[0]) {
|
||
p.stress = 0;
|
||
p.protection = 0;
|
||
p.blame_from.clear();
|
||
p.freedom_ready = false;
|
||
p.darvo = games_ground::DarvoStage::Off;
|
||
}
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
|
||
assert!(html.contains("<title>Stress 0 of 5</title>"));
|
||
assert!(html.contains("<title>DARVO Off</title>"));
|
||
assert!(
|
||
html.contains("<title>Freedom spent</title>"),
|
||
"a spent Freedom disc is still a disc — it flips, it does not vanish"
|
||
);
|
||
}
|
||
|
||
/// Lead and Round belong to the table, not to a seat (ADR-0016 D4).
|
||
#[test]
|
||
fn the_lead_and_round_markers_are_on_the_table() {
|
||
let v = view_of(4);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let table = html
|
||
.split("aria-label=\"the table, seen from above\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("</svg>").next())
|
||
.expect("one table svg");
|
||
assert!(
|
||
table.contains("Lead marker:"),
|
||
"the Lead marker is not on the table"
|
||
);
|
||
assert!(
|
||
table.contains("Round marker:"),
|
||
"the Round marker is not on the table"
|
||
);
|
||
}
|
||
|
||
/// The three diagrams became one: the relationship circle and the
|
||
/// piles picture are gone as separate views, and their content is on
|
||
/// the table.
|
||
#[test]
|
||
fn the_stacks_and_the_relations_are_on_the_table() {
|
||
let v = view_of(3);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let table = html
|
||
.split("aria-label=\"the table, seen from above\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("</svg>").next())
|
||
.expect("one table svg");
|
||
assert!(
|
||
table.contains("draw pile:"),
|
||
"the draw stack is not on the table"
|
||
);
|
||
assert!(
|
||
table.contains("discard pile:"),
|
||
"the discard is not on the table"
|
||
);
|
||
assert!(
|
||
!html.contains("relationship graph"),
|
||
"the old separate relationship diagram is still being drawn"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0028 T02 — the cards say what they do, in the edition's words.
|
||
#[cfg(test)]
|
||
mod card_words {
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::{Action, GroundCommand};
|
||
|
||
/// The GROUND card's own tagline reaches the page. This is finding
|
||
/// F18's acceptance test and it has a person attached to it: the
|
||
/// maintainer said he did not understand this card.
|
||
#[test]
|
||
fn the_ground_card_explains_itself_on_the_page() {
|
||
let legal = vec![GroundCommand::SelectAction {
|
||
action: Action::Ground,
|
||
target: None,
|
||
problem: None,
|
||
}];
|
||
let html = crate::doc::document(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&legal,
|
||
"/command?t=x",
|
||
Some(PlayerId(0)),
|
||
false,
|
||
);
|
||
let text = crate::text_of(&html);
|
||
assert!(
|
||
text.contains("Regulate. Restore the frame. Decide."),
|
||
"the GROUND card's tagline did not reach the page: {text}"
|
||
);
|
||
assert!(
|
||
text.contains("Ground & Restate"),
|
||
"its rules text must be available on demand"
|
||
);
|
||
// From the dataset, not from us.
|
||
let from_edition = games_ground::edition::actions()
|
||
.expect("actions")
|
||
.into_iter()
|
||
.any(|c| c.title == "GROUND" && text.contains(&c.tagline));
|
||
assert!(from_edition, "the tagline on the page is not the edition's");
|
||
}
|
||
|
||
/// A Problem shows its own name. The page said `Repair 2` for a card
|
||
/// that reads "Missed Deadline", using columns vendored eight days
|
||
/// earlier and discarded at parse time (ADR-0015 D1).
|
||
#[test]
|
||
fn a_problem_shows_the_name_the_card_has() {
|
||
let titles: Vec<String> = games_ground::edition::problem_texts("SCN_01")
|
||
.expect("SCN_01")
|
||
.into_iter()
|
||
.map(|t| t.title)
|
||
.collect();
|
||
assert!(!titles.is_empty());
|
||
|
||
let mut view = crate::testfix::view(Some(PlayerId(0)));
|
||
// Priorities the fixture uses must exist in the edition for the
|
||
// lookup to resolve; use the edition's own.
|
||
let real = games_ground::edition::problem_texts("SCN_01").expect("SCN_01");
|
||
let keys: Vec<u32> = view.problems.keys().copied().collect();
|
||
for (k, t) in keys.iter().zip(real.iter()) {
|
||
if let Some(p) = view.problems.remove(k) {
|
||
view.problems.insert(u32::from(t.priority), p);
|
||
}
|
||
}
|
||
let text = crate::text_of(&crate::doc::document(
|
||
&view,
|
||
&[],
|
||
"/command?t=x",
|
||
Some(PlayerId(0)),
|
||
false,
|
||
));
|
||
assert!(
|
||
titles.iter().any(|t| text.contains(t.as_str())),
|
||
"no Problem title from the edition appears on the page: {text}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
|
||
#[cfg(test)]
|
||
mod notes {
|
||
use cb_kernel::PlayerId;
|
||
|
||
use crate::doc::{document_with_log, text_of};
|
||
use crate::input::{resolve, Note, PointerFact};
|
||
|
||
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
|
||
/// perfectly well-formed pointer fact must not become a move.
|
||
///
|
||
/// Structural, not vigilance: `Note::parse` returns a `Note`,
|
||
/// `resolve` takes a `PointerFact`, and nothing converts between
|
||
/// them. This asserts the behaviour anyway, because "the types don't
|
||
/// connect" is a claim about code layout until something checks it.
|
||
#[test]
|
||
fn a_note_that_looks_like_a_move_is_not_one() {
|
||
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
|
||
let note = Note::parse(hostile).expect("it parses as a note");
|
||
assert_eq!(
|
||
note.text, "down=action-solve&up=problem-1",
|
||
"the text is stored verbatim, uninterpreted"
|
||
);
|
||
|
||
// And the command parser refuses the same body outright — the two
|
||
// channels do not overlap even at the wire level.
|
||
assert!(
|
||
PointerFact::parse(hostile).is_err(),
|
||
"the command channel accepted a note body"
|
||
);
|
||
// The reverse, so this is not vacuous: a real pointer fact IS a
|
||
// command, and is NOT a note.
|
||
assert!(PointerFact::parse("down=a&up=b").is_ok());
|
||
assert!(
|
||
Note::parse("down=a&up=b").is_err(),
|
||
"the note channel accepted a pointer fact"
|
||
);
|
||
|
||
// Nothing in the crate turns a Note into a command. `resolve`'s
|
||
// signature is the proof; this pins it against a careless change.
|
||
let legal: Vec<games_ground::GroundCommand> = vec![];
|
||
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
|
||
}
|
||
|
||
/// An empty note is refused, not stored — a blank row is noise in the
|
||
/// register.
|
||
#[test]
|
||
fn an_empty_note_is_refused() {
|
||
assert!(Note::parse("note=").is_err());
|
||
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
|
||
assert_eq!(
|
||
Note::parse("note=%20hello%20").expect("real text").text,
|
||
"hello",
|
||
"surrounding whitespace is trimmed"
|
||
);
|
||
}
|
||
|
||
/// Percent and `+` decoding, since the form posts urlencoded.
|
||
#[test]
|
||
fn the_players_words_survive_the_wire() {
|
||
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
|
||
assert_eq!(n.text, "why is SOLVE doing nothing?");
|
||
}
|
||
|
||
/// **The first hostile input this renderer has handled.** Until now
|
||
/// `esc()` escaped suit names.
|
||
#[test]
|
||
fn a_note_containing_markup_renders_as_text() {
|
||
let hostile = "<script>alert(1)</script> & \"quoted\"";
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
&[],
|
||
&[hostile.to_string()],
|
||
);
|
||
assert!(
|
||
!html.contains("<script>alert(1)</script>"),
|
||
"a note's markup reached the document unescaped"
|
||
);
|
||
assert!(
|
||
html.contains("<script>"),
|
||
"the note should still be visible, escaped"
|
||
);
|
||
assert!(
|
||
text_of(&html).contains("alert(1)"),
|
||
"escaping must not eat the player's words — they still read what they wrote"
|
||
);
|
||
}
|
||
|
||
/// The comment box is always offered, and the page works without it
|
||
/// being used. A control that appears only sometimes trains a player
|
||
/// not to look for it.
|
||
#[test]
|
||
fn the_comment_box_is_always_there() {
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
&[],
|
||
&[],
|
||
);
|
||
assert!(
|
||
html.contains("action=\"/note?t=x\""),
|
||
"the form must carry the session token"
|
||
);
|
||
assert!(
|
||
html.contains("method=\"post\""),
|
||
"a plain form, so it works with the script disabled"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
|
||
#[cfg(test)]
|
||
mod two_columns {
|
||
use cb_kernel::PlayerId;
|
||
|
||
use crate::doc::document_with_log;
|
||
|
||
fn page(meta: &[String]) -> String {
|
||
document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
&[],
|
||
meta,
|
||
)
|
||
}
|
||
|
||
/// The columns exist, and the game is in the left one.
|
||
#[test]
|
||
fn the_table_is_in_the_game_column_and_the_log_is_not() {
|
||
let html = page(&[]);
|
||
let game = html
|
||
.split("class=\"cb-game\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
||
.expect("a game column followed by a meta column");
|
||
assert!(
|
||
game.contains("<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'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;
|