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:
+119
-4
@@ -412,12 +412,16 @@ def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
|
||||
# ---- C5: the lightbox ---------------------------------------------------------
|
||||
|
||||
def _region(body: str, rid: str) -> str:
|
||||
"""The element carrying data-region=rid, through its matching close tag
|
||||
(same-name nesting counted, so a region holding spans or divs is whole)."""
|
||||
m = re.search(r'<(\w+)[^>]*data-region="%s"[^>]*>' % re.escape(rid), body)
|
||||
assert m, f"no region {rid}"
|
||||
tag = m.group(1)
|
||||
# regions in these templates do not nest a same-named tag inside themselves
|
||||
end = body.index(f"</{tag}>", m.end())
|
||||
return body[m.start():end]
|
||||
tag, depth, pos = m.group(1), 1, m.end()
|
||||
for t in re.finditer(r"<(/?)%s\b[^>]*>" % tag, body[pos:]):
|
||||
depth += -1 if t.group(1) else 1
|
||||
if depth == 0:
|
||||
return body[m.start():pos + t.end()]
|
||||
raise AssertionError(f"region {rid} never closes")
|
||||
|
||||
|
||||
def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path):
|
||||
@@ -558,3 +562,114 @@ def test_no_emblem_in_the_chrome(tmp_path):
|
||||
m = re.search(r'<header class="topbar">.*?</header>', body, re.S)
|
||||
if m:
|
||||
assert "<img" not in m.group(0) and "<svg" not in m.group(0), path
|
||||
|
||||
|
||||
# ---- fixups from the heid code-review panel (round "Wren") --------------------
|
||||
|
||||
@pytest.mark.parametrize("accept,want", [
|
||||
("application/json", True),
|
||||
("application/json;q=0.5", True),
|
||||
("application/json;q=abc", False), # malformed q alone
|
||||
("application/json, application/json;q=broken", False), # malformed AFTER a good one
|
||||
("application/json;q=broken, application/json", False),
|
||||
("application/json, text/plain;q=nope", False), # any unparseable entry
|
||||
])
|
||||
def test_wants_json_fails_closed_on_any_unparseable_entry(accept, want):
|
||||
"""C3: any header that fails to parse is False, whatever order its entries
|
||||
come in. The first cut returned True as soon as it met a good JSON entry,
|
||||
so a malformed one after it was never read (Wren W3, 3/4)."""
|
||||
from booth.app import wants_json
|
||||
assert wants_json(accept) is want
|
||||
|
||||
|
||||
def test_a_board_booth_keeps_its_single_column_even_with_media_in_it(tmp_path):
|
||||
"""C5: ANYTHING with links.md is a board — page identity, not page content
|
||||
(the lesson `is_board` already carries). A board with an image and a
|
||||
links.md that parses to no rows must not get the lightbox (Wren W2, 3/4)."""
|
||||
_booth(tmp_path, "links", {"links.md": b"just prose, no rows\n", "a.png": PNG})
|
||||
body = _client(tmp_path).get("/b/links/").text
|
||||
assert 'class="lightbox"' not in body and 'data-region="verdict"' not in body
|
||||
|
||||
|
||||
def test_the_header_count_and_lifetime_line_are_a_region_too(tmp_path):
|
||||
"""Answering the last open pick in place must not leave "1 open" and
|
||||
"held until answered" stale in the header (Wren, hulda) — they depend on
|
||||
marks, so by C3's rule they are a region."""
|
||||
from booth.marks import declare_pick
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
|
||||
status = _region(_client(tmp_path).get("/b/g/").text, "booth-status")
|
||||
assert "1 open" in status and "held until answered" in status
|
||||
|
||||
|
||||
def test_a_booth_with_marks_and_no_items_keeps_its_panel_in_a_region(tmp_path):
|
||||
"""No set, so no lightbox — but the panel's forms are in-place, so the
|
||||
panel must be a region or a note written there shows nowhere (Wren, hulda)."""
|
||||
from booth.marks import write_note
|
||||
b = _booth(tmp_path, "g", {})
|
||||
write_note(b, None, "only a note")
|
||||
body = _client(tmp_path).get("/b/g/").text
|
||||
assert "only a note" in _region(body, "marks-panel")
|
||||
|
||||
|
||||
def test_interleaved_groups_get_no_inline_headers(tmp_path):
|
||||
"""Groups come from basenames, the order from full paths, so groups can
|
||||
interleave: d1/aa, d1/bb, d2/aa, d2/bb. Re-printing 'aa' and 'bb' would
|
||||
claim runs that are not there, and one header per group would file d2/aa
|
||||
under 'bb'. Headers render only when every group is one contiguous run
|
||||
(Wren, hulda). The rail's jump links are unaffected."""
|
||||
_booth(tmp_path, "g", {"d1/aa-1.png": PNG, "d1/bb-1.png": PNG,
|
||||
"d2/aa-2.png": PNG, "d2/bb-2.png": PNG})
|
||||
body = _client(tmp_path).get("/b/g/").text
|
||||
assert 'class="grp-head"' not in body
|
||||
assert 'class="rail-g"' in body
|
||||
|
||||
|
||||
def test_a_booth_with_damaged_marks_sorts_after_every_dated_question(tmp_path):
|
||||
"""Mixed damage: one readable open pick (the OLDEST stamp here) plus one
|
||||
entry that cannot be read. The booth is held 'unreadable', and the
|
||||
contract puts unreadable booths after every booth with a dated question —
|
||||
the damage is the thing to fix, not the age of the pick beside it (Wren,
|
||||
groa)."""
|
||||
import json
|
||||
from booth.marks import declare_pick
|
||||
mixed = _booth(tmp_path, "mixed", {"a.png": PNG})
|
||||
clean = _booth(tmp_path, "clean", {"a.png": PNG})
|
||||
declare_pick(mixed, "q", {"prompt": "?", "options": ["x", "y"]})
|
||||
declare_pick(clean, "q", {"prompt": "?", "options": ["x", "y"]})
|
||||
_set_created(mixed, "q", "2026-01-01T00:00:00+00:00")
|
||||
_set_created(clean, "q", "2026-09-01T00:00:00+00:00")
|
||||
doc = json.loads((mixed / ".marks.json").read_text())
|
||||
doc["marks"].append({"id": "bad", "shape": "note", "created": 7, "text": "x"})
|
||||
(mixed / ".marks.json").write_text(json.dumps(doc))
|
||||
body = _client(tmp_path).get("/").text
|
||||
assert _desk(body)["needs"] == ["clean", "mixed"]
|
||||
|
||||
|
||||
def test_one_flag_predicate_everywhere_and_a_damaged_flag_counts_nowhere(tmp_path):
|
||||
"""The Desk count, the tray, the filmstrip and the review button all read
|
||||
ONE predicate: a readable flag mark on the item. An unreadable flag entry
|
||||
is judgment we cannot see, and must not inflate a count it cannot be shown
|
||||
in (Wren W4, groa/kimi)."""
|
||||
import json
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
|
||||
set_flag(b, "a.png", True)
|
||||
doc = json.loads((b / ".marks.json").read_text())
|
||||
doc["marks"].append({"id": "flag:b.png", "shape": "flag", "target": "b.png", "created": 9})
|
||||
(b / ".marks.json").write_text(json.dumps(doc))
|
||||
c = _client(tmp_path)
|
||||
row = re.search(r'data-booth="g".*?</article>', c.get("/").text, re.S).group(0)
|
||||
assert "1 flagged" in row
|
||||
review = c.get("/b/g/view?f=b.png").text
|
||||
assert "○ flag" in _region(review, "rail")
|
||||
assert re.findall(r'class="film-f([^"]*)"\s+href="\?f=([^"]+)"', review)[1][0].split() == ["is-current"]
|
||||
|
||||
|
||||
def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path):
|
||||
"""C2: the review route calls record_view as well as record_seen, so the
|
||||
Desk's "new since you looked" clears when the booth is reviewed at full
|
||||
size, not only when its grid is opened (Wren, groa: untested)."""
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
assert not (b / ".viewed").exists()
|
||||
_client(tmp_path).get("/b/g/view?f=a.png")
|
||||
assert (b / ".viewed").exists() and (b / ".seen").exists()
|
||||
|
||||
+104
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user