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
|
|
|
//! `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;
|
CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind
ADR-0009: embed quick-js; node is refused. Measured marginal cost against
the dev-toolchain graph, under the positive control:
boa_engine 896,410
rquickjs 69,985
quick-js 11,434
node 0 <- and that zero is the problem
ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97,
which has no node, so the test would make our build fetch a JS runtime of
tens of millions of unaudited lines while scoring zero on the only
instrument that governs dependencies. A browser is exempt because a
developer has one regardless of us; a CI-installed runtime is not.
The loop is now closed: the real server serves the real page, QuickJS
runs that page's own scripts, the gesture goes over a real socket, and
the seat's Choice comes back. Before this, every link was tested and the
chain was not — a page whose JavaScript sent something else entirely
would have passed everything.
Three controls, each red for its stated reason: the JS posting a command
name instead of ids, the gesture not being delivered (EXPECT-VACUOUS),
and the token stripped from the endpoint.
A wrong assertion worth keeping: the first draft required the body not to
contain "attack". It legitimately does — action-attack is the id of an
element a finger landed on. An element may name an action; that is not
the page deciding. The real test is the shape: exactly two fields, down
and up, carrying two ids and nothing derived from them.
AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's
headroom; after landing AM-4b did not move at all. It measures
games-ground --edges normal — one package, no dev edges. Measured, the
workspace including dev edges is 725,258 lines against AM-4b's 317,021:
408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap,
ciborium, quick-js). The decision stands on the acquisition rule; the
affordability argument is withdrawn. Third defect in the AM-4 family.
Also fixed structurally rather than by raising a limit: `make status` had
grown past its 40-line readability gate as workplans accumulated. Closed
workplans now collapse to one line, so the report is fixed-size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
|
|
|
#[cfg(any(test, feature = "js-harness"))]
|
|
|
|
|
pub mod jsrun;
|
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
|
|
|
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"),
|
CB-WP-0020: the table you can read
Six of seven perceptual defects fixed; item 1 already passed.
T01, at the maintainer's instruction: a legal target restyles its
EXISTING border rather than drawing a new box. outline + outline-offset
drew a second rectangle, which an SVG viewport clips (the missing top and
left edges) and which made a seat's highlight card-sized. A border
already in the layout cannot move the layout.
T02: the ghost was a textContent copy of the card, which is why the line
break collapsed and it read as a second card, and why showing the
explanation destroyed the label. It is now a pill, the explanation is
appended beside the label, and the left-behind element is dimmed and
dashed. The stub grew innerHTML so a test can assert BOTH are present --
it could previously only see that something was displayed.
T03: NOT reproduced and recorded as not reproduced. The likeliest cause
is which element the browser reports -- for touch and pen the pointer is
captured to the pointerdown target, making every drop look like a
drop-on-itself, which is the other half of the report. elementFromPoint
is correct under both explanations. Separately the refusal was written in
element ids on the one surface a player reads when something goes wrong;
it now speaks the game's words and a test forbids id leakage.
T04: seat selections rendered as Debug. The coverage gate then failed my
first fix for dropping a field when target and problem were both set --
the aggregate does not produce that shape and the gate was right not to
care.
T05: the headline reads from group_success. 'Play again' is real, and its
first version was useless: run_game bound a fresh listener per game, so a
second game moved to a new port and left the tab pointing at a dead one.
One listener per session now, and the test asserts the second game is a
DIFFERENT deal.
Chaos d8=8 fired the first override at the new rate and drew S, changing
nothing -- one half of window 2's retirement condition.
CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight,
and the first under 20%.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:20:38 +02:00
|
|
|
// 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-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
|
|
|
("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: the drop target that was never there
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.
Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.
Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.
And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.
A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.
make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
|
|
|
/// 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)");
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0017: legible interaction, and the chaos window's verdict
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>
2026-08-02 22:42:45 +02:00
|
|
|
/// **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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0016: the drop target that was never there
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.
Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.
Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.
And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.
A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.
make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
|
|
|
/// **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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 02:15:30 +02:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod gamelog {
|
|
|
|
|
//! CB-WP-0018 T02.
|
|
|
|
|
|
|
|
|
|
use crate::doc::{document_with_log, 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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn page(log: &[LogLine]) -> String {
|
|
|
|
|
document_with_log(
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
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-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
|
|
|
#[cfg(test)]
|
|
|
|
|
mod testfix;
|