- Touch: on a coarse pointer every row control is at least 28px square again (32px), and wipe stands clear of the zip link. The move onto the facts line had dropped the deliberate 28px floor to ~21px, 4-6px from zip; with scripts off no confirm fires, so a mis-tap on wipe is the delete. The zip link no longer breaks between its glyph and its word, and each separator is glued to the item after it. - The wipe dialog shows the name as it should be read: control and bidi formatting characters in an agent-made name show as U+FFFD, so U+202E or a newline cannot rewrite what the operator approves. An unknown data-confirm word now prompts generically instead of submitting unguarded (fail closed). - No page scrolls sideways: `code` wraps anywhere, so a long unbreakable install path in the footer or the empty Desk no longer widens every page. The overflow test now sweeps 390/720/850/1000/1400 with the heaviest row the Desk draws, and compares scrollWidth with the page's own clientWidth. Its first fixture used a hyphenated path, which wrapped by itself; the test passed with the bug present until the path became one unbreakable run. r2_flow.toml: 27/27 proved. 749 passed.
540 lines
24 KiB
Python
540 lines
24 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 contextlib
|
||
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()
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _serving(data_dir: pathlib.Path):
|
||
"""A real uvicorn on a free port over `data_dir`. A context manager rather
|
||
than only a fixture, so a test can serve from a data dir of its own shape
|
||
(a long install path, say)."""
|
||
import uvicorn
|
||
|
||
sock = socket.socket()
|
||
sock.bind(("127.0.0.1", 0))
|
||
port = sock.getsockname()[1]
|
||
sock.close()
|
||
app = create_app(data_dir, 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}"
|
||
finally:
|
||
server.should_exit = True
|
||
thread.join(timeout=10)
|
||
|
||
|
||
@pytest.fixture
|
||
def live(tmp_path):
|
||
with _serving(tmp_path) as base:
|
||
yield base, tmp_path
|
||
|
||
|
||
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 <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()
|
||
|
||
|
||
# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
|
||
|
||
def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
|
||
"""Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
|
||
multi-select + confirmation script along with the handlers it replaced.
|
||
Removing a row is destructive; the confirm naming it must still stand in
|
||
front of the POST, and select-all must still select."""
|
||
base, root = live
|
||
board = root / "links"
|
||
board.mkdir()
|
||
(board / "links.md").write_text(
|
||
"- [one](http://x/1) <sub>· a · 2026-09-01 10:00</sub>\n"
|
||
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\n")
|
||
page = browser.new_page()
|
||
page.goto(f"{base}/b/links/", wait_until="networkidle")
|
||
dialogs = []
|
||
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
|
||
page.locator(".board-rm-btn").first.click()
|
||
page.wait_for_timeout(300)
|
||
page.locator("#board-selall").check()
|
||
ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
|
||
page.close()
|
||
assert dialogs and "Remove this link?" in dialogs[0]
|
||
assert ticked == 2
|
||
assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
|
||
|
||
|
||
def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
|
||
"""Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
|
||
other in-place save, drafts were matched by POSITION, and a double-click on
|
||
Add note wrote two notes. Now: dirty controls carry by identity, and a form
|
||
already in flight ignores a second submit."""
|
||
from booth.marks import declare_pick, marks_for
|
||
base, root = live
|
||
b = _set(root, 3)
|
||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
page.locator('.verdict input[type=radio][value="y"]').check()
|
||
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
|
||
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
|
||
assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
|
||
|
||
page.locator(".verdict .mark-add textarea").fill("once")
|
||
page.locator(".verdict .mark-add button").dblclick()
|
||
page.wait_for_timeout(1500)
|
||
page.close()
|
||
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
|
||
|
||
|
||
# The first page refresh after a save is held back 1s in the CLIENT: the server
|
||
# renders it at once (so it carries only the first flag) and the browser sees
|
||
# it late. Localhost alone never loses that race, so without the hold the test
|
||
# passed with sequencing deleted — it has to be forced to be a control.
|
||
_HOLD_FIRST_REFRESH = """
|
||
(function () {
|
||
var real = window.fetch, n = 0;
|
||
window.fetch = function (u, o) {
|
||
var p = real.apply(this, arguments);
|
||
if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
|
||
return p.then(function (r) {
|
||
return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
|
||
});
|
||
}
|
||
return p;
|
||
};
|
||
})();
|
||
"""
|
||
|
||
|
||
def test_quick_successive_flags_all_show(browser, live):
|
||
"""Nyx (hulda): with no sequencing, an older refresh landing after a newer
|
||
one showed the newer flag as gone. Saves are serialized."""
|
||
base, root = live
|
||
_set(root, 4)
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.add_init_script(_HOLD_FIRST_REFRESH)
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
for rel in ("01.png", "02.png", "03.png"):
|
||
page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
|
||
page.wait_for_timeout(150)
|
||
page.wait_for_function(
|
||
"document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
|
||
page.wait_for_timeout(1500)
|
||
n = page.locator("figure.item.is-flagged").count()
|
||
page.close()
|
||
assert n == 3
|
||
|
||
|
||
def test_the_standalone_marks_page_updates_in_place(browser, live):
|
||
"""Nyx (kimi): the marks page's forms are in-place, but the page had no
|
||
region, so an answer saved and the page never showed it."""
|
||
from booth.marks import declare_pick
|
||
base, root = live
|
||
b = _set(root, 1)
|
||
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||
page = browser.new_page()
|
||
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||
page.evaluate("window.__same_page = 1")
|
||
page.locator('input[type=radio][value="x"]').check()
|
||
page.locator(".mark-submit").click()
|
||
page.wait_for_selector(".mark.is-answered", timeout=10000)
|
||
# In place, not the reload fallback: the page's own window survived.
|
||
same = page.evaluate("window.__same_page === 1")
|
||
page.close()
|
||
assert same
|
||
|
||
|
||
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
||
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
||
base.html's narrow override and won it, parking the arrow 360px in from
|
||
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
||
base, root = live
|
||
_set(root, 3)
|
||
rights = {}
|
||
for w in (1400, 390):
|
||
page = browser.new_page(viewport={"width": w, "height": 900})
|
||
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
||
rights[w] = page.evaluate(
|
||
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
||
page.close()
|
||
assert rights == {1400: "360px", 390: "0px"}
|
||
|
||
|
||
def test_a_rows_keep_release_and_wipe_take_no_room_of_their_own(browser, live):
|
||
"""Operator, on the live Desk: 'release and x take up space whether or not
|
||
they're visible.' They were `opacity:0` in their own side column, which
|
||
hides a control and still reserves its box. Each now sits on the facts
|
||
line beside the state it changes, visible without hover (a touch screen
|
||
never had hover), so a row with no badge has no side column at all."""
|
||
import os
|
||
base, root = live
|
||
past = time.time() - 10_000
|
||
for name, kept in (("kept1", True), ("loose", False)):
|
||
d = root / name
|
||
d.mkdir()
|
||
(d / "a.png").write_bytes(PNG)
|
||
os.utime(d / "a.png", (past, past))
|
||
(d / ".viewed").write_bytes(b"") # looked at since: no 'new' badge
|
||
if kept:
|
||
(d / ".forever").write_bytes(b"")
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
page.mouse.move(0, 0) # nothing hovered
|
||
out = {}
|
||
for name, form in (("kept1", "form.release"), ("kept1", "form.wipe-kept"),
|
||
("loose", "form.keepit"), ("loose", "form.wipe")):
|
||
btn = page.locator(f'.desk-row[data-booth="{name}"] {form} button')
|
||
out[(name, form)] = btn.is_visible() and btn.evaluate(
|
||
"b => { for (let e = b; e; e = e.parentElement)"
|
||
" if (getComputedStyle(e).opacity === '0') return false;"
|
||
" return true; }")
|
||
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(r => {
|
||
const row = r.getBoundingClientRect(), main = r.querySelector('.desk-main').getBoundingClientRect();
|
||
return Math.round(row.right - main.right); })""")
|
||
page.close()
|
||
assert all(out.values()), out
|
||
# row padding (12) + border (1) + nothing else: no side column is reserved
|
||
assert gaps and max(gaps) <= 14, gaps
|
||
|
||
|
||
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
|
||
"""The row-controls change made a Desk row a flex container, and a long
|
||
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
|
||
width: at 390px the page scrolled sideways to 1029px. The column is capped
|
||
at the space it has; the ellipsis does the rest.
|
||
|
||
Slate (heid bug-hunt) widened it three ways: the invariant is PAGE-level,
|
||
so a long install path — printed in the footer and the empty state — must
|
||
wrap too (groa); the widths between the two first tested (700-1000px, a
|
||
non-wrapping flex line) are swept with the heaviest row the Desk draws
|
||
(regin); and the page is compared with its OWN client width, never the
|
||
viewport's, which a vertical scrollbar would make a false failure (kimi)."""
|
||
from booth.manifest import write_manifest
|
||
from booth.marks import declare_pick, set_flag
|
||
# ONE unbreakable run: a hyphen or a slash is a line-break opportunity, and
|
||
# the first draft of this fixture ("a-very-long-install-path-" * 9) wrapped
|
||
# all by itself — the test passed with the bug present.
|
||
root = tmp_path / ("averylonginstallpath" * 11)
|
||
root.mkdir()
|
||
over = {}
|
||
with _serving(root) as base:
|
||
for w in (390, 1400): # the empty Desk first
|
||
page = browser.new_page(viewport={"width": w, "height": 844})
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
over[("empty", w)] = page.evaluate(
|
||
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
||
page.close()
|
||
d = root / ("heavy-" + "x" * 60)
|
||
d.mkdir()
|
||
for i in range(12):
|
||
(d / f"{i:02d}.png").write_bytes(PNG)
|
||
set_flag(d, f"{i:02d}.png", True)
|
||
declare_pick(d, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||
(d / ".uploaded").write_bytes(b"")
|
||
write_manifest(d, "design-dev", title="A set with a long title " * 4,
|
||
why="a reason long enough to overflow any phone " * 6)
|
||
for w in (390, 720, 850, 1000, 1400):
|
||
page = browser.new_page(viewport={"width": w, "height": 844})
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
over[("heavy", w)] = page.evaluate(
|
||
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
||
page.close()
|
||
assert all(v <= 0 for v in over.values()), over
|
||
|
||
|
||
def test_on_a_touch_screen_the_row_controls_keep_their_tap_floor(browser, live):
|
||
"""Slate T2 (kimi, groa): moving keep/release/wipe onto the facts line
|
||
dropped the deliberate 28px tap target (`height:28px;min-width:28px`) to
|
||
about 19px, 4-6px from the zip link. With scripts off no confirm fires, so
|
||
a mis-tap on wipe POSTs the delete. On a coarse pointer every row control
|
||
is at least 28px square again and wipe stands clear of the zip link; a
|
||
fine pointer keeps the compact line."""
|
||
import os
|
||
base, root = live
|
||
past = time.time() - 10_000
|
||
for name, kept in (("kept1", True), ("loose", False)):
|
||
d = root / name
|
||
d.mkdir()
|
||
(d / "a.png").write_bytes(PNG)
|
||
os.utime(d / "a.png", (past, past))
|
||
(d / ".viewed").write_bytes(b"")
|
||
if kept:
|
||
(d / ".forever").write_bytes(b"")
|
||
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
|
||
page = ctx.new_page()
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
coarse = page.evaluate("matchMedia('(pointer: coarse)').matches")
|
||
boxes = page.evaluate("""() => [...document.querySelectorAll('.desk-facts form button')].map(b => {
|
||
const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; })""")
|
||
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
|
||
const z = row.querySelector('.dl-link').getBoundingClientRect(),
|
||
w = row.querySelector('form.wipe button').getBoundingClientRect();
|
||
// the clearance between the two boxes on whichever axis separates them:
|
||
// beside each other on one line, or wipe wrapped onto the next
|
||
return Math.round(Math.max(w.left - z.right, z.left - w.right, w.top - z.bottom, z.top - w.bottom)); })""")
|
||
ctx.close()
|
||
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
|
||
assert len(boxes) == 4 and all(w >= 28 and h >= 28 for w, h in boxes), boxes
|
||
assert all(g >= 8 for g in gaps), gaps
|
||
|
||
|
||
def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser, live):
|
||
"""Slate T4 (groa): the booth name travels as data, never into a script —
|
||
but the confirm TEXT showed it raw, so a name carrying a bidi override
|
||
(U+202E) or a newline rewrote what the operator reads before approving a
|
||
wipe. Controls and bidi formatting show as U+FFFD instead. And (seat S1)
|
||
a `data-confirm` word the page does not know submitted with NO prompt; an
|
||
unknown word now asks generically — fail closed, never open."""
|
||
import os
|
||
base, root = live
|
||
name = "safegnp.xe\nline2"
|
||
d = root / name
|
||
d.mkdir()
|
||
(d / "a.png").write_bytes(PNG)
|
||
past = time.time() - 10_000
|
||
os.utime(d / "a.png", (past, past))
|
||
(d / ".viewed").write_bytes(b"")
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
said = []
|
||
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
page.locator("form.wipe button").first.click()
|
||
page.wait_for_timeout(300)
|
||
page.evaluate("""() => { const f = document.createElement('form');
|
||
f.method = 'post'; f.action = '/nowhere'; f.setAttribute('data-confirm', 'typo');
|
||
f.setAttribute('data-booth', 'x'); f.innerHTML = '<button>go</button>';
|
||
document.body.appendChild(f); f.querySelector('button').click(); }""")
|
||
page.wait_for_timeout(300)
|
||
page.close()
|
||
assert d.exists(), "a dismissed confirm must not wipe"
|
||
assert len(said) == 2, said
|
||
shown = said[0].split("\n\n")[0]
|
||
assert not any(c in shown for c in "\n"), repr(shown)
|
||
assert "safe�gnp.xe�line2" in shown, repr(shown)
|