fix(u3): seven defects two cold panels found in the declared seam
The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3
diff, between them found four real defects and three vacuous falsifiers. Both
snapshots predate the contract-review fixes, so two of their findings were
already closed; the rest are here.
Prototype pollution in the placement maps. A mark id and a question key are
both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in
each. Against a plain `{}` an anchor naming NO mark returned an inherited
function, passed the guard meant to reject it, and threw on .questions.length
-- aborting placement before the tail, so one typo in author markup cost the
page every ask. The `placed` set had the mirror bug: inherited
`got.constructor` read as already-placed and silently dropped a question.
Object.create(null), three times. Found independently by both panels.
A declaring page was not served as written. read_text() opens in
universal-newline mode, so a CRLF report came back LF, and errors="replace"
replaced every byte that was not valid UTF-8. That is this unit's headline
promise, broken by the read itself, and the test could not see it because its
fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the
decoded copy answers only "does it declare the seam?".
A submit anchor inside the author's own <form> lost ours -- the parser drops a
nested form element outright -- while the code still recorded the pick as
submitted, so no fallback was appended. Every control's form= pointed at
nothing and the button did nothing. It counts as submitted only if the form
survived.
A broken pick's diagnostic never rendered from a submit-only anchor: an errored
pick's submit block is empty, and mounting that then marking it placed made the
tail skip the "broken ask" box entirely. The anchor is left alone instead.
An author's own element could hijack the open-ask chip -- id="bk-ask-winner-
background" satisfies any prefix rule, hyphen boundary included. The chip now
searches only elements this script mounted, which is the identity the deleted
bk-ask-<id>-top anchor used to guarantee, and takes the earliest by
compareDocumentPosition.
No error boundary around fragment rendering. A .marks.json that is well-formed
JSON with a wrong-shaped answer hydrates with no error and then raises in the
macro; this endpoint renders every pick on every load of the report, so that
was the whole seam gone while hold_read called the file readable. Reproduced
before building for it. _safe_fragments gives it the per-mark leniency
_hydrate_safe already applies one layer down.
The gallery and marks pages still 500 on that same entry. Measured at 42ea67f
-- it predates this unit, they render the same macro with no guard, and the
gallery is named out of scope in the contract. Recorded, not quietly widened:
persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
Also corrected: several comments claimed a multi-question pick POSTs a 400
unless every question is answered. It does not -- an empty submission is
refused, a partial one is recorded on purpose. The real reason an unplaced
question must still be appended is that a question which never reaches the page
cannot be answered at all.
Vacuity pass rebuilt around the rule this session learned: the mutation comes
from the invariant's claim, never from the falsifier's example. 21 mutations,
21 caught, unmutated control green. Getting there took three rounds -- it
passed INV-3 with the contract's own mutation, then found its own fix's hole,
then flagged seven stale mutations and one genuinely vacuous fixture whose
sibling-mark arrangement made the right answer also the first answer.
444 tests. Deployed and verified: 23/23 booths 200, and all four live verbatim
reports served at exactly +46 bytes -- len(EMBED_SCRIPT_TAG) -- with the
authors' own wrappers and headings intact and no console errors.
This commit is contained in:
+104
-6
@@ -58,7 +58,8 @@ def test_embed_payload_carries_a_fragment_for_every_shape(client):
|
||||
c, data = client
|
||||
_multi(data / "b")
|
||||
body = c.get("/b/b/embed.json").json()
|
||||
assert body["booth"] == "b" and body["home"] == "/"
|
||||
assert body["booth"] == "b"
|
||||
assert "home" not in body, "a value nothing reads is a second copy waiting to drift"
|
||||
assert body["favicon"].startswith("data:image/svg+xml,")
|
||||
(m,) = body["marks"]
|
||||
assert m["id"] == "batch" and m["error"] is None
|
||||
@@ -80,11 +81,29 @@ def test_a_single_question_pick_has_one_question_with_a_null_key(client):
|
||||
assert "Which render wins?" in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_marks_are_ordered_created_then_id(client):
|
||||
def test_marks_are_ordered_by_creation_before_id(client):
|
||||
"""`created` LEADS. The fixture makes creation order and alphabetical order
|
||||
disagree, because a fixture where they agree cannot tell the stated rule
|
||||
from a plain id sort — which is what the first version of this test did, and
|
||||
what a panel caught by reading the fixture rather than the assertion."""
|
||||
c, data = client
|
||||
b = _pick(data / "b", "zebra")
|
||||
_pick(b, "alpha")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first
|
||||
"alpha": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||
for e in raw["marks"]:
|
||||
e["created"] = stamps[e["id"]]
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"]
|
||||
|
||||
|
||||
def test_the_id_is_only_the_tie_break(client):
|
||||
"""And with `created` equal, the id decides — so two marks written in the
|
||||
same second cannot swap between renders."""
|
||||
c, data = client
|
||||
b = _pick(data / "b", "zebra")
|
||||
_pick(b, "alpha")
|
||||
# force identical creation stamps: the id is the tie-break, not json order
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||
@@ -179,10 +198,16 @@ def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
|
||||
original = src.read_text()
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
c = TestClient(app)
|
||||
served = c.get(EMBED_SRC).text
|
||||
try:
|
||||
# Poisoned BEFORE the first request, not between two of them. The
|
||||
# earlier shape passed for a route that read the file lazily and cached
|
||||
# on first use — which is not "read once at startup", and is exactly the
|
||||
# staleness this invariant exists to forbid.
|
||||
src.write_text("/* POISONED */\n")
|
||||
assert c.get(EMBED_SRC).text == served, "embed.js is being re-read per request"
|
||||
first = c.get(EMBED_SRC).text
|
||||
assert "POISONED" not in first, "embed.js is read at request time, not at startup"
|
||||
assert first == original
|
||||
assert c.get(EMBED_SRC).text == first
|
||||
finally:
|
||||
src.write_text(original)
|
||||
|
||||
@@ -208,8 +233,13 @@ def test_undeclared_page_gains_only_the_tag(client):
|
||||
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
|
||||
(b / "index.html").write_text(src)
|
||||
out = c.get("/b/b/").text
|
||||
# The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also
|
||||
# holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this
|
||||
# test exists to catch. A panel found it by reading the assertion, not the
|
||||
# code.
|
||||
assert out == src + '<script src="/_booth/embed.js" defer></script>'
|
||||
assert out == src + EMBED_SCRIPT_TAG
|
||||
assert out.startswith(src)
|
||||
assert len(out) > len(src)
|
||||
assert out.lower().lstrip().startswith("<!doctype")
|
||||
assert out.index("charset") < 1024
|
||||
|
||||
@@ -223,6 +253,74 @@ def test_a_booth_with_no_marks_still_gets_the_seam(client):
|
||||
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
|
||||
|
||||
|
||||
def test_a_declaring_page_is_served_BYTE_for_byte(client):
|
||||
"""INV-1, at the level the promise is actually made.
|
||||
|
||||
The first version read the file with `read_text()`, which opens in
|
||||
universal-newline mode: a report written with CRLF came back with LF, and
|
||||
`errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page
|
||||
was NOT served as its author wrote it — the headline promise — and the
|
||||
original test could not see it, because its fixture was LF-only ASCII.
|
||||
Found by a cross-frontier bug-hunt panel.
|
||||
"""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = (b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n'
|
||||
b'<script src="/_booth/embed.js" defer></script>\r\n</body>')
|
||||
(b / "index.html").write_bytes(src)
|
||||
r = c.get("/b/b/")
|
||||
assert r.content == src, "the operator's document was edited on the way out"
|
||||
assert b"\r\n" in r.content and b"\xff" in r.content
|
||||
|
||||
|
||||
def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client):
|
||||
"""INV-2, same level: the source is a BYTE-exact prefix of the response."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n</body>'
|
||||
(b / "index.html").write_bytes(src)
|
||||
r = c.get("/b/b/")
|
||||
assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||
assert r.content.startswith(src)
|
||||
|
||||
|
||||
def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||
"""`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the
|
||||
JSON is well formed, the SHAPE is not — and the template then asks a list
|
||||
for `.get`. This endpoint renders every pick on every load of the operator's
|
||||
report, so an unguarded raise here is the whole seam gone while `hold_read`
|
||||
calls the file perfectly readable. Verified reachable, not assumed."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||
_pick(b, "healthy")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "batch":
|
||||
e["answer"] = {"answers": [], "notes": ""}
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
assert "broken ask" in by["batch"]["whole"]
|
||||
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||
assert by["healthy"]["error"] is None
|
||||
assert "Which render wins?" in by["healthy"]["whole"]
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
|
||||
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before this unit. They render
|
||||
# the same macro without this guard. Out of scope here (the gallery is named
|
||||
# out of scope in the contract) and recorded rather than quietly widened:
|
||||
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
|
||||
|
||||
def test_no_regex_touches_author_html():
|
||||
"""INV-3, and the version that actually falsifies it.
|
||||
|
||||
|
||||
+208
-24
@@ -194,7 +194,8 @@ def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
|
||||
|
||||
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
||||
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
|
||||
a 400 the operator meets only after filling the form in."""
|
||||
unanswerable: a question that never reaches the page cannot be picked, and
|
||||
a submission with nothing picked at all is refused outright."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b",
|
||||
@@ -202,7 +203,10 @@ def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
||||
# attached, not visible: the shared <form> is deliberately empty and so has
|
||||
# no box — the controls that bind to it are what the operator sees.
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2 # never dropped
|
||||
# BOTH halves. Asserting only the appended r2 let a mutation that silently
|
||||
# swallowed the anchored r1 — while still recording it as placed — pass.
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
|
||||
page.close()
|
||||
|
||||
@@ -231,14 +235,19 @@ def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
|
||||
|
||||
|
||||
def test_the_tail_follows_payload_order(browser, live):
|
||||
"""INV-6. Two picks whose creation order and id order disagree: the page must
|
||||
render them `(created, id)`, the order every other surface reads."""
|
||||
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
|
||||
`winner` is created first, `batch` second, so payload order is
|
||||
winner-then-batch while an id sort would give the reverse. The first version
|
||||
of this fixture made the two identical, so sorting the tail alphabetically
|
||||
in JavaScript passed it — caught by a panel reading the fixture."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
_multi(b)
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
|
||||
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||
for e in raw["marks"]:
|
||||
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||
e["created"] = stamps[e["id"]]
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
@@ -246,9 +255,9 @@ def test_the_tail_follows_payload_order(browser, live):
|
||||
batch = [i for i, v in enumerate(ids) if "batch" in v]
|
||||
winner = [i for i, v in enumerate(ids) if "winner" in v]
|
||||
assert batch and winner, ids
|
||||
# identical `created`, so the id is the tie-break: batch before winner,
|
||||
# every fragment of one ahead of every fragment of the other.
|
||||
assert max(batch) < min(winner), ids
|
||||
# winner (created first) ahead of batch (created second) — the OPPOSITE of
|
||||
# alphabetical, so an id sort cannot pass this.
|
||||
assert max(winner) < min(batch), ids
|
||||
page.close()
|
||||
|
||||
|
||||
@@ -262,7 +271,7 @@ def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
|
||||
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
|
||||
a live document in visual order, which means a control can land before the
|
||||
form it points at. If form-owner resolution does not survive that, the
|
||||
operator fills the whole thing in and gets a 400.
|
||||
operator fills the whole thing in and the button saves nothing.
|
||||
|
||||
It was true by construction before U3 (static HTML, resolved at parse). It
|
||||
is true by measurement now. That is the trade this test pays for.
|
||||
@@ -292,31 +301,170 @@ def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
|
||||
chip = page.locator(".booth-nav-asks")
|
||||
assert chip.inner_text() == "? 1 open ask"
|
||||
target = chip.get_attribute("href")
|
||||
assert target.startswith("#bk-ask-batch")
|
||||
assert page.locator(target).count() == 1
|
||||
# the EARLIEST match in document order, not merely a match: the report
|
||||
# anchors r1 above r2 above the submit block, so any later one is wrong.
|
||||
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
|
||||
assert ids, "no batch fragment mounted"
|
||||
assert target == "#" + ids[0], (target, ids)
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
|
||||
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
|
||||
thing for an author to typo into an anchor. Against a plain `{}` lookup it
|
||||
came back as Object.prototype.toString — truthy, so it sailed past the
|
||||
unknown-mark guard and threw on `.questions.length`, aborting placement
|
||||
before the tail and costing the page EVERY ask. One typo, no chrome, no
|
||||
error the operator would see. Found by a cross-frontier panel."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
|
||||
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("#t").inner_html().strip() == "" # left alone
|
||||
assert page.locator("#v").inner_html().strip() == "" # left alone
|
||||
# and the real ask still mounted, which is what the bug destroyed
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
|
||||
"""The mirror of the same bug, on the `placed` set. `constructor` matches
|
||||
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
|
||||
read as ALREADY PLACED — so a question the author did not anchor was
|
||||
silently dropped from the tail, which is INV-7's whole subject."""
|
||||
from booth.marks import declare_pick
|
||||
|
||||
base, data = live
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"title": "Round one", "questions": [
|
||||
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||
]})
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('input[name="choice.constructor"]').count() == 2, \
|
||||
"an unplaced question was swallowed by an inherited property"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
|
||||
"""A submit anchor placed inside the author's own `<form>` loses ours: the
|
||||
HTML parser drops a nested form element outright. The controls' `form=`
|
||||
then points at nothing, the button does nothing, and the operator finds out
|
||||
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
|
||||
fix is to count the anchor submitted only if the form actually survived, so
|
||||
the tail supplies one at body level where no form encloses it."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body>'
|
||||
'<div data-booth-mark="batch:r1"></div>'
|
||||
'<div data-booth-mark="batch:r2"></div>'
|
||||
'<form id="mine" action="/elsewhere">'
|
||||
'<div data-booth-ask-submit="batch"></div></form>'
|
||||
f"{SEAM}</body>"), b)
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||
page.check('input[name="choice.r1"][value="keep"]')
|
||||
page.check('input[name="choice.r2"][value="cut"]')
|
||||
with page.expect_navigation():
|
||||
page.click("button.bk-ask-go")
|
||||
ans = _answer(b)
|
||||
assert ans is not None, "the button reached no form"
|
||||
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||
assert ans["answers"]["r2"]["choice"] == "cut"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
|
||||
"""A broken pick has no submit block — its `submit` is empty and the
|
||||
diagnostic lives in `whole`. Mounting that empty string and then recording
|
||||
the pick as placed made the tail skip it, so the 'broken ask' box never
|
||||
rendered at the one surface built to show it. A question the session
|
||||
believes it posted has to be visible."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
|
||||
f"</div>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
|
||||
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
|
||||
# so assert the diagnostic itself, which is the part that has to reach him.
|
||||
shown = page.locator("body").inner_text()
|
||||
assert "this question could not be read" in shown, shown
|
||||
assert "options" in shown # the actual reason
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_author_element_cannot_hijack_the_chip(browser, live):
|
||||
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
|
||||
hyphen boundary included. The chip therefore searches only the elements THIS
|
||||
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
|
||||
guarantee — and takes the earliest of those in document order."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
|
||||
f"<p>report</p>{SEAM}</body>"), b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||
assert target != "#bk-ask-winner-background"
|
||||
landed = page.locator(target)
|
||||
assert landed.count() == 1
|
||||
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
|
||||
"the chip jumped to something the Booth did not mount"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
||||
"""A cold panel read the chip rule as a bare prefix match and pointed out
|
||||
that `bk-ask-batch2-...` starts with `bk-ask-batch`. It does not match: the
|
||||
rule is the id EXACTLY, or the id followed by a hyphen. An author element
|
||||
can collide too, so the fixture plants one."""
|
||||
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
|
||||
in the document. `batch2`'s id starts with `batch`, so the original
|
||||
id-prefix rule could land on it; the mounted-elements rule cannot, because
|
||||
the candidates are partitioned by mark.
|
||||
|
||||
⚠ The first version of this test put the sibling's fragment AFTER the open
|
||||
mark's, so the right answer was also the first answer and pooling every
|
||||
mark's elements passed it. The vacuity pass caught that; the fixture now
|
||||
puts the sibling FIRST, which is the only arrangement that can tell the two
|
||||
implementations apart.
|
||||
"""
|
||||
from booth.marks import declare_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
b = _multi(data / "b") # `batch`, created first
|
||||
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div id="bk-ask-batchX"></div>{SEAM}</body>', b)
|
||||
|
||||
# batch2 is anchored at the very top; batch is unanchored and so lands in
|
||||
# the tail, at the END of the body. Document order is therefore batch2's
|
||||
# fragments, then batch's.
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div data-booth-mark="batch2"></div>'
|
||||
f"<p>report</p>{SEAM}</body>"), b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
|
||||
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||
assert any("batch2" in i for i in ids) and any(
|
||||
"batch2" not in i and "batch" in i for i in ids), ids
|
||||
assert ids.index(next(i for i in ids if "batch2" in i)) < \
|
||||
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
|
||||
f"fixture is wrong: the sibling must come FIRST, got {ids}"
|
||||
|
||||
# `open` is (created, id) -> batch before batch2, so the chip targets batch
|
||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||
assert target != "#bk-ask-batchX"
|
||||
landed = page.locator(target)
|
||||
assert landed.count() == 1
|
||||
# whatever it points at belongs to `batch` itself, not to `batch2`
|
||||
assert "batch2" not in target
|
||||
assert landed.locator('input[name^="choice."]').count() > 0 or "batch" in target
|
||||
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
||||
assert target.startswith("#bk-ask-batch")
|
||||
assert page.locator(target).count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
@@ -353,3 +501,39 @@ def test_the_chip_count_comes_from_the_server(browser, live):
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator(".booth-nav-asks").count() == 0
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
|
||||
"""INV-4, and the version that actually falsifies it.
|
||||
|
||||
The test above uses honest fixtures, so a client that INFERRED openness from
|
||||
the rendered fragments would pass it — the fragments and `open` always agree
|
||||
when the server computes both. A panel pointed out that this never creates
|
||||
the disagreement it claims to test.
|
||||
|
||||
So: intercept the response and make `open` lie. The fragments say fully
|
||||
answered; the payload says two are open. The chip must follow the payload,
|
||||
because the payload is the only thing that decides.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from booth.marks import answer_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
|
||||
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
|
||||
|
||||
def lie(route):
|
||||
body = _json.loads(route.fetch().text())
|
||||
assert body["open"] == [], "fixture is not answered; the lie would be true"
|
||||
body["open"] = ["batch", "batch"]
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=_json.dumps(body))
|
||||
|
||||
page = browser.new_page()
|
||||
page.route("**/embed.json", lie)
|
||||
page.goto(f"{base}/b/b/", wait_until="networkidle")
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
|
||||
page.close()
|
||||
|
||||
Reference in New Issue
Block a user