clay-borg/crates/cb-render-html/src/lib.rs
tegwick 84d688688d CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.

  games-ground shipped:  23 third-party crates
  cb-render-html:        23 third-party crates
  new crates introduced:  0

Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.

What shipped:
  crates/cb-render-html  doc.rs (HTML/SVG emission, incl. the relationship
                         graph), input.rs (pointer facts -> commands),
                         serve.rs (Guard, Request, loopback bind)
  tools/cb-play          hotseat.rs + `--serve PORT`

Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.

The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):

  1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
  4   a token-less request is refused, in the unit AND over a real socket
  5   JS may not construct commands — the page reports pointer facts, Rust
      resolves them against the legal list the aggregate already offered,
      and a test asserts the emitted script contains no game vocabulary
  6   the coverage gate crosses the language boundary: it walks the
      serialized view for leaf paths and requires each token to appear in
      the PARSED emitted document, with a test that the parse really is a
      parse (script/style contents must not count as rendered)

The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.

Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00

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