The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."
- Reveal all: one control per booth, in the booth header and the review's
top bar, outside every data-region. It is in the markup only when
something is blurred, always `hidden` until the script shows it.
- The state is sessionStorage per booth, per tab, and nothing reaches
the server. It is carried as one `reveal-all` class on <html>, applied
before first paint from the page's own data-booth, so booth A's reveal
cannot follow you into booth B and the index is never revealed.
- Per-item reveal buttons stand down by stylesheet, and an item's own
reveal is never touched, so "blur again" restores each item as it was.
- A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
it works with scripts off. Its label follows is_booth_blurred; from the
review it carries `back` and lands on the same item. A fogged booth's
Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
`blurred`, so an item blurred only by the booth offered an un-blur that
visibly did nothing. The gallery now carries `blurred_self`, and such an
item shows "◉ booth", a label rather than a control.
Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
687 lines
31 KiB
Python
687 lines
31 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]
|
||
# ⚠ THE SOCKET IS HANDED TO UVICORN STILL BOUND, never closed and
|
||
# re-opened by port number. The old form did bind -> getsockname -> CLOSE ->
|
||
# tell uvicorn the number, which leaves a window where the kernel can give
|
||
# that port to somebody else — and this suite runs TWO browser files that
|
||
# each start a server per test, so the other one is right there competing
|
||
# for it. Passing the live socket removes the window rather than narrowing
|
||
# it.
|
||
#
|
||
# Honest about the evidence: two different browser tests failed once each
|
||
# across full-suite runs while passing 3/3 and 5/5 on their own, which is
|
||
# the signature of contention. We cannot prove from two samples that this
|
||
# race was the cause. It is a real defect either way, and it is the only
|
||
# one visible in the harness.
|
||
|
||
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=lambda: server.run(sockets=[sock]), 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)
|
||
|
||
|
||
_FILTER = "sel => { const e = document.querySelector(sel); return e ? getComputedStyle(e).filter : 'MISSING'; }"
|
||
|
||
|
||
def _blurred_set(root: pathlib.Path, name: str = "g", blur=("a.png",), flag=("a.png",)) -> pathlib.Path:
|
||
from booth.app import set_blurred
|
||
from booth.marks import set_flag
|
||
b = root / name
|
||
b.mkdir()
|
||
for rel in ("a.png", "b.png", "c.png"):
|
||
(b / rel).write_bytes(PNG)
|
||
for rel in blur:
|
||
set_blurred(b, rel, True)
|
||
for rel in flag:
|
||
set_flag(b, rel, True)
|
||
return b
|
||
|
||
|
||
def _settle(page) -> None:
|
||
page.wait_for_timeout(400) # the filter transition (--dur-2)
|
||
|
||
|
||
def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(browser, live):
|
||
"""r2b D2 (blur ruling A): one click lifts the blur on the tile, the tray,
|
||
the review stage and the filmstrip, and it holds on the next page of the
|
||
same tab. A fresh tab is blurred again — per tab, never persisted."""
|
||
base, root = live
|
||
_blurred_set(root)
|
||
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||
page = ctx.new_page()
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
tile, tray = 'figure.item[data-item="a.png"] img', ".tray-item.is-blurred img"
|
||
before = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
|
||
page.locator("[data-reveal-all]").click()
|
||
_settle(page)
|
||
after = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
|
||
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
|
||
_settle(page)
|
||
review = [page.evaluate(_FILTER, "#vimg"), page.evaluate(_FILTER, ".film-f.is-blurred img"),
|
||
page.locator("[data-reveal-all]").inner_text()]
|
||
fresh = ctx.new_page() # a new tab: sessionStorage is per tab
|
||
fresh.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
|
||
_settle(fresh)
|
||
again = fresh.evaluate(_FILTER, "#vimg")
|
||
ctx.close()
|
||
assert all("blur" in f for f in before), before
|
||
assert after == ["none", "none"], after
|
||
assert review[:2] == ["none", "none"] and "blur again" in review[2], review
|
||
assert "blur" in again, again
|
||
|
||
|
||
def test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk(browser, live):
|
||
"""r2b D2 + INV-4: the reveal is scoped to the booth you are in. Booth A's
|
||
cannot follow you into booth B, and nothing on the index is revealed."""
|
||
base, root = live
|
||
_blurred_set(root, "ga")
|
||
_blurred_set(root, "gb")
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.goto(f"{base}/b/ga/", wait_until="networkidle")
|
||
page.locator("[data-reveal-all]").click()
|
||
page.goto(f"{base}/b/gb/", wait_until="networkidle")
|
||
_settle(page)
|
||
b_tile = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
|
||
page.goto(f"{base}/", wait_until="networkidle")
|
||
desk = page.evaluate("""() => [...document.querySelectorAll('.desk-strip img.blurred-thumb')]
|
||
.map(i => getComputedStyle(i).filter)""")
|
||
page.close()
|
||
assert "blur" in b_tile, b_tile
|
||
assert desk and all("blur" in f for f in desk), desk
|
||
|
||
|
||
def test_reveal_all_survives_an_in_place_save(browser, live):
|
||
"""r2b D2: after an in-place save the blur is still lifted — the swapped-in
|
||
tile included — the control still says "blur again" and still works, and
|
||
the per-tile reveal buttons are still stood down."""
|
||
base, root = live
|
||
_blurred_set(root, blur=("a.png", "b.png"), flag=())
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
page.evaluate("window.__same_page = 1")
|
||
page.locator("[data-reveal-all]").click()
|
||
page.locator('figure.item[data-item="b.png"] .flagtoggle button').click()
|
||
page.wait_for_selector('figure.item.is-flagged[data-item="b.png"]', timeout=10000)
|
||
_settle(page)
|
||
got = {"same": page.evaluate("window.__same_page === 1"),
|
||
"b": page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'),
|
||
"label": page.locator("[data-reveal-all]").inner_text(),
|
||
"tile_btn": page.evaluate("""() => getComputedStyle(
|
||
document.querySelector('figure.item[data-item="b.png"] .reveal')).display""")}
|
||
page.locator("[data-reveal-all]").click()
|
||
_settle(page)
|
||
got["back"] = page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img')
|
||
page.close()
|
||
assert got["same"] and got["b"] == "none" and "blur again" in got["label"], got
|
||
assert got["tile_btn"] == "none" and "blur" in got["back"], got
|
||
|
||
|
||
def test_blur_again_restores_each_items_own_reveal(browser, live):
|
||
"""r2b D2: Reveal all never touches an item's own reveal, so "blur again"
|
||
returns each item exactly as it was — one revealed on its own stays so."""
|
||
base, root = live
|
||
_blurred_set(root, blur=("a.png", "b.png"), flag=())
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
page.locator('figure.item[data-item="a.png"] .reveal').click()
|
||
page.locator("[data-reveal-all]").click()
|
||
page.locator("[data-reveal-all]").click()
|
||
_settle(page)
|
||
got = [page.evaluate(_FILTER, f'figure.item[data-item="{r}"] img') for r in ("a.png", "b.png")]
|
||
page.close()
|
||
assert got[0] == "none" and "blur" in got[1], got
|
||
|
||
|
||
def test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click(browser, live):
|
||
"""r2b D2: without JS the control is in the markup but never shown. With
|
||
sessionStorage throwing on write (a private window), the click still
|
||
applies to the page — only the memory is lost."""
|
||
base, root = live
|
||
_blurred_set(root)
|
||
ctx = browser.new_context(java_script_enabled=False)
|
||
page = ctx.new_page()
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
nojs = page.locator("[data-reveal-all]").is_visible()
|
||
ctx.close()
|
||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };")
|
||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||
page.locator("[data-reveal-all]").click()
|
||
_settle(page)
|
||
lifted = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
|
||
page.close()
|
||
assert not nojs
|
||
assert lifted == "none", lifted
|