]*>.*?#(\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']*>(\w+) (\d+) ', grid)
+ assert heads == [("aa", "2"), ("bb", "2"), ("cc", "2")]
+ assert len(re.findall(r'\xc2\xb7 a \xc2\xb7 2026-09-01 10:00\n"})
+ board = _client(tmp_path).get("/b/links/").text
+ assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py
new file mode 100644
index 0000000..be3d889
--- /dev/null
+++ b/tests/test_flow_browser.py
@@ -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}"