//! `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; 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) { 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 = 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 = 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 = "

seen

\ "; 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("")) .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)")); } } #[cfg(test)] mod testfix;