Some checks failed
ci / check (push) Failing after 4s
Reported as "I did a session and noted no changes" — correct, and the fault was ours twice. make ground passes no --variant, so the session was baseline, and CB-WP-0043 deliberately leaves the baseline layout untouched because a variant that redraws the baseline invalidates every prior look at it. But the deeper defect is that nothing said so. The page named the round, the step, the Lead, the scoring mode and the viewer, and never which rules it was playing — GroundView did not even carry the variant. There was no way from the screen to tell baseline from H1 from H2. A session that cannot say which rules it is playing cannot report a rules change; the player did the right thing and the instrument had nothing to tell them. Now: variant on GroundView, `rules <id>` in the page header beside the scoring mode, `rules <id>` in the inspector because a replay that cannot say which rules produced it is the same defect in the other tool, and make ground VARIANT=h2 so the capability is reachable. Both coverage probes caught the new field independently — the render crate's and cb-play's — the second time in two passes that they have turned an addition into a legibility requirement instead of letting it be silent state. Verified by fetching the served page rather than by reading the code: make ground VARIANT=h2 prints "rules: h2" and the page carries "rules h2-scoped-problem-stress" with the scope labels; the baseline says "rules ground-darvo-r0" and keeps its row. Still open: nothing explains what a scope DOES, and the trial log header does not record the variant either — the same defect one artifact along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2039 lines
76 KiB
Rust
2039 lines
76 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 {
|
||
alive: "/alive?t=x",
|
||
command: "/command?t=x",
|
||
note: "/note?t=x",
|
||
};
|
||
pub use input::{resolve, Note, PointerFact};
|
||
pub use serve::{Guard, Refusal, Request};
|
||
|
||
#[cfg(test)]
|
||
mod coverage {
|
||
//! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s
|
||
//! `every_view_field_is_classified`.
|
||
//!
|
||
//! Same shape as CB-WP-0011's gate, one layer further out: walk the
|
||
//! serialized `GroundView` for every leaf *path*, and require each to
|
||
//! be either rendered — with a token that must appear in the **parsed
|
||
//! document** — or omitted with a stated reason. A new field on
|
||
//! `GroundView` that is in neither list fails the build.
|
||
//!
|
||
//! Paths, not keys: `problem` occurs under a DARVO target, a GROUND
|
||
//! choice and a Selection, and a key-set walk would let one vouch for
|
||
//! the other two.
|
||
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::view::GroundView;
|
||
|
||
use crate::doc::{document, text_of};
|
||
|
||
/// Every leaf path in the serialized view.
|
||
fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec<String>) {
|
||
match v {
|
||
serde_json::Value::Object(m) if !m.is_empty() => {
|
||
for (k, val) in m {
|
||
let p = if prefix.is_empty() {
|
||
k.clone()
|
||
} else {
|
||
format!("{prefix}.{k}")
|
||
};
|
||
paths(val, &p, out);
|
||
}
|
||
}
|
||
serde_json::Value::Array(a) if !a.is_empty() => {
|
||
for item in a {
|
||
paths(item, &format!("{prefix}.*"), out);
|
||
}
|
||
}
|
||
_ => out.push(prefix.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Collapse map keys to `*` so the classification is over fields, not
|
||
/// over whichever seats and priorities the fixture happens to hold.
|
||
fn normalize(path: &str) -> String {
|
||
const MAPS: &[&str] = &[
|
||
"players",
|
||
"relations",
|
||
"problems",
|
||
"problem_markers",
|
||
"focus",
|
||
"selections",
|
||
"ground_modes",
|
||
"ground_choices",
|
||
"support_responses",
|
||
"darvo_targets",
|
||
"personal",
|
||
];
|
||
let mut parts: Vec<String> = Vec::new();
|
||
let mut prev_is_map = false;
|
||
for seg in path.split('.') {
|
||
if prev_is_map && seg != "*" {
|
||
parts.push("*".to_string());
|
||
} else {
|
||
parts.push(seg.to_string());
|
||
}
|
||
prev_is_map = MAPS.contains(&seg);
|
||
}
|
||
parts.join(".")
|
||
}
|
||
|
||
/// `(leaf path, a token the parsed document must contain)`.
|
||
const RENDERED: &[(&str, &str)] = &[
|
||
("round", "round 3"),
|
||
("lead", "lead P2"),
|
||
("step", "step Select"),
|
||
("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"),
|
||
// CB-WP-0034: words, not Debug — and these probes are the reason
|
||
// it survived so long. They matched `problem: 7 }`,
|
||
// `player: Some(PlayerId(1))` and `members: [PlayerId(1)`, so the
|
||
// gate that exists to prove every field reaches the PLAYER was
|
||
// proving it by matching Rust struct syntax. It certified the
|
||
// defect as coverage.
|
||
//
|
||
// Second confirmation of CB-WP-0024's finding, from the other
|
||
// side: a probe naming a PRESENTATION does not survive a
|
||
// rendering change, and one naming Debug output actively pins it.
|
||
("ground_modes.*", "Ground & Restate"),
|
||
("ground_choices.*.choice", "protect Problem"),
|
||
("ground_choices.*.problem", "Problem 7 from Deny"),
|
||
("support_responses.*", "accepted P3\u{2019}s Support"),
|
||
("darvo_targets.*.problem", "Problem 1"),
|
||
("darvo_targets.*.player", "darvo target P2"),
|
||
// CB-WP-0043: H2's owner marker. The scope is expressed as
|
||
// POSITION, which no text probe can see — so each scoped Problem
|
||
// also says whose it is, and that is what these match. A probe
|
||
// that could only be satisfied by geometry would have to be
|
||
// OMITTED, and the page would be illegible to `text_of` and to a
|
||
// screen reader alike.
|
||
("variant", "ground-darvo-r0"),
|
||
("problem_markers.*.owner", "P1\u{2019}s alone"),
|
||
("problem_markers.*.scope", "P2\u{2019}s Bond network"),
|
||
("outcome.total", "total 9"),
|
||
("outcome.threshold", "of 12"),
|
||
("outcome.group_success", "failure"),
|
||
("outcome.personal.*", "P1 +3"),
|
||
("outcome.coalitions.*.members.*", "P2 + P3"),
|
||
("outcome.coalitions.*.score", "(score 4)"),
|
||
("outcome.mastery", "mastery +1"),
|
||
("outcome.winners.*", "winners P1"),
|
||
];
|
||
|
||
/// Deliberate omissions, each with a reason.
|
||
const OMITTED: &[(&str, &str)] = &[(
|
||
"players.*.hand",
|
||
"null for a non-viewer seat; the absence is rendered as a count",
|
||
)];
|
||
|
||
fn fixture() -> GroundView {
|
||
crate::testfix::view(Some(PlayerId(0)))
|
||
}
|
||
|
||
fn rendered_document(view: &GroundView) -> String {
|
||
text_of(&document(
|
||
view,
|
||
&[],
|
||
crate::TEST_ENDPOINTS.command,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
))
|
||
}
|
||
|
||
#[test]
|
||
fn every_view_field_is_classified_in_the_emitted_document() {
|
||
let view = fixture();
|
||
let json = serde_json::to_value(&view).expect("view serializes");
|
||
let mut raw = Vec::new();
|
||
paths(&json, "", &mut raw);
|
||
let all: std::collections::BTreeSet<String> = raw.iter().map(|p| normalize(p)).collect();
|
||
|
||
// EXPECT-VACUOUS floor. A coverage test over zero paths passes
|
||
// trivially, and that is precisely how this check would rot.
|
||
assert!(
|
||
all.len() >= 30,
|
||
"the walk found {} leaf path(s) — it is not walking the view",
|
||
all.len()
|
||
);
|
||
|
||
let claimed: std::collections::BTreeSet<&str> = RENDERED
|
||
.iter()
|
||
.map(|(p, _)| *p)
|
||
.chain(OMITTED.iter().map(|(p, _)| *p))
|
||
.collect();
|
||
|
||
let unclassified: Vec<&String> = all
|
||
.iter()
|
||
.filter(|p| !claimed.contains(p.as_str()))
|
||
.collect();
|
||
assert!(
|
||
unclassified.is_empty(),
|
||
"unclassified path(s) in GroundView: {unclassified:?} \
|
||
— add each to RENDERED with a token, or to OMITTED with a reason"
|
||
);
|
||
|
||
let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect();
|
||
assert!(
|
||
stale.is_empty(),
|
||
"classified path(s) no longer exist in GroundView: {stale:?}"
|
||
);
|
||
|
||
// The half that makes it a rendering gate rather than a bookkeeping
|
||
// one: the token must be in the *parsed document*.
|
||
let text = rendered_document(&view);
|
||
let missing: Vec<&str> = RENDERED
|
||
.iter()
|
||
.filter(|(_, tok)| !text.contains(tok))
|
||
.map(|(p, _)| *p)
|
||
.collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"claimed rendered, but absent from the emitted document: {missing:?}"
|
||
);
|
||
}
|
||
|
||
/// The parse must be a parse. If `text_of` returned the raw source, a
|
||
/// token hiding in a comment, a style rule or the script would count
|
||
/// as rendered — which is the loophole control 6 exists to close.
|
||
#[test]
|
||
fn the_parse_does_not_see_script_or_style_contents() {
|
||
let html = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
|
||
<script>var s='SCRIPTTOKEN';</script>";
|
||
let text = text_of(html);
|
||
assert!(text.contains("seen"));
|
||
assert!(text.contains("pid"), "element ids are addressable surface");
|
||
assert!(
|
||
!text.contains("STYLETOKEN"),
|
||
"style contents leaked into the text"
|
||
);
|
||
assert!(
|
||
!text.contains("SCRIPTTOKEN"),
|
||
"script contents leaked into the text"
|
||
);
|
||
}
|
||
|
||
/// Control 5, asserted at the document level: the emitted JavaScript
|
||
/// must contain no game vocabulary. If a rule ever needs to live in
|
||
/// the page, ADR-0007 says revisit the decision, not widen this.
|
||
#[test]
|
||
fn the_emitted_javascript_contains_no_game_vocabulary() {
|
||
let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let script = doc
|
||
.split_once("<script>")
|
||
.and_then(|(_, r)| r.rsplit_once("</script>"))
|
||
.map(|(s, _)| s.to_string())
|
||
.expect("the document carries a script");
|
||
for word in [
|
||
"Investigate",
|
||
"Solve",
|
||
"Support",
|
||
"Attack",
|
||
"Ground",
|
||
"darvo",
|
||
"DARVO",
|
||
"stress",
|
||
"freedom",
|
||
"problem",
|
||
"coalition",
|
||
"reveal",
|
||
"resolve",
|
||
] {
|
||
assert!(
|
||
!script.contains(word),
|
||
"the emitted JavaScript mentions {word:?} — control 5 is breached"
|
||
);
|
||
}
|
||
// And it must still be doing its one job.
|
||
assert!(script.contains("pointerdown") && script.contains("pointerup"));
|
||
}
|
||
|
||
/// The renderer must not invent a hand it was never given.
|
||
///
|
||
/// Scoped deliberately: the *projection's* hiding rules are asserted
|
||
/// where they live, and re-asserting them here would be a duplicated
|
||
/// fact that drifts. What this checks is the renderer's own failure
|
||
/// mode — that `hand: None` renders as an absence, for every seat, and
|
||
/// that a spectator's document contains no open hand at all.
|
||
#[test]
|
||
fn the_renderer_never_invents_a_hand_it_was_not_given() {
|
||
for seat in [0u8, 1, 2] {
|
||
let view = crate::testfix::view(Some(PlayerId(seat)));
|
||
let text = rendered_document(&view);
|
||
let shown = text.matches("cards)").count();
|
||
assert_eq!(
|
||
shown,
|
||
1,
|
||
"P{} sees {shown} open hands in the document; it was given exactly one",
|
||
seat + 1
|
||
);
|
||
assert_eq!(
|
||
text.matches("card(s), hidden").count(),
|
||
2,
|
||
"the other two seats' hands must render as an absence with a count"
|
||
);
|
||
}
|
||
let text = rendered_document(&crate::testfix::view(None));
|
||
assert_eq!(
|
||
text.matches("cards)").count(),
|
||
0,
|
||
"a spectator document shows an open hand"
|
||
);
|
||
assert!(text.contains("a spectator (no hands)"));
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0016 T03: every affordance the page offers must name an element
|
||
/// the page actually contains.
|
||
///
|
||
/// **This is the check that closes the class the human check found.** On
|
||
/// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an
|
||
/// action onto a seat. Every test in the repo was green. The cause: the
|
||
/// visible seat cards carried no `id`, so `seat-{n}` existed only on the
|
||
/// 26 px circles inside the relationship graph, while every action card
|
||
/// read *"drag Attack onto a seat…"*.
|
||
///
|
||
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
||
/// into a synthetic `{target:{id}}` and never hit-tests, so it establishes
|
||
/// *"the script posts the ids it was given"* — never *"there is an element
|
||
/// there to give."* The 42-path coverage gate asserts each view field is
|
||
/// present in the parsed document, which a `<div>` with no id satisfies.
|
||
///
|
||
/// So: drive a real game, and at every decision point require that both
|
||
/// halves of every offered affordance appear as real `id` attributes.
|
||
#[cfg(test)]
|
||
mod affordances {
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::bot::{play, Choice, Policy, RandomPolicy};
|
||
use games_ground::{GroundCommand, GroundState};
|
||
|
||
use crate::{doc, input};
|
||
|
||
fn fresh(seed: u64) -> GroundState {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players: 3,
|
||
preset: "standard-3p".into(),
|
||
patch: std::collections::BTreeMap::new(),
|
||
},
|
||
seed,
|
||
)
|
||
.expect("a standard 3p deal")
|
||
}
|
||
|
||
/// Renders the page at every real decision point and checks it, then
|
||
/// delegates the actual choice.
|
||
///
|
||
/// Hooking `Policy` rather than re-driving the game by hand matters:
|
||
/// these are the *same* decision points `cb-play --serve` renders at,
|
||
/// with the same `legal` list. A hand-rolled walk would be a second
|
||
/// implementation of the loop, and could agree with itself while
|
||
/// disagreeing with the thing shipped.
|
||
struct CheckingPolicy {
|
||
inner: RandomPolicy,
|
||
checked: Rc<RefCell<usize>>,
|
||
}
|
||
|
||
impl Policy for CheckingPolicy {
|
||
fn name(&self) -> &'static str {
|
||
"affordance-checking"
|
||
}
|
||
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
may_pass: bool,
|
||
) -> Choice {
|
||
let view = state.project(Viewer::Player(seat));
|
||
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
|
||
let present = doc::drop_keys(&html);
|
||
|
||
for cmd in legal {
|
||
let Some((from, to)) = input::affordance(cmd, seat) else {
|
||
// A command with no affordance is offered through the
|
||
// numbered-button path instead. That is a stated
|
||
// shape, not a missing element.
|
||
continue;
|
||
};
|
||
assert!(
|
||
present.contains(&from),
|
||
"the page offers {cmd:?} whose GRAB id {from:?} is not an \
|
||
element in the document (seat {seat:?}, step {:?})",
|
||
state.step
|
||
);
|
||
assert!(
|
||
present.contains(&to),
|
||
"the page offers {cmd:?} whose DROP id {to:?} is not an \
|
||
element in the document (seat {seat:?}, step {:?}). \
|
||
Present ids: {present:?}",
|
||
state.step
|
||
);
|
||
}
|
||
*self.checked.borrow_mut() += 1;
|
||
self.inner.choose(state, seat, legal, may_pass)
|
||
}
|
||
}
|
||
|
||
/// **The check that closes the class the human check found.**
|
||
///
|
||
/// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not
|
||
/// drag an action onto a seat. Every test in the repo was green. The
|
||
/// cause: the visible seat cards carried no `id`, so `seat-{n}` existed
|
||
/// only on the 26 px circles inside the relationship graph, while every
|
||
/// action card read *"drag Attack onto a seat…"*.
|
||
///
|
||
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
||
/// into a synthetic `{target:{id}}` and never hit-tests, so it
|
||
/// establishes *"the script posts the ids it was given"* — never
|
||
/// *"there is an element there to give."* The coverage gate asserts
|
||
/// each view field appears in the parsed document, which a `<div>` with
|
||
/// no id satisfies perfectly.
|
||
///
|
||
/// An affordance naming an element that does not exist is the
|
||
/// harness-does-nothing shape in the presentation layer, and until now
|
||
/// it had no detector at all.
|
||
#[test]
|
||
fn every_offered_affordance_names_an_element_that_exists() {
|
||
let checked = Rc::new(RefCell::new(0usize));
|
||
for seed in 0..4u64 {
|
||
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
||
.map(|i| {
|
||
Box::new(CheckingPolicy {
|
||
inner: RandomPolicy::new(seed * 10 + i),
|
||
checked: checked.clone(),
|
||
}) as Box<dyn Policy>
|
||
})
|
||
.collect();
|
||
play(fresh(seed), &mut policies).expect("a bot game completes");
|
||
}
|
||
// Positive control: a run that rendered nothing would assert
|
||
// nothing and read as a pass — the exact failure this test exists
|
||
// to catch, one level up.
|
||
let n = *checked.borrow();
|
||
assert!(n >= 50, "checked only {n} decision point(s)");
|
||
}
|
||
|
||
/// **ADR-0010 Decision 2, property 2.** A target the page marks legal
|
||
/// must resolve.
|
||
///
|
||
/// If the page can advertise a drop that Rust then refuses, the two
|
||
/// have drifted and the highlighting is *worse* than none — it teaches
|
||
/// the player something false. This walks real games and checks the
|
||
/// emitted `data-targets` against `resolve` itself, so the page's
|
||
/// promise and the referee's answer cannot disagree.
|
||
#[test]
|
||
fn everything_the_page_advertises_actually_resolves() {
|
||
let checked = Rc::new(RefCell::new(0usize));
|
||
for seed in 0..4u64 {
|
||
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
||
.map(|i| {
|
||
Box::new(AdvertisedPolicy {
|
||
inner: RandomPolicy::new(seed * 7 + i),
|
||
checked: checked.clone(),
|
||
}) as Box<dyn Policy>
|
||
})
|
||
.collect();
|
||
play(fresh(seed), &mut policies).expect("a bot game completes");
|
||
}
|
||
let n = *checked.borrow();
|
||
assert!(n >= 50, "checked only {n} advertised target(s)");
|
||
}
|
||
|
||
struct AdvertisedPolicy {
|
||
inner: RandomPolicy,
|
||
checked: Rc<RefCell<usize>>,
|
||
}
|
||
|
||
impl Policy for AdvertisedPolicy {
|
||
fn name(&self) -> &'static str {
|
||
"advertised-target-checking"
|
||
}
|
||
|
||
fn choose(
|
||
&mut self,
|
||
state: &GroundState,
|
||
seat: PlayerId,
|
||
legal: &[GroundCommand],
|
||
may_pass: bool,
|
||
) -> Choice {
|
||
let view = state.project(Viewer::Player(seat));
|
||
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
|
||
|
||
for (grab, targets) in crate::jsrun::droppables(&html) {
|
||
let Some(spec) = targets else { continue };
|
||
for drop in spec.split(' ').filter(|s| !s.is_empty()) {
|
||
let fact = crate::PointerFact::new(&grab, drop);
|
||
assert!(
|
||
crate::resolve(&fact, legal, seat).is_ok(),
|
||
"the page advertises {grab} -> {drop} but resolve refuses it \
|
||
(seat {seat:?}, step {:?})",
|
||
state.step
|
||
);
|
||
*self.checked.borrow_mut() += 1;
|
||
}
|
||
}
|
||
self.inner.choose(state, seat, legal, may_pass)
|
||
}
|
||
}
|
||
|
||
/// **The regression test for the defect actually reported**, and the
|
||
/// reason the check above is not sufficient on its own.
|
||
///
|
||
/// `every_offered_affordance_names_an_element_that_exists` passes on
|
||
/// the broken tree. `seat-0` *did* exist — on the 26 px circle in the
|
||
/// relationship graph — so an existence check over the whole document
|
||
/// cannot see that the seat *card*, which is what the instruction text
|
||
/// points at, was not droppable.
|
||
///
|
||
/// A seat is drawn twice and both drawings are the seat. This asserts
|
||
/// the card specifically, by requiring the drop key on the element
|
||
/// that also carries `data-viewer` — the card, and nothing else.
|
||
#[test]
|
||
fn every_seat_card_is_a_drop_target_not_only_the_graph_node() {
|
||
let view = crate::testfix::view(Some(PlayerId(0)));
|
||
let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
|
||
for seat in view.players.keys() {
|
||
let card = format!(
|
||
"data-viewer=\"{}\" data-drop=\"seat-{}\"",
|
||
view.viewer == Some(*seat),
|
||
seat.0
|
||
);
|
||
assert!(
|
||
html.contains(&card),
|
||
"seat {seat:?} has a card that is not a drop target; looked for {card:?}"
|
||
);
|
||
}
|
||
|
||
// And the graph node keeps working — someone will have learned to
|
||
// aim at the circle, and this fix must not take that away.
|
||
let keys = doc::drop_keys(&html);
|
||
for seat in view.players.keys() {
|
||
assert!(keys.contains(&format!("seat-{}", seat.0)));
|
||
}
|
||
assert_eq!(
|
||
html.matches("data-drop=\"seat-0\"").count(),
|
||
2,
|
||
"seat 0 should be droppable in exactly two places: card and graph node"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod gamelog {
|
||
//! CB-WP-0018 T02.
|
||
|
||
use crate::doc::{document_with_log, Account, LogLine};
|
||
use cb_kernel::PlayerId;
|
||
|
||
fn line(effects: &[&str]) -> LogLine {
|
||
LogLine {
|
||
who: "P1".into(),
|
||
what: "select_action action=SOLVE problem=1".into(),
|
||
effects: effects.iter().map(|s| (*s).to_string()).collect(),
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0035. A server that has gone must be NOTICED.
|
||
///
|
||
/// The script's `fetch` had no rejection handler, so when the process
|
||
/// had exited the promise rejected and the chain never ran — not even
|
||
/// the status line moved. Reported twice: *"play again does not
|
||
/// report that the session is no longer available"*, and the linger
|
||
/// timeout going unnoticed.
|
||
///
|
||
/// **Both from one cause**, so both are asserted: the click path and
|
||
/// the heartbeat that needs no click.
|
||
#[test]
|
||
fn a_session_that_answers_nothing_is_reported_and_sealed() {
|
||
let page = crate::doc::ending(
|
||
None,
|
||
"the game ended",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
);
|
||
for (beat, what) in [(false, "pressing a control"), (true, "the heartbeat")] {
|
||
let (live, status) = crate::jsrun::unanswered(&page, beat).expect("run the page");
|
||
assert!(
|
||
status.contains("no longer available"),
|
||
"{what} said nothing about the session: {status:?}"
|
||
);
|
||
assert!(
|
||
status.contains("[sealed]"),
|
||
"{what} left the page looking live: {status:?}"
|
||
);
|
||
assert!(
|
||
live.is_empty(),
|
||
"{what} left {live:?} still droppable on a dead session"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **Both** pages must carry the heartbeat (CB-WP-0035).
|
||
///
|
||
/// The first cut wired it into the ending page only, and the gate
|
||
/// caught it as an unused variable — which is luck, not a control.
|
||
/// The timeout matters MOST during play: that is when a player walks
|
||
/// away, and the table is the thing that must stop looking live.
|
||
#[test]
|
||
fn every_page_can_tell_the_session_has_gone() {
|
||
let playing = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
Account::of(&[]),
|
||
&[],
|
||
);
|
||
let ended = crate::doc::ending(
|
||
None,
|
||
"m",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
);
|
||
for (name, page) in [("the table", &playing), ("the ending page", &ended)] {
|
||
assert!(
|
||
page.contains("CB_ALIVE"),
|
||
"{name} cannot notice the session ending"
|
||
);
|
||
// With the token: an unauthenticated heartbeat is refused,
|
||
// and a refusal every 5s would seal a LIVE session.
|
||
assert!(
|
||
page.contains("/alive?t="),
|
||
"{name}'s heartbeat carries no token"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **A Problem is drawn where its Stress lands** (CB-WP-0043).
|
||
///
|
||
/// H2 scopes End-of-Round Stress, so a row across the middle — true
|
||
/// under the baseline — becomes a lie: three of five cards belong to
|
||
/// particular seats. This checks the placement rule rather than the
|
||
/// fact that something was drawn.
|
||
#[test]
|
||
fn a_problem_sits_where_its_stress_lands() {
|
||
use games_ground::edition::StressScope;
|
||
use games_ground::view::ProblemMarker;
|
||
|
||
let v = crate::testfix::view(Some(PlayerId(0)));
|
||
let svg = crate::doc::document(&v, &[], "/c", Some(PlayerId(0)), false);
|
||
|
||
// Each scope says whose it is — position alone is invisible to
|
||
// `text_of` and to a screen reader.
|
||
let text = crate::text_of(&svg);
|
||
assert!(
|
||
text.contains("everyone\u{2019}s"),
|
||
"the global Problem is unlabelled: {text}"
|
||
);
|
||
assert!(
|
||
text.contains("P1\u{2019}s alone"),
|
||
"the personal Problem is unlabelled"
|
||
);
|
||
assert!(
|
||
text.contains("P2\u{2019}s Bond network"),
|
||
"the bond Problem is unlabelled"
|
||
);
|
||
|
||
// And the anchors really differ: a global Problem must not be
|
||
// drawn where a personal one is.
|
||
let mut baseline = v.clone();
|
||
baseline.problem_markers.clear();
|
||
let row = crate::doc::document(&baseline, &[], "/c", Some(PlayerId(0)), false);
|
||
assert_ne!(
|
||
row, svg,
|
||
"the scoped layout is identical to the baseline row — scope is not \
|
||
reaching the placement"
|
||
);
|
||
assert!(
|
||
!crate::text_of(&row).contains("P1\u{2019}s alone"),
|
||
"the baseline page labels Problems with owners it does not have"
|
||
);
|
||
|
||
// A bond Problem whose owner has no Bond says so, because the
|
||
// RULE falls back to personal at degree 0 and the picture must
|
||
// agree with the arithmetic.
|
||
let mut lonely = v.clone();
|
||
lonely.relations.clear();
|
||
lonely.problem_markers.insert(
|
||
7,
|
||
ProblemMarker {
|
||
owner: Some(PlayerId(1)),
|
||
scope: Some(StressScope::Bond),
|
||
},
|
||
);
|
||
let alone = crate::text_of(&crate::doc::document(
|
||
&lonely,
|
||
&[],
|
||
"/c",
|
||
Some(PlayerId(0)),
|
||
false,
|
||
));
|
||
assert!(
|
||
alone.contains("no Bond to share it"),
|
||
"a bond Problem with no Bonds should read as personal: {alone}"
|
||
);
|
||
}
|
||
|
||
/// CB-WP-0034. The move button must name **who**.
|
||
///
|
||
/// Reported three times across three sessions, two days apart, and it
|
||
/// survived a whole UI rebuild: *"RespondToSupport is unclear as i
|
||
/// cant see whos support i accept"*, then twice more about bonding.
|
||
///
|
||
/// The cause was not styling. The label was `format!("{c:?}")`, so
|
||
/// the button read `RespondToSupport { response: AcceptBond }` — Rust
|
||
/// struct syntax with no name in it — and the command **does not
|
||
/// carry** the counterparty, so nothing rendering the command alone
|
||
/// could have said who. It comes off the view.
|
||
#[test]
|
||
fn a_support_response_names_the_seat_that_offered_it() {
|
||
use games_ground::view::SelectionView;
|
||
use games_ground::{Action, GroundCommand, Selection, SupportResponse};
|
||
let me = PlayerId(0);
|
||
let them = PlayerId(1);
|
||
let mut v = crate::testfix::view(Some(me));
|
||
// P2 played Support at P1, revealed.
|
||
v.selections.insert(
|
||
them,
|
||
SelectionView::Shown(Selection {
|
||
action: Action::Support,
|
||
target: Some(me),
|
||
problem: None,
|
||
}),
|
||
);
|
||
let legal = vec![
|
||
GroundCommand::RespondToSupport {
|
||
response: SupportResponse::AcceptBond,
|
||
},
|
||
GroundCommand::RespondToSupport {
|
||
response: SupportResponse::DeclineBond,
|
||
},
|
||
];
|
||
let html = document_with_log(
|
||
&v,
|
||
&legal,
|
||
crate::TEST_ENDPOINTS,
|
||
Some(me),
|
||
false,
|
||
Account::of(&[]),
|
||
&[],
|
||
);
|
||
let text = crate::text_of(&html);
|
||
assert!(text.contains("accept P2"), "the offer is anonymous: {text}");
|
||
assert!(
|
||
text.contains("decline P2"),
|
||
"the refusal is anonymous: {text}"
|
||
);
|
||
// The defect itself, named: no Rust struct syntax on a button.
|
||
assert!(
|
||
!text.contains("RespondToSupport"),
|
||
"the raw command name is still on the page: {text}"
|
||
);
|
||
assert!(
|
||
!text.contains("AcceptBond"),
|
||
"the raw variant name is still on the page: {text}"
|
||
);
|
||
}
|
||
|
||
/// When the offer is not visible in this view, the page says so.
|
||
///
|
||
/// **It must not invent a name and must not drop the question.** A
|
||
/// confident wrong seat is worse than an honest gap — that is the
|
||
/// family ADR-0018 exists for.
|
||
#[test]
|
||
fn an_unseen_offer_is_not_given_an_invented_name() {
|
||
use games_ground::{GroundCommand, SupportResponse};
|
||
let me = PlayerId(0);
|
||
let mut v = crate::testfix::view(Some(me));
|
||
v.selections.clear();
|
||
let legal = vec![GroundCommand::RespondToSupport {
|
||
response: SupportResponse::AcceptBond,
|
||
}];
|
||
let text = crate::text_of(&document_with_log(
|
||
&v,
|
||
&legal,
|
||
crate::TEST_ENDPOINTS,
|
||
Some(me),
|
||
false,
|
||
Account::of(&[]),
|
||
&[],
|
||
));
|
||
assert!(
|
||
text.contains("this view does not show which seat"),
|
||
"it should say the offer is not visible: {text}"
|
||
);
|
||
for name in ["P1", "P2", "P3"] {
|
||
assert!(
|
||
!text.contains(&format!("accept {name}")),
|
||
"it named {name} with nothing to go on: {text}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// No move button may carry Rust `Debug` output (CB-WP-0034).
|
||
///
|
||
/// The guard against this returning is that `command_label` has no
|
||
/// catch-all arm, so a new variant stops the build. This asserts the
|
||
/// visible half over every command the fixture can offer.
|
||
#[test]
|
||
fn no_move_button_shows_rust_struct_syntax() {
|
||
use games_ground::*;
|
||
let me = PlayerId(0);
|
||
let legal = vec![
|
||
GroundCommand::SpendFreedom,
|
||
GroundCommand::ChooseGroundMode {
|
||
mode: GroundMode::Ou,
|
||
choice: Some(GroundChoice::ProtectProblem { problem: 7 }),
|
||
},
|
||
GroundCommand::ChooseDarvoTarget {
|
||
target: DarvoTarget {
|
||
problem: Some(1),
|
||
player: Some(PlayerId(1)),
|
||
},
|
||
},
|
||
GroundCommand::RespondToSupport {
|
||
response: SupportResponse::FlipToBond,
|
||
},
|
||
];
|
||
let text = crate::text_of(&document_with_log(
|
||
&crate::testfix::view(Some(me)),
|
||
&legal,
|
||
crate::TEST_ENDPOINTS,
|
||
Some(me),
|
||
false,
|
||
Account::of(&[]),
|
||
&[],
|
||
));
|
||
// The tells of a `{:?}` label reaching a player.
|
||
for tell in ["{ ", " }", "Some(", "None", "PlayerId("] {
|
||
assert!(
|
||
!text.contains(tell),
|
||
"Debug output {tell:?} reached the page: {text}"
|
||
);
|
||
}
|
||
// And the words are actually there. `SpendFreedom` is not among
|
||
// them: it carries a spatial affordance, so it is a drag target
|
||
// rather than a button -- which is why this asserts on the
|
||
// commands that DO become buttons.
|
||
assert!(text.contains("Observe & Uphold"), "{text}");
|
||
assert!(text.contains("DARVO target: P2, Problem 1"), "{text}");
|
||
}
|
||
|
||
fn note(after: usize, text: &str) -> crate::doc::LogNote {
|
||
crate::doc::LogNote {
|
||
after,
|
||
round: 2,
|
||
step: "Select".into(),
|
||
text: text.into(),
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0032. A comment appears where it was written.
|
||
///
|
||
/// **Position is the whole feature.** A remark like *"why did that do
|
||
/// nothing?"* is about the move above it; collected at the bottom it
|
||
/// is a list of sentences with no subjects.
|
||
#[test]
|
||
fn comments_appear_in_the_log_where_they_were_written() {
|
||
let lines = vec![line(&["e0"]), line(&["e1"]), line(&["e2"])];
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
Account {
|
||
lines: &lines,
|
||
notes: &[
|
||
note(0, "before I moved"),
|
||
note(2, "why did that do nothing"),
|
||
],
|
||
},
|
||
&[],
|
||
);
|
||
let text = crate::text_of(&html);
|
||
let at = |needle: &str| {
|
||
text.find(needle)
|
||
.unwrap_or_else(|| panic!("missing {needle:?}"))
|
||
};
|
||
// Written before any move: above the first effect.
|
||
assert!(at("before I moved") < at("e0"), "{text}");
|
||
// Written after two commands: after the second, before the third.
|
||
assert!(at("e1") < at("why did that do nothing"), "{text}");
|
||
assert!(at("why did that do nothing") < at("e2"), "{text}");
|
||
}
|
||
|
||
/// A comment must not be readable as something the game did.
|
||
///
|
||
/// The log is the RECORDER's vocabulary — what the scenario file will
|
||
/// say. A note styled like a log line would be a sentence the game
|
||
/// never produced, sitting in the account of what the game produced,
|
||
/// and `ADR-0014 D4` turns on that distinction being visible.
|
||
#[test]
|
||
fn a_comment_is_not_dressed_as_a_game_event() {
|
||
let lines = vec![line(&["e0"])];
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
Account {
|
||
lines: &lines,
|
||
notes: &[note(1, "select_action action=SOLVE")],
|
||
},
|
||
&[],
|
||
);
|
||
assert!(
|
||
html.contains("class=\"note\""),
|
||
"the note had no marking of its own"
|
||
);
|
||
let text = crate::text_of(&html);
|
||
// Attributed to the player, and to a position.
|
||
assert!(text.contains("you \u{2014} round 2, Select"), "{text}");
|
||
}
|
||
|
||
/// A post-game note is written when every command has been played, so
|
||
/// its `after` can exceed a log this page happens to be showing. It
|
||
/// must still appear — a dropped comment is the failure the whole
|
||
/// note channel exists to avoid.
|
||
#[test]
|
||
fn a_comment_past_the_end_of_the_log_is_still_shown() {
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
Account {
|
||
lines: &[],
|
||
notes: &[note(9, "so that is why it failed")],
|
||
},
|
||
&[],
|
||
);
|
||
assert!(
|
||
crate::text_of(&html).contains("so that is why it failed"),
|
||
"a comment was silently dropped"
|
||
);
|
||
}
|
||
|
||
fn page(log: &[LogLine]) -> String {
|
||
document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
Account::of(log),
|
||
&[],
|
||
)
|
||
}
|
||
|
||
/// **The case the pass was reported for.** A SOLVE that cannot be
|
||
/// fulfilled produces no events, and a log built only from events
|
||
/// would render nothing for it — reproducing the silence the
|
||
/// maintainer hit when the same move did nothing three rounds
|
||
/// running.
|
||
#[test]
|
||
fn a_command_that_produced_nothing_says_so() {
|
||
let html = page(&[line(&[])]);
|
||
assert!(
|
||
html.contains("no effect"),
|
||
"a command with no events rendered as if it had done something"
|
||
);
|
||
let text = crate::text_of(&html);
|
||
assert!(text.contains("select_action action=SOLVE problem=1"));
|
||
}
|
||
|
||
#[test]
|
||
fn effects_are_listed_and_an_empty_log_says_it_is_empty() {
|
||
let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])]));
|
||
assert!(text.contains("problem 1 claimed by P1"));
|
||
assert!(
|
||
!text.contains("no effect"),
|
||
"a command WITH effects was marked as having none"
|
||
);
|
||
assert!(crate::text_of(&page(&[])).contains("nothing has happened yet"));
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table.
|
||
///
|
||
/// The maintainer asked for the piles to be visible and for the discard
|
||
/// to show a shuffle when the draw runs out. **The reshuffle is real** —
|
||
/// `games/ground/src/lib.rs::draw_solution` implements the U4 default
|
||
/// (deterministic reshuffle of the discard; skip the draw if both are
|
||
/// empty), which ground-game confirmed on 2026-08-03. So this renders the
|
||
/// state in which the next draw triggers it, rather than inventing a rule.
|
||
#[cfg(test)]
|
||
mod piles {
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::view::GroundView;
|
||
|
||
use crate::doc::document;
|
||
|
||
fn view() -> GroundView {
|
||
crate::testfix::view(Some(PlayerId(0)))
|
||
}
|
||
|
||
fn html(v: &GroundView) -> String {
|
||
document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
|
||
}
|
||
|
||
/// The counts must come from the projection, never be recomputed.
|
||
#[test]
|
||
fn both_counts_are_the_views_own_numbers() {
|
||
let mut v = view();
|
||
v.solution_deck_len = 5;
|
||
let doc = crate::text_of(&html(&v));
|
||
assert!(
|
||
doc.contains("draw pile: 5 remaining"),
|
||
"the drawn deck count is not the view's: {doc}"
|
||
);
|
||
let n = v.solution_discard.len();
|
||
assert!(
|
||
doc.contains(&format!("discard pile: {n} remaining")),
|
||
"the drawn discard count is not the view's ({n}): {doc}"
|
||
);
|
||
}
|
||
|
||
/// An empty discard is an EMPTY pile, not a missing one. A absent slot
|
||
/// reads as "this game has no discard", which is a different claim.
|
||
#[test]
|
||
fn an_empty_pile_is_drawn_rather_than_omitted() {
|
||
let mut v = view();
|
||
v.solution_discard.clear();
|
||
let doc = crate::text_of(&html(&v));
|
||
assert!(
|
||
doc.contains("discard pile: 0 remaining"),
|
||
"an empty discard vanished instead of rendering as empty: {doc}"
|
||
);
|
||
}
|
||
|
||
/// The U4 state: deck empty, discard holding cards. The next draw
|
||
/// reshuffles, and the table should say so.
|
||
#[test]
|
||
fn an_exhausted_deck_says_the_discard_shuffles_back_in() {
|
||
let mut v = view();
|
||
v.solution_deck_len = 0;
|
||
assert!(!v.solution_discard.is_empty(), "fixture needs a discard");
|
||
let doc = crate::text_of(&html(&v));
|
||
assert!(
|
||
doc.contains("shuffles in on next draw"),
|
||
"an exhausted deck did not announce the U4 reshuffle: {doc}"
|
||
);
|
||
|
||
// And the negative half: with cards left, no shuffle is promised.
|
||
let mut full = view();
|
||
full.solution_deck_len = 12;
|
||
assert!(
|
||
!crate::text_of(&html(&full)).contains("shuffles in on next draw"),
|
||
"a stocked deck claimed a reshuffle was coming"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0028 T03 — one overhead table, not three diagrams.
|
||
#[cfg(test)]
|
||
mod overhead_table {
|
||
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::GroundState;
|
||
|
||
/// Seat circle centres, read out of the emitted SVG.
|
||
///
|
||
/// Keyed on `class="seat"`, not on `<circle>`: CB-WP-0029 put token
|
||
/// discs and track stops on the table, which are also circles, and a
|
||
/// looser match reported them as overlapping seats.
|
||
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
|
||
html.match_indices("<circle class=\"seat\" cx=\"")
|
||
.filter_map(|(i, _)| {
|
||
let rest = &html[i + 25..];
|
||
let (x, rest) = rest.split_once("\" cy=\"")?;
|
||
let (y, _) = rest.split_once('"')?;
|
||
Some((x.parse().ok()?, y.parse().ok()?))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn view_of(players: u8) -> games_ground::view::GroundView {
|
||
GroundState::setup(
|
||
&Setup {
|
||
players,
|
||
preset: format!("standard-{players}p"),
|
||
patch: Default::default(),
|
||
},
|
||
7,
|
||
)
|
||
.expect("preset")
|
||
.project(Viewer::Player(PlayerId(0)))
|
||
}
|
||
|
||
/// **Every seat count lays out, and no two seats land on each other.**
|
||
/// Asserted per count rather than eyeballed at three, which is the
|
||
/// only count anyone ever looks at.
|
||
#[test]
|
||
fn two_through_six_seats_all_lay_out_without_overlap() {
|
||
for players in 2..=6u8 {
|
||
let v = view_of(players);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
|
||
let seats = seat_centres(&html);
|
||
assert_eq!(
|
||
seats.len(),
|
||
players as usize,
|
||
"{players}p: expected one circle per seat, got {seats:?}"
|
||
);
|
||
for (i, a) in seats.iter().enumerate() {
|
||
for b in &seats[i + 1..] {
|
||
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
||
assert!(
|
||
d > 64.0,
|
||
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
||
they overlap"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A table where you cannot find yourself is worse than a list.
|
||
#[test]
|
||
fn the_viewers_own_seat_is_marked() {
|
||
let v = view_of(4);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
assert!(
|
||
crate::text_of(&html).contains("P1 (you)"),
|
||
"the viewer's seat is not identifiable"
|
||
);
|
||
assert!(
|
||
html.contains("stroke=\"#9cf\" stroke-width=\"3\""),
|
||
"the viewer's seat should also be visually distinct, not only labelled"
|
||
);
|
||
// A spectator has no seat to mark, and must not claim one.
|
||
let spec = GroundState::setup(
|
||
&Setup {
|
||
players: 4,
|
||
preset: "standard-4p".into(),
|
||
patch: Default::default(),
|
||
},
|
||
7,
|
||
)
|
||
.expect("preset")
|
||
.project(Viewer::Spectator);
|
||
assert!(
|
||
!crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"),
|
||
"a spectator was given a seat"
|
||
);
|
||
}
|
||
|
||
/// **Observation 1: the seats were ON the table, not around it.**
|
||
/// Every seat circle must lie outside the ellipse.
|
||
#[test]
|
||
fn the_seats_sit_outside_the_table_not_on_it() {
|
||
for players in 2..=6u8 {
|
||
let v = view_of(players);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64);
|
||
for (x, y) in seat_centres(&html) {
|
||
// Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the
|
||
// seat's own radius kept clear of the rim.
|
||
let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2);
|
||
assert!(
|
||
d > 1.0,
|
||
"{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Observations 3 and 4: the table you can see is the table you drop
|
||
/// on, and the game must be playable.** A `table` drop target that is
|
||
/// not the drawn table is why a player could not find where to drop.
|
||
#[test]
|
||
fn the_drawn_table_is_the_drop_target_and_there_is_only_one() {
|
||
let v = view_of(3);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
assert_eq!(
|
||
html.matches("data-drop=\"table\"").count(),
|
||
1,
|
||
"two elements claim to be the table; a player cannot tell which to use"
|
||
);
|
||
let table = html
|
||
.split("aria-label=\"the table, seen from above\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("</svg>").next())
|
||
.expect("one table svg");
|
||
assert!(
|
||
table.contains("data-drop=\"table\""),
|
||
"the drop target is not on the drawn table"
|
||
);
|
||
assert!(
|
||
table.contains("<ellipse"),
|
||
"the drop target should be the surface itself"
|
||
);
|
||
}
|
||
|
||
/// **The reason the game became unplayable.** The table was 620px
|
||
/// tall, so the action cards sat a screen below the Problems — and you
|
||
/// cannot drag between two things never on screen together.
|
||
///
|
||
/// The limit is **derived, not guessed**. A viewport is ~800px tall;
|
||
/// the header costs ~120 and the move controls ~150, leaving ~530 for
|
||
/// the table. The version that broke dragging declared 620. 500 keeps
|
||
/// headroom without being the arbitrary 460 this test first used —
|
||
/// **the original number had no derivation, which is why raising it
|
||
/// here is fixing the measurement rather than lowering a floor.**
|
||
#[test]
|
||
fn the_table_is_short_enough_to_drag_from() {
|
||
let v = view_of(6);
|
||
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
||
let vb = html
|
||
.split("viewBox=\"0 0 760 ")
|
||
.nth(1)
|
||
.and_then(|s| s.split('"').next())
|
||
.expect("the table declares a viewBox");
|
||
let h: f64 = vb.parse().expect("a number");
|
||
assert!(
|
||
h <= 500.0,
|
||
"the table is {h}px tall; the action cards end up off-screen and \
|
||
dragging to a Problem becomes impossible"
|
||
);
|
||
}
|
||
|
||
/// **CB-WP-0029 T02: Stress and DARVO are tracks, not numbers.**
|
||
///
|
||
/// The track is the point: DARVO arms at Stress 5 (GR-R08), so a
|
||
/// marker approaching the end of a 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,
|
||
crate::doc::Account::of(&[]),
|
||
&[hostile.to_string()],
|
||
);
|
||
assert!(
|
||
!html.contains("<script>alert(1)</script>"),
|
||
"a note's markup reached the document unescaped"
|
||
);
|
||
assert!(
|
||
html.contains("<script>"),
|
||
"the note should still be visible, escaped"
|
||
);
|
||
assert!(
|
||
text_of(&html).contains("alert(1)"),
|
||
"escaping must not eat the player's words — they still read what they wrote"
|
||
);
|
||
}
|
||
|
||
/// The comment box is always offered, and the page works without it
|
||
/// being used. A control that appears only sometimes trains a player
|
||
/// not to look for it.
|
||
#[test]
|
||
fn the_comment_box_is_always_there() {
|
||
let html = document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
);
|
||
assert!(
|
||
html.contains("action=\"/note?t=x\""),
|
||
"the form must carry the session token"
|
||
);
|
||
assert!(
|
||
html.contains("method=\"post\""),
|
||
"a plain form, so it works with the script disabled"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
|
||
#[cfg(test)]
|
||
mod two_columns {
|
||
use cb_kernel::PlayerId;
|
||
|
||
use crate::doc::document_with_log;
|
||
|
||
fn page(meta: &[String]) -> String {
|
||
document_with_log(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[],
|
||
crate::TEST_ENDPOINTS,
|
||
Some(PlayerId(0)),
|
||
false,
|
||
crate::doc::Account::of(&[]),
|
||
meta,
|
||
)
|
||
}
|
||
|
||
/// The columns exist, and the game is in the left one.
|
||
#[test]
|
||
fn the_table_is_in_the_game_column_and_the_log_is_not() {
|
||
let html = page(&[]);
|
||
let game = html
|
||
.split("class=\"cb-game\"")
|
||
.nth(1)
|
||
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
||
.expect("a game column followed by a meta column");
|
||
assert!(
|
||
game.contains("<h2>the table</h2>"),
|
||
"the table must be in the game column"
|
||
);
|
||
assert!(
|
||
!game.contains("<h2>log</h2>"),
|
||
"the log belongs in the meta column — it is commentary on the game, not part of it"
|
||
);
|
||
}
|
||
|
||
/// **A player who writes nothing must not be worse off.** An empty
|
||
/// meta panel renders as nothing, not as an empty heading.
|
||
#[test]
|
||
fn an_empty_meta_panel_adds_no_furniture() {
|
||
assert!(
|
||
!page(&[]).contains("this session"),
|
||
"an empty session panel drew a heading with nothing under it"
|
||
);
|
||
assert!(
|
||
page(&["2 games this session".into()]).contains("this session"),
|
||
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
|
||
);
|
||
}
|
||
|
||
/// The single-column fallback is deliberate, not incidental. Asserted
|
||
/// on the stylesheet because there is no browser here to resize —
|
||
/// which is a weaker test than laying it out, and is said so rather
|
||
/// than dressed up.
|
||
#[test]
|
||
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
|
||
let html = page(&[]);
|
||
assert!(
|
||
html.contains("@media (max-width:64rem)"),
|
||
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
|
||
);
|
||
assert!(
|
||
html.contains("minmax(0,1fr)"),
|
||
"grid children default to min-content width; without minmax(0,…) the SVG table \
|
||
refuses to shrink and pushes the meta column off-screen"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
|
||
///
|
||
/// The maintainer could follow the other players only by reading the log.
|
||
/// The data was already projected and already rendered — as sentences.
|
||
/// This adds the picture without touching the hiding rule, which is the
|
||
/// only part that could do harm.
|
||
#[cfg(test)]
|
||
mod played_cards {
|
||
use cb_kernel::PlayerId;
|
||
use games_ground::view::{GroundView, SelectionView};
|
||
use games_ground::{Action, Selection};
|
||
|
||
use crate::doc::document;
|
||
|
||
fn html(v: &GroundView) -> String {
|
||
document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
|
||
}
|
||
|
||
/// A seat's revealed play is drawn, not only written.
|
||
#[test]
|
||
fn a_revealed_play_is_drawn_as_a_card() {
|
||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||
v.selections.insert(
|
||
PlayerId(1),
|
||
SelectionView::Shown(Selection {
|
||
action: Action::Solve,
|
||
target: None,
|
||
problem: Some(3),
|
||
}),
|
||
);
|
||
let doc = html(&v);
|
||
assert!(
|
||
doc.contains("played Solve"),
|
||
"the revealed play was not drawn as a card"
|
||
);
|
||
assert!(
|
||
crate::text_of(&doc).contains("selected Solve"),
|
||
"the sentence must survive alongside the picture — the log is the record"
|
||
);
|
||
}
|
||
|
||
/// **The leak test.** Nothing in the emitted document may vary with
|
||
/// another seat's hidden selection.
|
||
///
|
||
/// The shape is `view.rs`'s own
|
||
/// `a_seat_never_sees_another_seats_face_down_selection`: assert the
|
||
/// absence, then assert the same view AFTER reveal shows it —
|
||
/// otherwise the first assertion passes for a renderer that draws
|
||
/// nothing at all.
|
||
#[test]
|
||
fn a_hidden_play_renders_identically_whatever_it_is() {
|
||
let render_hidden = |action, problem| {
|
||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||
// The seat HAS chosen; the viewer may not see what.
|
||
v.selections.insert(PlayerId(1), SelectionView::Hidden);
|
||
// A different real choice underneath, which must not reach us.
|
||
v.selections.insert(
|
||
PlayerId(2),
|
||
SelectionView::Shown(Selection {
|
||
action,
|
||
target: None,
|
||
problem,
|
||
}),
|
||
);
|
||
html(&v)
|
||
};
|
||
|
||
// Two different hidden situations must produce the same markup for
|
||
// the hidden seat. Compare the card backs directly.
|
||
let a = render_hidden(Action::Solve, Some(1));
|
||
let b = render_hidden(Action::Attack, Some(9));
|
||
let back = |h: &str| {
|
||
let i = h
|
||
.find("<title>face down</title>")
|
||
.expect("a face-down card");
|
||
h[i.saturating_sub(200)..i + 200].to_string()
|
||
};
|
||
assert_eq!(
|
||
back(&a),
|
||
back(&b),
|
||
"the face-down card differed between two games — it varies with something"
|
||
);
|
||
|
||
// The other half: revealed, the same renderer DOES show it.
|
||
let mut shown = crate::testfix::view(Some(PlayerId(0)));
|
||
shown.selections.insert(
|
||
PlayerId(1),
|
||
SelectionView::Shown(Selection {
|
||
action: Action::Attack,
|
||
target: Some(PlayerId(2)),
|
||
problem: None,
|
||
}),
|
||
);
|
||
assert!(
|
||
html(&shown).contains("played Attack"),
|
||
"the assertion above would pass for a renderer that draws nothing"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CB-WP-0024 T01 — the ending page's one control.
|
||
///
|
||
/// The maintainer reported it as *"the button says 'I need to read this'
|
||
/// — why? the UI is not closing."* Two defects wearing one button: the
|
||
/// label described a reading while the control stopped a server, and
|
||
/// acknowledging it changed nothing on screen, leaving a live-looking
|
||
/// table and a `play again` pointing at a closed port.
|
||
#[cfg(test)]
|
||
mod ending_page {
|
||
use cb_kernel::PlayerId;
|
||
|
||
use crate::{doc, jsrun};
|
||
|
||
fn page() -> String {
|
||
doc::ending(
|
||
None,
|
||
"the game ended",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
)
|
||
}
|
||
|
||
/// The label must say what the control DOES. Asserted on the rendered
|
||
/// text so reverting the wording turns this red — a comment would not.
|
||
#[test]
|
||
fn the_control_is_labelled_by_its_effect_not_by_a_reading() {
|
||
let text = crate::text_of(&page());
|
||
assert!(
|
||
text.contains("end session") && text.contains("stops the game server"),
|
||
"the ending control must name its effect: {text}"
|
||
);
|
||
assert!(
|
||
!text.contains("I have read this"),
|
||
"the label claimed the player had read something; it stops a server"
|
||
);
|
||
}
|
||
|
||
/// The defect the maintainer actually saw. After the server says the
|
||
/// session is closed, `play again` must stop being offered — it now
|
||
/// points at a port nobody is listening on.
|
||
#[test]
|
||
fn acknowledging_the_end_stops_the_page_offering_anything() {
|
||
let html = page();
|
||
let before = doc::drop_keys(&html);
|
||
assert!(
|
||
before.contains("again") && before.contains("done"),
|
||
"fixture must start with both controls: {before:?}"
|
||
);
|
||
|
||
let (live, status) =
|
||
jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended")
|
||
.expect("run the page");
|
||
|
||
assert!(
|
||
!live.contains(&"again".to_string()),
|
||
"`play again` survived the session ending and would post to a closed port: {live:?}"
|
||
);
|
||
assert!(
|
||
live.is_empty(),
|
||
"every control must be sealed once the server stops, not only `again`: {live:?}"
|
||
);
|
||
assert!(
|
||
status.contains("session has ended"),
|
||
"the page must say what happened: {status:?}"
|
||
);
|
||
// The WHOLE page goes inert, not only the controls. A greyed
|
||
// button beside a full-colour table reads as a live game with one
|
||
// broken control.
|
||
assert!(
|
||
status.contains("sealed-page"),
|
||
"the page itself was not marked ended: {status:?}"
|
||
);
|
||
}
|
||
|
||
/// The controls sit **below** the log: you read what happened, then
|
||
/// decide what to do next.
|
||
#[test]
|
||
fn the_controls_come_after_the_log() {
|
||
let html = page();
|
||
let log = html.find("<h2>log</h2>").expect("a log section");
|
||
let again = html
|
||
.find("data-drop=\"again\"")
|
||
.expect("a play-again control");
|
||
assert!(
|
||
again > log,
|
||
"the controls are above the log; the reader decides before reading"
|
||
);
|
||
}
|
||
|
||
/// **A thing you click must not look like a thing you drag.**
|
||
///
|
||
/// Reported two ways at once: *"the button shows a hand to pick up
|
||
/// that it probably shouldn't"* and *"I can't start another game"*.
|
||
/// They are one defect — a grab cursor invites a drag, and a drag
|
||
/// released over nothing posts nothing, so the button looks dead.
|
||
#[test]
|
||
fn click_targets_do_not_wear_the_drag_affordance() {
|
||
let pages = [
|
||
doc::ending(
|
||
None,
|
||
"m",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
),
|
||
doc::document(
|
||
&crate::testfix::view(Some(PlayerId(0))),
|
||
&[games_ground::GroundCommand::SelectAction {
|
||
action: games_ground::Action::Investigate,
|
||
target: None,
|
||
problem: Some(2),
|
||
}],
|
||
"/command?t=x",
|
||
Some(PlayerId(0)),
|
||
true,
|
||
),
|
||
];
|
||
for html in &pages {
|
||
for key in ["again", "done", "pass"] {
|
||
let Some(i) = html.find(&format!("data-drop=\"{key}\"")) else {
|
||
continue;
|
||
};
|
||
let tag = &html[html[..i].rfind('<').expect("an opening tag")..i];
|
||
assert!(
|
||
!tag.contains("pick"),
|
||
"`{key}` is a click target wearing `.pick`, which is cursor:grab — \
|
||
it invites a drag, and a drag onto nothing posts nothing"
|
||
);
|
||
assert!(
|
||
tag.contains("tap"),
|
||
"`{key}` must still look pressable: {tag}"
|
||
);
|
||
}
|
||
}
|
||
// The inverse, or this passes for a page with no affordances at
|
||
// all: a real draggable still carries `.pick`.
|
||
assert!(
|
||
pages[1].contains("class=\"card act pick\""),
|
||
"action cards must still be draggable"
|
||
);
|
||
}
|
||
|
||
/// **CB-WP-0028 T06, asserted both ways.** A test that only checked
|
||
/// the win case would pass for a page that always says "solved".
|
||
#[test]
|
||
fn a_won_game_is_solved_and_a_lost_one_is_over() {
|
||
let mut won = crate::testfix::view(None);
|
||
if let Some(o) = won.outcome.as_mut() {
|
||
o.group_success = true;
|
||
}
|
||
let mut lost = crate::testfix::view(None);
|
||
if let Some(o) = lost.outcome.as_mut() {
|
||
o.group_success = false;
|
||
}
|
||
let head = |v: &games_ground::view::GroundView| {
|
||
crate::text_of(&doc::ending(
|
||
Some(v),
|
||
"m",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
))
|
||
};
|
||
assert!(
|
||
head(&won).contains("game solved"),
|
||
"a won game said otherwise"
|
||
);
|
||
assert!(
|
||
!head(&won).contains("game over"),
|
||
"\"game over\" is arcade vocabulary for a failure state"
|
||
);
|
||
assert!(
|
||
head(&lost).contains("game over"),
|
||
"a lost game said otherwise"
|
||
);
|
||
assert!(!head(&lost).contains("game solved"));
|
||
|
||
// And a game with no outcome claims neither.
|
||
let none = crate::text_of(&doc::ending(
|
||
None,
|
||
"P1 ran out of input",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
));
|
||
assert!(!none.contains("game solved") && !none.contains("game over"));
|
||
}
|
||
|
||
/// **CB-WP-0028 T07.** Every ranking names its source, and the
|
||
/// co-operative mode is not ranked at all — the game says its
|
||
/// tiebreak is "Not applicable", and ranking it anyway would invent
|
||
/// scoring the rules do not have.
|
||
#[test]
|
||
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
|
||
let mut v = crate::testfix::view(None);
|
||
v.mode = games_ground::ScoringMode::SharedGround;
|
||
let text = crate::text_of(&doc::ending(
|
||
Some(&v),
|
||
"m",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
));
|
||
|
||
assert!(
|
||
text.contains("problems solved"),
|
||
"the countable fact is missing"
|
||
);
|
||
assert!(
|
||
text.contains("not ranked"),
|
||
"SHARED GROUND must not be ranked: {text}"
|
||
);
|
||
assert!(
|
||
!text.contains("clay-borg's reading"),
|
||
"a reading must not be offered where the game defines no ranking"
|
||
);
|
||
}
|
||
|
||
/// And the inverse, or the test above passes for a page that never
|
||
/// ranks anything. A ranked mode cites the GAME's tiebreak and marks
|
||
/// anything derived as ours.
|
||
#[test]
|
||
fn a_ranked_mode_cites_the_games_own_tiebreak() {
|
||
let mut v = crate::testfix::view(None);
|
||
v.mode = games_ground::ScoringMode::BondedCoalitions;
|
||
let text = crate::text_of(&doc::ending(
|
||
Some(&v),
|
||
"m",
|
||
"/command?t=x",
|
||
None,
|
||
"/alive?t=x",
|
||
crate::doc::Account::of(&[]),
|
||
&[],
|
||
));
|
||
|
||
assert!(
|
||
text.contains("Lower combined Stress"),
|
||
"the tiebreak shown must be the edition's own words: {text}"
|
||
);
|
||
assert!(
|
||
text.contains("clay-borg'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;
|