fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded

Code fixes:
- The narrow-screen fold was specified and never built (4/4). The tray and
  notes are now closed <details> in the aside; above 1000px CSS alone
  (::details-content) shows them and hides the summary. There is no
  script. Browser-tested at 390 and 1400, JS on and off.
- The lightbox gated on parsed board rows, not page identity (3/4). It now
  uses is_board, the lesson the bench panel already carried.
- wants_json returned True at the first good entry, so a malformed later
  entry was never read (3/4). It now parses every entry first; any error
  is False.
- One flag predicate, flagged_targets. It serves the Desk count, the tray,
  the filmstrip, the tape and the review button. An unreadable flag entry
  counts nowhere.
- The header's open count and lifetime line, and the no-set marks panel,
  are now regions (they were stale after an in-place answer).
- Inline group headers render only when every group is one contiguous run.
  Interleaved directories no longer reprint or misfile headers.
- A booth held unreadable has no open_since, even with a readable pick
  beside the damage.
- The swap marks an absent region is-stale instead of leaving it looking
  current. It carries disclosure state (except the sent form's). The
  failure message is readable for 0.9 s before the reload.

Contract amended where the code was right and the text was not: the
wants_json and record_seen signatures, landed_at's three refinements, the
group position being ring-based, the end of the set offering every other
open pick, the Space-key player exception, and the fold mechanism.

New tests cover the parse order; a board with media; the header region; the
no-set panel; interleaved groups; mixed damage; the flag predicate; the
review recording .viewed; the fold at two widths with JS on and off; the
status message before the reload; a lost response after a landed write
(exactly one note); a stale absent region; stage node identity across a
swap; and F with a radio focused. The lost-response and stale tests turn
red under their mutations. 724 passed.
This commit is contained in:
vh
2026-09-23 09:41:18 -07:00
parent 881c7f5df3
commit fa5d46443d
7 changed files with 404 additions and 41 deletions
+104 -2
View File
@@ -107,13 +107,57 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
(b / ".marks.json").write_text("{damaged")
with page.expect_navigation(timeout=10000):
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # past the reload
page.close()
assert "Could not save in place" in said
assert len(posts) == 1, f"re-POSTed: {posts}"
def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
"""The case the never-re-POST rule exists for: the server WROTE the note,
then the response was lost. A retry would write it twice. The route lets
the request reach the server and then drops the reply."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
def drop_after_write(route):
route.fetch() # the write lands
route.abort() # ...and the browser never hears back
page.route("**/b/g/note", drop_after_write)
page.locator(".verdict .mark-add textarea").fill("exactly once")
page.locator(".verdict .mark-add button").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
page.wait_for_timeout(1500)
page.close()
notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
assert len(notes) == 1, f"written {len(notes)} times"
def test_a_tile_the_fresh_page_no_longer_has_stays_put_marked_stale(browser, live):
"""Un-flag under ?filter=flagged: the fresh page has no such tile. It is
left where it is (nothing shifts under the reader) and marked stale."""
from booth.marks import set_flag
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/?filter=flagged", wait_until="networkidle")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
tiles = page.eval_on_selector_all("figure.item", "els => els.map(e => e.dataset.item)")
page.close()
assert tiles == ["01.png", "02.png"]
def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
"""At full size: F typed into the note is a letter; F outside it flags IN
PLACE (the filmstrip underline and the tape catch up, no reload); Space
@@ -141,10 +185,68 @@ def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
assert state["film"] == ["#2"]
assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
page.keyboard.press("n")
focused = page.evaluate("document.activeElement.id")
assert focused == "vnote-text", "N focuses the note"
page.locator(".vr-where").click()
with page.expect_navigation():
page.keyboard.press(" ")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("ArrowRight")
assert page.url.endswith("/b/g/view?f=04.png")
with page.expect_navigation():
page.keyboard.press("ArrowLeft")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("Escape")
assert page.url.endswith("/b/g/#item-03.png")
page.close()
def test_the_stage_survives_an_in_place_save_and_a_focused_radio_keeps_f(browser, live):
"""The stage is never a region: the same node must still be on the page
after a flag lands (a playing track would restart otherwise). And F while
a radio — an <input> — has focus is not a flag."""
from booth.marks import declare_pick
base, root = live
b = _set(root, 2)
declare_pick(b, "q", {"prompt": "Sharp?", "options": ["yes", "no"]}, target="01.png")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
page.evaluate("document.getElementById('vstage').__mark = 1")
page.locator('.vrail input[type=radio]').first.focus()
page.keyboard.press("f")
page.wait_for_timeout(600)
assert page.locator(".vflag-btn.is-flagged").count() == 0, "F in a radio is not a flag"
page.locator(".vr-where").click()
page.keyboard.press("f")
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
same = page.evaluate("document.getElementById('vstage').__mark === 1")
page.close()
assert same, "the stage was replaced by the swap"
@pytest.mark.parametrize("js", [True, False])
def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(browser, live, js):
"""C5 (Wren W1, 4/4): under 1000px the verdict stacks ABOVE the set, and
its flags and notes fold into <details> so the question is not buried —
with no script. Wide, they are simply shown. Checked with JS on and off."""
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "02.png", True)
write_note(b, None, "a booth note")
for width, shown in ((390, False), (1400, True)):
ctx = browser.new_context(viewport={"width": width, "height": 900}, java_script_enabled=js)
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
tray = page.locator(".verdict .tray-item").first
note = page.locator(".verdict .mark-text", has_text="a booth note")
assert tray.is_visible() is shown, (width, js, "tray")
assert note.is_visible() is shown, (width, js, "note")
if not shown: # folded, but one tap away
page.locator(".verdict summary.v-fold-head").first.click()
assert tray.is_visible()
ctx.close()