CB-WP-0016: the drop target that was never there
Some checks failed
ci / check (push) Failing after 4s
Some checks failed
ci / check (push) Failing after 4s
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>
This commit is contained in:
parent
4df2d0a4ae
commit
bf72a1863a
9 changed files with 496 additions and 27 deletions
|
|
@ -331,5 +331,184 @@ mod coverage {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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)");
|
||||
}
|
||||
|
||||
/// **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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue