CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot carry a move. T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was easy is that PointerFact::parse already refuses any unrecognised field -- a comment could not reach the command path even by accident. So /command carries pointer facts, /note carries text, and Note has no code path to GroundCommand. Comments live in trials/<date>-<slug>.md, not in ScenarioFile: a scenario is executed, replayed and hashed, and prose in it is data the runner must ignore, which is how a format rots. The state hash binds; round and step are for reading. And the retention question, decided before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note reaches ground-game only by being promoted to a register finding, by a human, with the wording chosen then -- "the DARVO sequence is infuriating" is useful signal and a bad way to open a message to the game's designer. T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a grid child defaults to min-content width and without it the SVG table refuses to shrink and pushes the meta column off-screen, looking correct on the developer's monitor and broken everywhere else. Single-column fallback under 64rem. The running tally moved into the panel so it is visible WHILE PLAYING; it only appeared on the ending page before, and a score you see once the game is over informs nothing. T03. A plain <form method="post">, so the box works with the script disabled; the command channel needs JavaScript because a drag is not a form submission, a comment is one. 303 See Other so a reload does not re-post. esc()'s first hostile input: <script>alert(1)</script> renders escaped AND STILL READABLE -- escaping that eats the player's words is its own defect. Verified over real HTTP: note posted 303, hostile note stored as text, empty note refused 400, game did not advance. T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE I RAN IT: the first version called any note without a recording an orphan, so a live session reported every note as broken -- the recording is only written at game end. A metric that cries wolf is one nobody reads, which is the exact failure this pass exists to prevent. Now ok / pending / orphan, and only orphan is a target-0 number. The self-test exercises the REPORTING path, not just the parser, because design-baseline.py had a green self-test and an unexercised reporting path and that is where it rotted. And a latent Makefile defect surfaced: make trials did nothing, because trials is also a directory and Make saw an up-to-date file. design, difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass -- were ALL missing from .PHONY; only the one that collided revealed it. make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c6e322d5f
commit
4fb506fcd1
11 changed files with 1002 additions and 10 deletions
|
|
@ -48,7 +48,7 @@ pub mod jsrun;
|
|||
pub mod serve;
|
||||
|
||||
pub use doc::{document, text_of};
|
||||
pub use input::{resolve, PointerFact};
|
||||
pub use input::{resolve, Note, PointerFact};
|
||||
pub use serve::{Guard, Refusal, Request};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -599,6 +599,7 @@ mod gamelog {
|
|||
Some(PlayerId(0)),
|
||||
false,
|
||||
log,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -706,6 +707,191 @@ mod piles {
|
|||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
|
||||
#[cfg(test)]
|
||||
mod notes {
|
||||
use cb_kernel::PlayerId;
|
||||
|
||||
use crate::doc::{document_with_log, text_of};
|
||||
use crate::input::{resolve, Note, PointerFact};
|
||||
|
||||
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
|
||||
/// perfectly well-formed pointer fact must not become a move.
|
||||
///
|
||||
/// Structural, not vigilance: `Note::parse` returns a `Note`,
|
||||
/// `resolve` takes a `PointerFact`, and nothing converts between
|
||||
/// them. This asserts the behaviour anyway, because "the types don't
|
||||
/// connect" is a claim about code layout until something checks it.
|
||||
#[test]
|
||||
fn a_note_that_looks_like_a_move_is_not_one() {
|
||||
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
|
||||
let note = Note::parse(hostile).expect("it parses as a note");
|
||||
assert_eq!(
|
||||
note.text, "down=action-solve&up=problem-1",
|
||||
"the text is stored verbatim, uninterpreted"
|
||||
);
|
||||
|
||||
// And the command parser refuses the same body outright — the two
|
||||
// channels do not overlap even at the wire level.
|
||||
assert!(
|
||||
PointerFact::parse(hostile).is_err(),
|
||||
"the command channel accepted a note body"
|
||||
);
|
||||
// The reverse, so this is not vacuous: a real pointer fact IS a
|
||||
// command, and is NOT a note.
|
||||
assert!(PointerFact::parse("down=a&up=b").is_ok());
|
||||
assert!(
|
||||
Note::parse("down=a&up=b").is_err(),
|
||||
"the note channel accepted a pointer fact"
|
||||
);
|
||||
|
||||
// Nothing in the crate turns a Note into a command. `resolve`'s
|
||||
// signature is the proof; this pins it against a careless change.
|
||||
let legal: Vec<games_ground::GroundCommand> = vec![];
|
||||
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
|
||||
}
|
||||
|
||||
/// An empty note is refused, not stored — a blank row is noise in the
|
||||
/// register.
|
||||
#[test]
|
||||
fn an_empty_note_is_refused() {
|
||||
assert!(Note::parse("note=").is_err());
|
||||
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
|
||||
assert_eq!(
|
||||
Note::parse("note=%20hello%20").expect("real text").text,
|
||||
"hello",
|
||||
"surrounding whitespace is trimmed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Percent and `+` decoding, since the form posts urlencoded.
|
||||
#[test]
|
||||
fn the_players_words_survive_the_wire() {
|
||||
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
|
||||
assert_eq!(n.text, "why is SOLVE doing nothing?");
|
||||
}
|
||||
|
||||
/// **The first hostile input this renderer has handled.** Until now
|
||||
/// `esc()` escaped suit names.
|
||||
#[test]
|
||||
fn a_note_containing_markup_renders_as_text() {
|
||||
let hostile = "<script>alert(1)</script> & \"quoted\"";
|
||||
let html = document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
"/command?t=x",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
&[hostile.to_string()],
|
||||
);
|
||||
assert!(
|
||||
!html.contains("<script>alert(1)</script>"),
|
||||
"a note's markup reached the document unescaped"
|
||||
);
|
||||
assert!(
|
||||
html.contains("<script>"),
|
||||
"the note should still be visible, escaped"
|
||||
);
|
||||
assert!(
|
||||
text_of(&html).contains("alert(1)"),
|
||||
"escaping must not eat the player's words — they still read what they wrote"
|
||||
);
|
||||
}
|
||||
|
||||
/// The comment box is always offered, and the page works without it
|
||||
/// being used. A control that appears only sometimes trains a player
|
||||
/// not to look for it.
|
||||
#[test]
|
||||
fn the_comment_box_is_always_there() {
|
||||
let html = document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
"/command?t=x",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
assert!(html.contains("action=\"/note\""), "no comment box");
|
||||
assert!(
|
||||
html.contains("method=\"post\""),
|
||||
"a plain form, so it works with the script disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
|
||||
#[cfg(test)]
|
||||
mod two_columns {
|
||||
use cb_kernel::PlayerId;
|
||||
|
||||
use crate::doc::document_with_log;
|
||||
|
||||
fn page(meta: &[String]) -> String {
|
||||
document_with_log(
|
||||
&crate::testfix::view(Some(PlayerId(0))),
|
||||
&[],
|
||||
"/command?t=x",
|
||||
Some(PlayerId(0)),
|
||||
false,
|
||||
&[],
|
||||
meta,
|
||||
)
|
||||
}
|
||||
|
||||
/// The columns exist, and the game is in the left one.
|
||||
#[test]
|
||||
fn the_table_is_in_the_game_column_and_the_log_is_not() {
|
||||
let html = page(&[]);
|
||||
let game = html
|
||||
.split("class=\"cb-game\"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
||||
.expect("a game column followed by a meta column");
|
||||
assert!(
|
||||
game.contains("<h2>problems</h2>"),
|
||||
"the table must be in the game column"
|
||||
);
|
||||
assert!(
|
||||
!game.contains("<h2>log</h2>"),
|
||||
"the log belongs in the meta column — it is commentary on the game, not part of it"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A player who writes nothing must not be worse off.** An empty
|
||||
/// meta panel renders as nothing, not as an empty heading.
|
||||
#[test]
|
||||
fn an_empty_meta_panel_adds_no_furniture() {
|
||||
assert!(
|
||||
!page(&[]).contains("this session"),
|
||||
"an empty session panel drew a heading with nothing under it"
|
||||
);
|
||||
assert!(
|
||||
page(&["2 games this session".into()]).contains("this session"),
|
||||
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
|
||||
);
|
||||
}
|
||||
|
||||
/// The single-column fallback is deliberate, not incidental. Asserted
|
||||
/// on the stylesheet because there is no browser here to resize —
|
||||
/// which is a weaker test than laying it out, and is said so rather
|
||||
/// than dressed up.
|
||||
#[test]
|
||||
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
|
||||
let html = page(&[]);
|
||||
assert!(
|
||||
html.contains("@media (max-width:64rem)"),
|
||||
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
|
||||
);
|
||||
assert!(
|
||||
html.contains("minmax(0,1fr)"),
|
||||
"grid children default to min-content width; without minmax(0,…) the SVG table \
|
||||
refuses to shrink and pushes the meta column off-screen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
|
||||
///
|
||||
/// The maintainer could follow the other players only by reading the log.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue