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>
333 lines
12 KiB
Rust
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;
|