Some checks failed
ci / check (push) Failing after 4s
Provenance (tier M, structural S, chaos d4=4 -> OVERRIDE drawn M): the maintainer could drag after CB-WP-0016 but could not tell what was pickable, held, or droppable. Underneath that, the page was WRONG about which moves exist: 9 legal commands rendered as 5 cards each claiming all three target kinds, from a const string in the emitter. Investigate is legal on problems 2 and 3 but not 1; Solve on 1 but not 2 or 3. The live page now says 'Solve onto problem 1'. ADR-0010 restates control 5, which this work would otherwise have outgrown in silence: every game fact the page acts on must arrive from Rust as data; the script may read, match and render it, never compute, infer, filter or default one. The survey's real finding is that the permitted and forbidden designs are indistinguishable from outside, so the vocabulary grep is demoted to a cheap first line and two behavioural properties become the controls -- the highlighted set EQUALS the set Rust emitted, and anything the page marks legal must resolve. Both mutation-proven; the derive-legality mutation produces a plausible highlight (seat-0,1,2 where only seat-1 is legal) and is caught. Visible now: .pick resting shadow, .held on the grabbed element, .dropok on every legal target including BOTH drawings of a seat, and a ghost following the pointer. Nothing perceptual is verified and ADR-0010 D5 says so. The DOM stub now models classList/querySelectorAll/createElement and builds its node set from the real emitted page. Trap recorded: QuickJS fixes its stack limit at Context creation relative to that frame, so a helper returning a Context makes every later eval report 'SyntaxError: stack overflow'. CHAOS WINDOW CLOSED, 12 declarations, 2 overrides, one each way. Both changed the outcome, so the retirement condition is not met. Verdict: keep, and recommend d4 -> d8 with a second window of 12 -- that is a change to the loop's own constraints and is owed to the next declaration as tier-M work, not made here. CB-EV-0014 corrected: it quoted CB-WP-0015 at $15.14/136 and called it the first settled figure quoted. Now $22.70/166. The number had been read during CB-WP-0015 itself, so there are two defects -- the boundary, and quoting from memory instead of re-running the instrument. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
577 lines
22 KiB
Rust
577 lines
22 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};
|
|
pub use input::{resolve, 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", "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"),
|
|
("selections.*.action", "action: Attack"),
|
|
("selections.*.target", "target: Some(PlayerId(1))"),
|
|
("selections.*.problem", "problem: Some(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,
|
|
&[],
|
|
"/command?t=x",
|
|
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 testfix;
|