feat(r2): C5 the lightbox, and the in-place client

- On a gallery booth the marks panel moves into a sticky verdict aside
  beside the set. The aside comes first in the document, so a narrow screen
  stacks the question above the work; grid areas place it on the right when
  wide. Nothing in an ordered collection moves. Boards are unchanged.
- The flag tray lists flagged items by tile number: the declared change
  from the panel list's (created, id). The standalone marks page keeps the
  list.
- Inline group headers are divs, never figure.item.
- Every mark-dependent element is a data-region: the verdict, each tile,
  the rail's filter counts. There is also a server-rendered status line.
- The in-place script (base.html) POSTs with an explicit JSON Accept, then
  on 204 swaps every region from a fresh GET. Live media and per-viewer view
  state are carried across the swap, so there is no layout jolt and no
  stopped track. It never re-POSTs: on failure it says so and reloads. Tile
  controls re-bind after a swap, and the grid cursor survives it.
- The `n` key opens the tile's closed note disclosure before focusing it.
- test_embed_browser's keyboard-flag test expected a navigation, which is
  the defect R2 removes. It is updated as declared in the contract, and
  tightened: a window marker must survive, proving no reload.

Browser tests: flag in place, with no reload and no scroll jump, and the
tile, tray and rail count all updated; and a failed save that reloads
without re-POSTing. Two mutations turn them red (no carry, no rail region).
700 passed.
This commit is contained in:
vh
2026-09-23 08:57:36 -07:00
parent ce27b06f32
commit 50f88a3e5e
8 changed files with 445 additions and 109 deletions
+13 -4
View File
@@ -592,17 +592,26 @@ def test_the_keyboard_flag_actually_submits(browser, live):
a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke.
Asserted end to end: press f, and the flag must come back from the server
on the reloaded page."""
Asserted end to end: press f, and the flag must come back from the server.
R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
used to expect a NAVIGATION — the flag form POSTed and the page reloaded.
That reload is the defect R2 removes. The claim that matters is kept and
tightened: the flag must come back from the SERVER (the swapped tile is
server-rendered), and a marker set on the window before the keypress must
survive, which a reload would wipe."""
base, root = live
_gallery(root)
page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight") # cursor onto the first tile
with page.expect_navigation(): # the flag form POSTs and redirects back
page.keyboard.press("f")
page.keyboard.press("f")
page.wait_for_selector("figure.item.is-flagged", timeout=10000)
flagged = page.locator("figure.item.is-flagged").count()
survived = page.evaluate("window.__noReload === 1")
page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
assert survived, "the flag reloaded the page; in-place judgment must not"
+69
View File
@@ -407,3 +407,72 @@ def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
_booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"})
row = re.search(r'data-booth="songs".*?</article>', _client(tmp_path).get("/").text, re.S).group(0)
assert "♪ audio" in row and "<img" not in row
# ---- C5: the lightbox ---------------------------------------------------------
def _region(body: str, rid: str) -> str:
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]
def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path):
"""The tracer for C5: the open question, the flags and the notes live in
one aside next to the grid — not in a panel above it that scrolls away."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
declare_pick(b, "q", {"prompt": "Which one?", "options": ["a", "b"]})
body = _client(tmp_path).get("/b/g/").text
aside = _region(body, "verdict")
assert aside.startswith('<aside class="verdict"')
assert 'action="/b/g/answer"' in aside and "Which one?" in aside
assert body.count('action="/b/g/answer"') == 1, "the pick renders once, in the aside"
assert 'class="lightbox"' in body and 'id="grid"' in body
def test_the_flag_tray_lists_flags_by_tile_number_not_by_click_order(tmp_path):
"""Flag #3 first, then #1: the tray reads #1, #3. Today's panel list is in
click order `(created, id)`; the tray is the SET's order, the declared
change. Each entry is the original shown small, blurred if the item is."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
set_flag(b, "c.png", True)
time.sleep(0.01)
set_flag(b, "a.png", True)
set_blurred(b, "a.png", True)
aside = _region(_client(tmp_path).get("/b/g/").text, "verdict")
tray = re.findall(r'<a class="tray-item( is-blurred)?" href="view\?f=([^"]+)"[^>]*>.*?#(\d+)<', aside, re.S)
assert tray == [(" is-blurred", "a.png", "1"), ("", "c.png", "3")]
def test_groups_get_an_inline_header_that_is_not_a_tile(tmp_path):
"""When the rail finds grouping informative, each group's first tile is
preceded by a header — a div, never a figure.item, so the tile sequence
the keyboard and the order check walk is exactly the items."""
files = {f"{g}-{n}.png": PNG for g in ("aa", "bb", "cc") for n in (1, 2)}
_booth(tmp_path, "g", files)
body = _client(tmp_path).get("/b/g/").text
grid = body[body.index('id="grid"'):]
heads = re.findall(r'<div class="grp-head"[^>]*><span class="grp-key">(\w+)</span> <span class="grp-n">(\d+)</span>', grid)
assert heads == [("aa", "2"), ("bb", "2"), ("cc", "2")]
assert len(re.findall(r'<figure class="item', grid)) == 6
# and no header at all when grouping is not informative
_booth(tmp_path, "flat", {f"DSC{n}.jpg": PNG for n in range(4)})
assert 'class="grp-head"' not in _client(tmp_path).get("/b/flat/").text
def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
"""The in-place script replaces regions by id; a stale flag count or tile
after an in-place flag is the failure this pins."""
b = _booth(tmp_path, "g", {"a b.png": PNG, "c.png": PNG})
body = _client(tmp_path).get("/b/g/").text
for rid in ("verdict", "filters", "item-a%20b.png", "item-c.png", "status"):
assert f'data-region="{rid}"' in body, rid
# the standing board has no lightbox and therefore no verdict aside
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\n"})
board = _client(tmp_path).get("/b/links/").text
assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
+114
View File
@@ -0,0 +1,114 @@
"""R2 C3/C5/C6 in a real DOM: in-place judgment.
A TestClient can prove what the server answers. It cannot prove that a flag
made in the page lands without a reload, that the regions come back fresh, or
that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same
harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
browser is available.
"""
import pathlib
import socket
import sys
import threading
import time
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright is not installed")
PNG = b"\x89PNG\r\n\x1a\n"
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch()
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}")
yield b
b.close()
@pytest.fixture
def live(tmp_path):
import uvicorn
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
time.sleep(0.02)
if not server.started:
pytest.skip("uvicorn did not come up")
try:
yield f"http://127.0.0.1:{port}", tmp_path
finally:
server.should_exit = True
thread.join(timeout=10)
def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
b = root / "g"
b.mkdir()
for i in range(1, n + 1):
(b / f"{i:02d}.png").write_bytes(PNG)
return b
def test_a_flag_lands_in_place_and_every_region_catches_up(browser, live):
"""Click a tile's flag far down the page: no reload, the scroll position
holds, and the tile, the tray and the rail's count all show the server's
new state — the three places one flag appears."""
base, root = live
_set(root)
posts = []
page = browser.new_page(viewport={"width": 1400, "height": 800})
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
tile = page.locator('figure.item[data-item="24.png"]')
tile.scroll_into_view_if_needed()
y = page.evaluate("window.scrollY")
tile.locator(".flagtoggle button").click()
page.wait_for_selector('figure.item.is-flagged[data-item="24.png"]', timeout=10000)
state = page.evaluate("""() => ({
reload: window.__noReload !== 1,
y: window.scrollY,
tray: [...document.querySelectorAll('.tray .tray-ord')].map(e => e.textContent),
count: document.querySelector('.rail-f[data-filter="flagged"] b').textContent,
})""")
page.close()
assert not state["reload"]
assert abs(state["y"] - y) < 4, "the page must not jump"
assert state["tray"] == ["#24"]
assert state["count"] == "1"
assert len(posts) == 1
def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
"""The server refuses the write (the marks file went bad under the page).
The script must not retry — a retry after a lost response would duplicate
the judgment — it says so and reloads to show the server's truth."""
base, root = live
b = _set(root, 3)
posts = []
page = browser.new_page()
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.wait_for_load_state("networkidle")
page.close()
assert len(posts) == 1, f"re-POSTed: {posts}"