"""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") 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 moves on; Esc goes back to the grid at the tile you were on.""" base, root = live _set(root, 4) page = browser.new_page(viewport={"width": 1400, "height": 900}) page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle") page.evaluate("window.__noReload = 1") page.locator("#vnote-text").click() page.keyboard.type("fff") assert page.locator(".vflag-btn.is-flagged").count() == 0 assert page.locator("#vnote-text").input_value() == "fff" page.locator(".vr-where").click() # focus back on the page, not a field page.keyboard.press("f") page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000) state = page.evaluate("""() => ({ reload: window.__noReload !== 1, film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent), draft: document.getElementById('vnote-text').value, })""") assert not state["reload"] 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 — 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
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()