- The zoom route becomes the review for image, video AND audio: the native player on the stage for sound and video, the Fit/1:1 toggle for pictures only. The judgment rail, the tape and the filmstrip are each a data-region. The stage never is, so a playing track survives an in-place save. - The rail shows the whole-set number, K of M in the review ring and the position in the group; then the caption, and the flag and notes, landing back here (back=view). A pick targeting this item is answerable in place. On the last item the end-of-set block lists what was seen, the flags, and every other open question. - The keys are ← → Space F N Esc. Every one is ignored in an editable field, and Esc returns to the grid at the tile you were on. - _marks.html gains picks_only/back_view, so a pick form has one renderer wherever it sits. - In-place swaps now carry an unsaved draft across. A half-typed note survives a flag, except in the form that was just sent. - The filmstrip keeps the current frame in view. - C7: no emblem in the chrome, pinned. Browser tests cover: F typed into the note stays a letter and does not flag; F outside the note flags in place and the draft survives; Space moves; Esc lands on the grid tile. 706 passed.
151 lines
5.6 KiB
Python
151 lines
5.6 KiB
Python
"""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}"
|
|
|
|
|
|
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"
|
|
|
|
with page.expect_navigation():
|
|
page.keyboard.press(" ")
|
|
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()
|