The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
first by the grid, the review and compare. A field or a player owns every
key but Escape (Esc still goes back from a focused player); a control
owns Space; a focused 1:1 stage that can pan owns the
arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
the browser's. The field check lives on as BoothKeys.isEditable. Before:
an arrow on a focused video left the review, and Enter on any control
also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
(script-set, one tile at a time) and focus, without a scroll; the cursor
is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
review only from the body, the grid or the tile, by its view?f= link;
n opens a closed doc's fold; Escape clears the cursor
and releases the tile's focus. The reticle is its focus mark (no second
ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
details.doc-fold (its summary is the label only) and div.doc-tools beside
it; the body and notes follow in div.doc-inline, hidden with a closed
fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
picture as the browser draws it (EXIF 5-8 swap), read from the header
only (no decode; PNG getexif is skipped unless the header carried it),
opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
included, so cp -p over a file is seen), in a separate
step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
its mark. content-visibility:auto, which the report proposed too, is NOT
added: a swapped-in tile has no remembered size, and a flag far down moved
the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
scroll-margin-top (the two add), so a control reached by Tab stops below
the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
rules' numbers (132px, 217px at <=480), now pinned by a test. The height
script follows the live rail after every in-place save (it watched the
replaced node, and read 0px after one flag), and the rail's own controls
cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
words on it, the glyph in an aria-hidden span, the item's name as
.sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
aria-pressed (its words already say the state; r2b rules them) and its
"on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
refusal's words, and a later save says every standing failure's words
(each once, in order) instead of "Saved.", and every warning says the
other standing failures first, so no failure buries another. Test first:
test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
(now in BoothKeys), r2c "the stage reveal shows with scripts off", and
this contract's S3 doc-bar row and five S5b status-line rows.
Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).
Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).
From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).
Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
804 lines
34 KiB
Python
804 lines
34 KiB
Python
"""The anti-slop fix slices (docs/contracts/as_antislop.contract.md).
|
||
|
||
S1, the house clock: a clock time the operator reads is local 24-hour time as
|
||
four digits with no colon (0848). Raw ISO stamps, HH:MM, microseconds, offsets
|
||
and a poster's IP address never reach visible text. The exact stored value
|
||
stays available in each <time>'s `datetime`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import time
|
||
|
||
import pytest
|
||
|
||
from booth.marks import answer_pick, declare_pick, marks_for, write_note
|
||
|
||
PACIFIC = "America/Los_Angeles"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def pacific():
|
||
"""Pin the zone: `clock` renders local time, and this box's local time is
|
||
the operator's. A test that inherited the runner's zone would pass or fail by
|
||
where it ran."""
|
||
old = os.environ.get("TZ")
|
||
os.environ["TZ"] = PACIFIC
|
||
time.tzset()
|
||
yield
|
||
if old is None:
|
||
os.environ.pop("TZ", None)
|
||
else:
|
||
os.environ["TZ"] = old
|
||
time.tzset()
|
||
|
||
|
||
def _now():
|
||
return time.mktime((2026, 9, 28, 12, 0, 0, 0, 0, -1))
|
||
|
||
|
||
@pytest.fixture
|
||
def client(tmp_path):
|
||
from fastapi.testclient import TestClient
|
||
|
||
from booth.app import create_app
|
||
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
|
||
|
||
|
||
def _text(fragment: str) -> str:
|
||
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", fragment)).strip()
|
||
|
||
|
||
HHMM = re.compile(r"\b\d{1,2}:\d{2}\b")
|
||
ISO = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}")
|
||
IP = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b|::1\b")
|
||
HOUSE = re.compile(r"\b\d{1,2} [A-Z][a-z]{2}(?: \d{4})? \d{4}\b")
|
||
|
||
|
||
# ---- the filters ---------------------------------------------------------------
|
||
|
||
def test_clock_forms():
|
||
from booth.app import clock
|
||
now = _now()
|
||
assert clock("2026-09-28T08:48:20.478024-07:00", now=now) == "28 Sep 0848"
|
||
assert clock("2026-09-28T15:48:20+00:00", now=now) == "28 Sep 0848" # converted to local
|
||
assert clock("2026-09-28T08:48:20", now=now) == "28 Sep 0848" # naive = local
|
||
assert clock("2026-09-06 23:35", now=now) == "6 Sep 2335" # the board's rows
|
||
assert clock("2025-09-06 23:35", now=now) == "6 Sep 2025 2335" # another year says so
|
||
assert clock(time.mktime((2026, 9, 28, 8, 48, 0, 0, 0, -1)), now=now) == "28 Sep 0848"
|
||
assert clock("", now=now) == "" and clock(None, now=now) == ""
|
||
assert clock("2026-09-28", now=now) == "28 Sep" # a date has no clock: no 0000
|
||
assert clock("2026-W39-1", now=now) == "21 Sep" # nor does an ISO week
|
||
|
||
|
||
def test_clock_never_raises():
|
||
"""The Desk and the board render many rows in ONE response: one bad stamp
|
||
must cost its own text, never the page. What cannot be read is shown as
|
||
given, never guessed."""
|
||
from booth.app import clock
|
||
for bad in ("not a time", "2026-13-45T99:99", "99999-01-01T00:00:00", 1e20, 10 ** 400, -(10 ** 400),
|
||
float("nan"), float("inf"), True, [1]):
|
||
assert isinstance(clock(bad, now=_now()), str)
|
||
assert clock("not a time", now=_now()) == "not a time"
|
||
assert clock("2026-13-45T99:99", now=_now()) == "2026-13-45T99:99"
|
||
|
||
|
||
def test_byline_hides_addresses():
|
||
from booth.app import byline
|
||
for addr in ("127.0.0.1", "10.100.10.5", "::1", "2001:db8::1", "10.100.10.5:443", "10.100.10.5/32",
|
||
"[2001:db8::1]:443", "\u200b10.100.10.5", " 127.0.0.1 "):
|
||
assert byline(addr) == "", addr
|
||
assert byline("design-dev") == "design-dev" and byline("host:8080") == "host:8080"
|
||
assert byline("") == "" and byline(None) == ""
|
||
|
||
|
||
def test_date_stamp_is_house_form():
|
||
from booth.app import date_stamp
|
||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{4}", date_stamp(_now())), date_stamp(_now())
|
||
assert date_stamp(_now()) == "2026-09-28 1200"
|
||
|
||
|
||
# ---- where they apply ------------------------------------------------------------
|
||
|
||
def _answered_booth(data):
|
||
b = data / "b"
|
||
b.mkdir()
|
||
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
|
||
answer_pick(b, "p1", marks_for(b)[0].options[0]["id"], who="127.0.0.1")
|
||
write_note(b, None, "about the booth", who="10.100.10.5")
|
||
return b
|
||
|
||
|
||
def _whens(html):
|
||
return [_text(w) for w in re.findall(r'<span class="mark-when">(.*?)</span>', html, flags=re.S)]
|
||
|
||
|
||
@pytest.mark.parametrize("path", ["/b/b/marks", "/b/b/"])
|
||
def test_rendered_marks_use_the_house_clock(client, path):
|
||
c, data = client
|
||
_answered_booth(data)
|
||
html = c.get(path).text
|
||
whens = _whens(html)
|
||
assert whens, f"{path} renders no mark line at all (positive control)"
|
||
for w in whens:
|
||
assert HOUSE.search(w), w
|
||
assert not (HHMM.search(w) or ISO.search(w) or IP.search(w)), w
|
||
visible = _text(re.sub(r"<(script|style)\b.*?</\1>", " ", html, flags=re.S))
|
||
assert "127.0.0.1" not in html and "10.100.10.5" not in html, "no address anywhere, attributes included"
|
||
stored = {m.created for m in marks_for(data / "b")} | {m.answer["answered_at"] for m in marks_for(data / "b") if m.answer}
|
||
carried = set(re.findall(r'<time datetime="([^"]+)"', html))
|
||
assert stored & carried, "the exact stored stamp rides in datetime="
|
||
|
||
|
||
def test_embed_fragment_uses_the_house_clock(client):
|
||
c, data = client
|
||
_answered_booth(data)
|
||
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
|
||
# The SUBMIT block's tag carries the stamp (the per-question tag says only
|
||
# "✓ answered"); the embed places it from `submit`.
|
||
tag = _text(re.search(r'<span class="bk-ask-tag">(.*?)</span>', m["submit"], flags=re.S).group(1))
|
||
assert tag.startswith("✓ answered"), tag
|
||
assert HOUSE.search(tag) and not (ISO.search(tag) or HHMM.search(tag)), tag
|
||
|
||
|
||
def test_board_rows_use_the_house_clock(client):
|
||
from booth.links import LINKS_FILE
|
||
c, data = client
|
||
board = data / "links"
|
||
board.mkdir()
|
||
(board / LINKS_FILE).write_text("- [Booth](http://x/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n"
|
||
"- [Other](http://y/) <sub>· 10.0.0.5 · 2026-09-06 23:36</sub>\n")
|
||
html = c.get("/b/links/").text
|
||
whens = [_text(w) for w in re.findall(r'<span class="board-when">(.*?)</span>', html, flags=re.S)]
|
||
assert "10.0.0.5" not in html, "a board row's author is bylined like every other"
|
||
assert len(whens) == 2 and all(HOUSE.search(w) and not HHMM.search(w) for w in whens), whens
|
||
assert "6 Sep 2335" in whens, whens
|
||
assert 'datetime="2026-09-06 23:35"' in html
|
||
|
||
|
||
def test_a_malformed_answer_costs_its_line_not_the_page(client):
|
||
"""One stored answer missing `unanswered` must not 500 the booth or marks
|
||
page (heid bug-hunt Q3). REFUTED as a live bug: the Booth's Jinja uses the
|
||
default `Undefined`, whose `|length` is 0. Kept as a guard: switching the
|
||
environment to StrictUndefined would turn this red."""
|
||
import json as _json
|
||
c, data = client
|
||
b = data / "b"
|
||
b.mkdir()
|
||
(b / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
|
||
doc = _json.loads((b / ".marks.json").read_text())
|
||
doc["marks"][0]["answer"] = {"answers": []}
|
||
(b / ".marks.json").write_text(_json.dumps(doc))
|
||
for url in ("/b/b/", "/b/b/marks"):
|
||
assert c.get(url).status_code == 200, url
|
||
|
||
|
||
# ---- S2: legibility ----------------------------------------------------------------
|
||
# Faded is not legible; labels >= 11px (--size-micro); sentence-like lines >= 12px
|
||
# (--size-caption). These read the shipped CSS, and the arrow test composites the
|
||
# colours with INDEPENDENT maths (OKLCH -> sRGB -> WCAG), not with anything the
|
||
# page computes.
|
||
|
||
import math as _math
|
||
from pathlib import Path as _Path
|
||
|
||
_T = _Path(__file__).resolve().parent.parent / "booth" / "templates"
|
||
_EMBED = _Path(__file__).resolve().parent.parent / "booth" / "static" / "embed.js"
|
||
|
||
|
||
def _rule(css: str, selector: str) -> str:
|
||
m = re.search(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||
assert m, f"no rule for {selector!r}"
|
||
return m.group(1)
|
||
|
||
|
||
def _oklch_srgb(l, c, h):
|
||
a, b = c * _math.cos(_math.radians(h)), c * _math.sin(_math.radians(h))
|
||
l_, m_, s_ = (l + 0.3963377774 * a + 0.2158037573 * b, l - 0.1055613458 * a - 0.0638541728 * b,
|
||
l - 0.0894841775 * a - 1.2914855480 * b)
|
||
L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
|
||
lin = (4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
|
||
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
|
||
-0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S)
|
||
enc = lambda v: 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
|
||
return [max(0.0, min(1.0, enc(v))) for v in lin]
|
||
|
||
|
||
def _lum(rgb):
|
||
f = lambda v: v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
||
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2])
|
||
|
||
|
||
def _oklch(decl: str, prop: str):
|
||
m = re.search(prop + r"\s*:\s*oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*/\s*([\d.]+))?\)", decl)
|
||
assert m, f"{prop} is not an oklch() colour in {decl!r}"
|
||
return _oklch_srgb(*map(float, m.groups()[:3])), float(m.group(4) or 1)
|
||
|
||
|
||
def test_review_arrows_hold_over_a_white_stage():
|
||
"""The worst stage is white: composite the chip over it in sRGB, as the
|
||
browser does, and measure the glyph against the result."""
|
||
decl = _rule((_T / "view.html").read_text(), ".vnav")
|
||
glyph, _ = _oklch(decl, r"(?<![-\w])color")
|
||
chip, alpha = _oklch(decl, "background")
|
||
under = [alpha * c + (1 - alpha) * 1.0 for c in chip]
|
||
a, b = _lum(glyph), _lum(under)
|
||
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
|
||
assert ratio >= 7, f"arrow glyph over the chip over white: {ratio:.2f}:1"
|
||
|
||
|
||
def _px(decl: str) -> float:
|
||
m = re.search(r"font(?:-size)?\s*:[^;]*?(?:(\d+(?:\.\d+)?)px|var\(--size-(micro|caption|sm|body)\))", decl)
|
||
assert m, decl
|
||
return float(m.group(1)) if m.group(1) else {"micro": 11, "caption": 12, "sm": 13, "body": 14}[m.group(2)]
|
||
|
||
|
||
def test_film_numbers_meet_the_label_floor():
|
||
assert _px(_rule((_T / "base.html").read_text(), ".film-ord")) >= 11
|
||
|
||
|
||
def test_mark_state_meets_the_label_floor():
|
||
assert _px(_rule((_T / "base.html").read_text(), ".mark-state")) >= 11
|
||
|
||
|
||
@pytest.mark.parametrize("selector", [".desk-rule", ".board-note", ".bench-note"])
|
||
def test_hint_lines_meet_the_sentence_floor(selector):
|
||
css = (_T / "base.html").read_text()
|
||
decls = [m.group(1) for m in re.finditer(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)]
|
||
sizes = [_px(d) for d in decls if "font" in d]
|
||
assert sizes and min(sizes) >= 12, (selector, sizes)
|
||
|
||
|
||
def test_retired_benches_are_not_faded():
|
||
css = (_T / "base.html").read_text()
|
||
for m in re.finditer(r"([^{}]*is-retired[^{}]*)\{([^}]*)\}", css):
|
||
assert "opacity" not in m.group(2), m.group(0)
|
||
|
||
|
||
def _js_rule(js: str, selector: str) -> str:
|
||
"""embed.js carries its sheet as JS string literals: `".sel{...}"`."""
|
||
m = re.search(r'"' + re.escape(selector) + r"\{([^}]*)\}", js)
|
||
assert m, f"no rule for {selector!r} in embed.js"
|
||
return m.group(1)
|
||
|
||
|
||
def test_embed_fades_nothing():
|
||
js = _EMBED.read_text()
|
||
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
|
||
decl = _js_rule(js, sel)
|
||
assert "opacity" not in decl, (sel, decl)
|
||
assert not re.search(r'"\.bk-ask-was b\{', js), "a child restoring opacity means the parent fades"
|
||
ph = _js_rule(js, ".bk-ask-notes::placeholder")
|
||
assert "color:inherit" in ph.replace(" ", ""), ph
|
||
|
||
|
||
def test_the_ask_tag_meets_the_label_floor():
|
||
"""The inline ask's state tag is a label (`? your pick`, `✓ answered 28 Sep 0848`).
|
||
Its sheet is split over JS string literals, so read the font line that follows
|
||
the selector."""
|
||
js = _EMBED.read_text()
|
||
i = js.index('".bk-ask-tag{')
|
||
m = re.search(r"font:\d+ (\d+(?:\.\d+)?)px", js[i:i + 400])
|
||
assert m and float(m.group(1)) >= 11, m and m.group(0)
|
||
|
||
|
||
|
||
# ---- S6: the operator's rulings ------------------------------------------------------
|
||
|
||
def test_the_tagline_is_a_sentence(client):
|
||
c, data = client
|
||
html = c.get("/").text
|
||
m = re.search(r'<span class="tagline">(.*?)</span>', html, flags=re.S)
|
||
assert m and _text(m.group(1)) == "held for review · wipes in 24h unless kept", m and m.group(1)
|
||
# the BASE rule (the phone rule `body.page-stage .topbar .tagline` is S3's)
|
||
decl = re.search(r"\n\s*\.tagline\{([^}]*)\}", (_T / "base.html").read_text()).group(1)
|
||
assert "uppercase" not in decl and _px(decl) >= 12, decl
|
||
|
||
|
||
def test_needs_you_rows_carry_no_side_stripe():
|
||
decl = _rule((_T / "base.html").read_text(), ".desk-row.is-needs")
|
||
assert "inset" not in decl, decl
|
||
|
||
|
||
def test_the_brand_dot_is_matte():
|
||
decl = _rule((_T / "base.html").read_text(), ".brand .dot")
|
||
assert "box-shadow" not in decl, decl
|
||
|
||
|
||
# ---- S5a: accessibility plumbing (markup and CSS) --------------------------------
|
||
# Written after the code: each test is proved by its row in antislop.toml, which
|
||
# runs the change it forbids and must see it fail.
|
||
|
||
import html as _html
|
||
|
||
|
||
def _s5_booth(data):
|
||
"""A booth that renders every control the S5a claims cover: a doc, a file, an
|
||
image with a note and a flag, a titled single-question ask, and a board."""
|
||
from booth.marks import set_flag
|
||
b = data / "b"
|
||
b.mkdir()
|
||
(b / "notes.md").write_text("# Notes\n\ntext\n")
|
||
(b / "kit.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
|
||
(b / "a.png").write_bytes(_PNG)
|
||
(b / "c.png").write_bytes(_PNG)
|
||
set_flag(b, "a.png", True)
|
||
write_note(b, "a.png", "soft edges")
|
||
write_note(b, None, "about the booth")
|
||
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
|
||
# S5a fixup: a multi-question ask with per-question notes, and keys that end
|
||
# like the ids S5a derives from them (`-prompt`, `title`)
|
||
declare_pick(b, "p2", {"title": "Round two", "questions": [
|
||
{"key": "x-prompt", "prompt": "First?", "options": ["keep", "cut"], "notes": True},
|
||
{"key": "x", "prompt": "Second?", "options": ["keep", "cut"], "notes": True},
|
||
{"key": "title", "prompt": "Third?", "options": ["keep", "cut"]}]})
|
||
_s5_board(data)
|
||
return b
|
||
|
||
|
||
def _s5_board(data):
|
||
"""The link board, with a row and a bench: its controls are named too."""
|
||
from booth.benches import upsert_bench
|
||
from booth.links import LINKS_FILE
|
||
(data / "links").mkdir()
|
||
(data / "links" / LINKS_FILE).write_text("- [Booth](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
|
||
upsert_bench(data, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev")
|
||
|
||
|
||
_PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xaeB`\x82")
|
||
|
||
|
||
def _pages(c):
|
||
return {u: c.get(u).text for u in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png",
|
||
"/b/b/marks", "/b/b/view?f=notes.md", "/b/links/")}
|
||
|
||
|
||
def _markup(page):
|
||
"""The page's own markup: script and style bodies are strings and comments
|
||
are prose (the stylesheet's comments name `<textarea>`), not elements."""
|
||
page = re.sub(r"<(script|style)\b.*?</\1>", " ", page, flags=re.S)
|
||
return re.sub(r"<!--.*?-->", " ", page, flags=re.S)
|
||
|
||
|
||
def _name(attrs, inner):
|
||
"""The accessible name, as far as markup decides it: aria-label wins, else
|
||
the text, counting each image's alt (an <img alt="a.png"> names its link)."""
|
||
m = re.search(r'aria-label="([^"]*)"', attrs)
|
||
if m:
|
||
return _html.unescape(m.group(1))
|
||
inner = re.sub(r'<img\b[^>]*\balt="([^"]*)"[^>]*>', r" \1 ", inner)
|
||
return _html.unescape(_text(inner))
|
||
|
||
|
||
def _controls(page):
|
||
for m in re.finditer(r"<(a|button)\b([^>]*)>(.*?)</\1>", _markup(page), flags=re.S):
|
||
if 'aria-hidden="true"' in m.group(2):
|
||
continue # a deliberate duplicate (the Desk's thumbnail link) is not a control
|
||
yield m.group(2), _name(m.group(2), m.group(3))
|
||
|
||
|
||
def test_every_control_has_a_word_for_a_name(client):
|
||
"""A control a screen reader announces as "×", "↗" or "01" has no name. Every
|
||
link and button's name, as the markup decides it, carries a word."""
|
||
c, data = client
|
||
_s5_booth(data)
|
||
for url, page in _pages(c).items():
|
||
seen = 0
|
||
for attrs, name in _controls(page):
|
||
seen += 1
|
||
assert re.search(r"[A-Za-z]", name), f"{url}: a control named {name!r}: {attrs[:120]}"
|
||
assert seen, f"{url}: no control found (positive control)"
|
||
|
||
|
||
def test_desk_row_controls_name_their_booth(client):
|
||
c, data = client
|
||
for n in ("alpha", "beta"):
|
||
(data / n).mkdir()
|
||
(data / n / "x.txt").write_text("x")
|
||
labels = re.findall(r'aria-label="(wipe the (?:kept )?booth [^"]+)"', c.get("/").text)
|
||
assert len(labels) == 2 and len(set(labels)) == 2 and any("alpha" in s for s in labels), labels
|
||
|
||
|
||
def test_fields_are_named(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
pages = _pages(c)
|
||
pages["embed"] = " ".join(m["whole"] + m["submit"] for m in c.get("/b/b/embed.json").json()["marks"])
|
||
for url, page in pages.items():
|
||
for m in re.finditer(r"<(textarea|input)\b([^>]*)>", _markup(page), flags=re.S):
|
||
attrs = m.group(2)
|
||
if re.search(r'type="(hidden|radio|checkbox|submit)"', attrs):
|
||
continue
|
||
ok = "aria-label=" in attrs or re.search(r'id="([^"]+)"', attrs) and \
|
||
re.search(r'<label[^>]*for="%s"' % re.escape(re.search(r'id="([^"]+)"', attrs).group(1)), page)
|
||
assert ok, f"{url}: an unnamed field: {attrs[:120]}"
|
||
|
||
|
||
def test_radio_groups_are_named_and_ids_are_unique(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
|
||
# The embed places an ask one of two ways: `whole` (title, questions and
|
||
# submit in one piece), or its questions one by one plus `submit`. Never both.
|
||
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
|
||
for how, frag in placements.items():
|
||
groups = re.findall(r'role="radiogroup" aria-labelledby="([^"]+)"', frag)
|
||
assert groups and all(f'id="{g}"' in frag for g in groups), (how, groups)
|
||
# the ASK ids are this slice's (a titled single ask emitted `bk-ask-<id>`
|
||
# twice). Mark ids (`mark-note-1`) also repeat across the tile and the
|
||
# aside; the CLI prints `#mark-<id>` links to them, so that one is
|
||
# reported, not changed here.
|
||
ids = re.findall(r'\bid="(bk-ask-[^"]+)"', frag)
|
||
dupes = {i for i in ids if ids.count(i) > 1}
|
||
assert ids and not dupes, (how, f"duplicate ask ids {sorted(dupes)[:5]}")
|
||
marks = c.get("/b/b/marks").text
|
||
for fs in re.findall(r"<fieldset\b.*?</fieldset>", marks, flags=re.S):
|
||
if 'type="radio"' in fs:
|
||
assert "<legend" in fs, fs[:160]
|
||
|
||
|
||
def test_every_page_has_one_h1_and_a_skip_link(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
for url in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/marks"):
|
||
page = re.sub(r'<article class="markdown-body.*?</article>', " ", c.get(url).text, flags=re.S) # a doc's own h1 is content
|
||
assert len(re.findall(r"<h1\b", page)) == 1, (url, len(re.findall(r"<h1\b", page)))
|
||
assert re.search(r'<a class="skip-link" href="#main">', page) and 'id="main"' in page, url
|
||
|
||
|
||
def test_theme_color_for_both_schemes(client):
|
||
c, _ = client
|
||
page = c.get("/").text
|
||
for scheme in ("light", "dark"):
|
||
assert re.search(r'<meta name="theme-color" media="\(prefers-color-scheme: %s\)" content="#[0-9a-f]{6}">' % scheme, page), scheme
|
||
|
||
|
||
def test_the_tape_is_one_picture(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
page = c.get("/b/b/view?f=a.png").text
|
||
assert re.search(r'<div class="tape" data-region="tape" role="img" aria-label="', page)
|
||
segs = re.findall(r'<a class="tape-s[^>]*>', page, flags=re.S)
|
||
assert segs and all('tabindex="-1"' in s and 'aria-hidden="true"' in s for s in segs), segs[:2]
|
||
|
||
|
||
def test_wipe_now_asks_by_name(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
page = c.get("/b/b/").text
|
||
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
|
||
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
|
||
assert "var WORDS = Object.create(null);" in page, "the shared confirm helper is on the booth page"
|
||
|
||
|
||
def test_human_dur_rolls_up_to_days():
|
||
from booth.app import human_dur
|
||
assert human_dur(47 * 3600) == "47h"
|
||
assert human_dur(48 * 3600) == "2d"
|
||
assert human_dur(167 * 3600 + 12 * 60) == "6d 23h"
|
||
|
||
|
||
def test_embed_chrome_draws_its_own_focus_rings():
|
||
js = _EMBED.read_text()
|
||
assert re.search(r'"\.booth-nav-home:focus-visible,\.booth-nav-asks:focus-visible\{outline:2px solid', js)
|
||
assert re.search(r'"\.bk-ask-go:focus-visible\{outline:2px solid', js)
|
||
assert re.search(r'"\.bk-ask-opt:has\(input:focus-visible\)\{outline:2px solid', js)
|
||
|
||
|
||
def test_focus_rings_are_drawn_inside_clipping_containers():
|
||
css = (_T / "base.html").read_text()
|
||
m = re.search(r"\.theme button:focus-visible,\.vtoggle button:focus-visible,\.item a:focus-visible,\s*\.desk-panel a:focus-visible\{outline-offset:-2px\}", css)
|
||
assert m
|
||
|
||
|
||
def test_a_truncated_why_carries_its_full_text(client):
|
||
c, data = client
|
||
b = data / "w"
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
|
||
page = c.get("/").text
|
||
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
|
||
|
||
|
||
|
||
# ---- S5a fixup: booth-dev's gate (hulda bug-hunt + heid BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469) ----
|
||
|
||
def test_no_id_repeats_on_any_page(client):
|
||
"""An id names one element. The tile's copy of a note used to repeat the
|
||
panel article's `mark-<id>` (booth-dev: the id is the article's); a question
|
||
key ending in `-prompt`, or named `title`, repeated the ids S5a derived."""
|
||
c, data = client
|
||
_s5_booth(data)
|
||
pages = _pages(c)
|
||
for m in c.get("/b/b/embed.json").json()["marks"]:
|
||
pages[f"embed {m['id']} whole"] = m["whole"]
|
||
pages[f"embed {m['id']} parts"] = "".join(q["html"] for q in m["questions"]) + m["submit"]
|
||
for url, page in pages.items():
|
||
ids = re.findall(r'\sid="([^"]+)"', _markup(page))
|
||
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
||
assert not dupes, (url, dupes[:5])
|
||
for ref in re.findall(r'aria-labelledby="([^"]+)"', page):
|
||
assert f'id="{ref}"' in page, (url, ref)
|
||
|
||
|
||
def test_release_on_the_booth_page_asks_by_name(client):
|
||
c, data = client
|
||
b = data / "k"
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
(b / ".forever").write_text("")
|
||
page = c.get("/b/k/").text
|
||
form = re.search(r'<form class="keep-lg"[^>]*>', page).group(0)
|
||
assert 'data-booth="k"' in form and 'data-confirm="release"' in form, form
|
||
|
||
|
||
def test_the_confirm_helper_is_listening_before_the_body_exists(client):
|
||
"""A click while the page is still loading must be asked too: the capture
|
||
listener is registered in <head>, not after the footer."""
|
||
c, data = client
|
||
_s5_booth(data)
|
||
for url in ("/", "/b/b/"):
|
||
page = c.get(url).text
|
||
assert page.index("function shown(n)") < page.index("</head>"), url
|
||
|
||
|
||
def test_human_dur_never_raises():
|
||
from booth.app import human_dur
|
||
for bad in (float("nan"), float("inf"), float("-inf")):
|
||
assert isinstance(human_dur(bad), str), bad
|
||
|
||
|
||
# ---- S5b: one status line per page, never hidden --------------------------------
|
||
|
||
def test_one_status_line_per_page(client):
|
||
"""Exactly one `data-region="status"` on every kind of page, never `hidden`,
|
||
and inside no other `data-region` (a swap would replace it mid-message)."""
|
||
from html.parser import HTMLParser
|
||
|
||
from booth.links import LINKS_FILE
|
||
c, data = client
|
||
_s5_booth(data)
|
||
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
|
||
|
||
class Lines(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.stack, self.found = [], []
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
a = dict(attrs)
|
||
if a.get("data-region") == "status":
|
||
self.found.append((tag, a, [r for r in self.stack if r]))
|
||
if tag not in void:
|
||
self.stack.append(a.get("data-region"))
|
||
|
||
def handle_endtag(self, tag):
|
||
if tag not in void and self.stack:
|
||
self.stack.pop()
|
||
|
||
assert (data / "links" / LINKS_FILE).exists()
|
||
for url, page in _pages(c).items():
|
||
p = Lines()
|
||
p.feed(page)
|
||
assert len(p.found) == 1, (url, len(p.found))
|
||
tag, attrs, outer = p.found[0]
|
||
assert "hidden" not in attrs, url
|
||
assert attrs.get("role") == "status" and attrs.get("aria-live") == "polite", (url, attrs)
|
||
assert not outer, (url, outer)
|
||
|
||
|
||
def test_the_in_place_client_can_read_every_page(client):
|
||
"""What the in-place client assumes about the pages it swaps (heid bug-hunt
|
||
R7, R11, accepted as true today and pinned here): no in-place form holds a
|
||
control `dirty()` cannot read (a <select>, or an input that is not text,
|
||
radio, checkbox, hidden or submit), and no `data-region` sits inside another
|
||
(a nested region is replaced inside a detached tree)."""
|
||
from html.parser import HTMLParser
|
||
c, data = client
|
||
_s5_booth(data)
|
||
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
|
||
|
||
class Pages(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.stack, self.inplace, self.odd, self.nested = [], 0, [], []
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
a = dict(attrs)
|
||
if tag == "form" and "data-inplace" in a:
|
||
self.inplace += 1
|
||
if self.inplace and (tag == "select" or (tag == "input" and a.get("type", "text")
|
||
not in ("hidden", "radio", "checkbox", "text", "submit"))):
|
||
self.odd.append((tag, a.get("type")))
|
||
if a.get("data-region") and any(self.stack):
|
||
self.nested.append(a["data-region"])
|
||
if tag not in void:
|
||
self.stack.append(a.get("data-region"))
|
||
|
||
def handle_endtag(self, tag):
|
||
if tag == "form" and self.inplace:
|
||
self.inplace -= 1
|
||
if tag not in void and self.stack:
|
||
self.stack.pop()
|
||
|
||
for url, page in _pages(c).items():
|
||
p = Pages()
|
||
p.feed(page)
|
||
assert not p.odd, (url, p.odd)
|
||
assert not p.nested, (url, p.nested)
|
||
|
||
|
||
# ---- S5c -----------------------------------------------------------------------------
|
||
|
||
def _figure(page, rel):
|
||
"""One tile's markup, by its data-item."""
|
||
m = re.search(r'<figure\b[^>]*data-item="' + re.escape(rel) + r'".*?</figure>', _markup(page), re.S)
|
||
assert m, f"no tile for {rel}"
|
||
return m.group(0)
|
||
|
||
|
||
def test_the_doc_summary_holds_no_controls(client):
|
||
"""G7: a <summary> is one button to a screen reader, and a <form> is not
|
||
valid inside one. The doc's summary holds its label; the controls sit in a
|
||
sibling toolbar."""
|
||
c, data = client
|
||
_s5_booth(data)
|
||
tile = _figure(c.get("/b/b/").text, "notes.md")
|
||
labels = [s for s in re.findall(r"<summary\b[^>]*>(.*?)</summary>", tile, re.S) if "doc-name" in s]
|
||
assert len(labels) == 1, labels
|
||
assert not re.search(r"<(a|button|form|input|textarea)\b", labels[0]), labels[0]
|
||
tools = re.search(r'<div class="doc-tools">(.*?)</div>\s*</div>', tile, re.S)
|
||
assert tools, "no doc toolbar"
|
||
t = tools.group(1)
|
||
assert 'href="view?f=notes.md"' in t and 'download' in t, t
|
||
assert 'class="blurtoggle' in t and 'class="flagtoggle' in t and "doc-close" in t, t
|
||
|
||
|
||
def _image(path, size, exif_orientation=None, fmt="PNG"):
|
||
from PIL import Image
|
||
im = Image.new("RGB", size, (90, 120, 160))
|
||
if exif_orientation is None:
|
||
im.save(path, fmt)
|
||
else:
|
||
exif = Image.Exif()
|
||
exif[0x0112] = exif_orientation
|
||
im.save(path, fmt, exif=exif)
|
||
|
||
|
||
def _img_size(tile):
|
||
img = re.search(r"<img\b[^>]*>", tile).group(0)
|
||
w, h = re.search(r'\bwidth="(\d+)"', img), re.search(r'\bheight="(\d+)"', img)
|
||
return (int(w.group(1)), int(h.group(1))) if w and h else None
|
||
|
||
|
||
def test_tile_images_carry_their_drawn_size(client):
|
||
"""G14: the picture's size as the browser draws it, so a lazy tile reserves
|
||
its box before it loads. Orientations 5-8 swap the numbers. Anything whose
|
||
header cannot be read safely gets no size, which is today's markup."""
|
||
pytest.importorskip("PIL.Image")
|
||
c, data = client
|
||
b = data / "g"
|
||
b.mkdir()
|
||
_image(b / "wide.png", (300, 200))
|
||
_image(b / "turned.jpg", (300, 200), exif_orientation=6, fmt="JPEG")
|
||
(b / "broken.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||
(b / "pic.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>')
|
||
(b / "link.png").symlink_to(b / "wide.png")
|
||
page = c.get("/b/g/").text
|
||
got = {r: _img_size(_figure(page, r)) for r in ("wide.png", "turned.jpg", "broken.png", "pic.svg", "link.png")}
|
||
assert got == {"wide.png": (300, 200), "turned.jpg": (200, 300), "broken.png": None,
|
||
"pic.svg": None, "link.png": None}, got
|
||
|
||
|
||
def test_reading_a_size_decodes_nothing(tmp_path, monkeypatch):
|
||
"""G14: the header only. Pillow's PNG getexif() decodes the whole picture to
|
||
look for a late eXIf chunk; a gallery render must not."""
|
||
Image = pytest.importorskip("PIL.Image")
|
||
from PIL import ImageFile
|
||
from booth import thumbs
|
||
_image(tmp_path / "a.png", (300, 200))
|
||
_image(tmp_path / "b.jpg", (300, 200), exif_orientation=6, fmt="JPEG")
|
||
|
||
def boom(self, *a, **k):
|
||
raise AssertionError("decoded")
|
||
monkeypatch.setattr(Image.Image, "load", boom)
|
||
monkeypatch.setattr(ImageFile.ImageFile, "load", boom)
|
||
thumbs._read_size.cache_clear()
|
||
assert thumbs.drawn_size(tmp_path / "a.png") == (300, 200)
|
||
assert thumbs.drawn_size(tmp_path / "b.jpg") == (200, 300)
|
||
|
||
|
||
_REVEAL = re.compile(r'<button\b(?=[^>]*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)</button>', re.S)
|
||
|
||
|
||
def test_reveal_glyphs_are_hidden_from_the_name(client):
|
||
"""G17: a reveal control's name is the words on it. No aria-label to
|
||
contradict the words after a flip, no aria-pressed on a control whose words
|
||
already say its state, and no glyph read aloud."""
|
||
from booth.app import set_blurred
|
||
c, data = client
|
||
b = _s5_booth(data)
|
||
for rel in ("a.png", "c.png", "notes.md"):
|
||
set_blurred(b, rel, True)
|
||
seen = 0
|
||
for url in ("/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/view?f=notes.md"):
|
||
for attrs, inner in _REVEAL.findall(_markup(c.get(url).text)):
|
||
seen += 1
|
||
assert "aria-label" not in attrs and "aria-pressed" not in attrs, (url, attrs)
|
||
bare = re.sub(r'<span\b[^>]*aria-hidden="true"[^>]*>.*?</span>', "", inner, flags=re.S)
|
||
assert not re.search("[\U0001F300-\U0001FAFF]", bare), (url, inner)
|
||
assert seen >= 7, seen # tile x3 (two images, the doc), review x2, compare x3, doc page x2
|
||
|
||
|
||
def test_a_planted_fifo_or_link_costs_its_size_and_never_hangs(tmp_path):
|
||
"""G14: opened O_NONBLOCK, a FIFO with no writer reads as empty instead of
|
||
holding the render forever; opened O_NOFOLLOW, a link is refused."""
|
||
import threading
|
||
pytest.importorskip("PIL.Image")
|
||
from booth.thumbs import drawn_size
|
||
_image(tmp_path / "a.png", (300, 200))
|
||
os.mkfifo(tmp_path / "pipe.png")
|
||
(tmp_path / "link.png").symlink_to(tmp_path / "a.png")
|
||
got = {}
|
||
t = threading.Thread(target=lambda: got.update(pipe=drawn_size(tmp_path / "pipe.png")), daemon=True)
|
||
t.start()
|
||
t.join(3)
|
||
assert not t.is_alive() and got == {"pipe": None}, got
|
||
assert drawn_size(tmp_path / "link.png") is None
|
||
assert drawn_size(tmp_path / "a.png") == (300, 200) # the positive control
|
||
|
||
|
||
def test_a_size_is_read_once_per_version_of_the_file(tmp_path, monkeypatch):
|
||
"""G14: cached by the file's identity, so a gallery render reads each
|
||
header once while it is unchanged, and a replaced file is read again."""
|
||
Image = pytest.importorskip("PIL.Image")
|
||
from booth import thumbs
|
||
_image(tmp_path / "a.png", (300, 200))
|
||
thumbs._read_size.cache_clear()
|
||
opened = []
|
||
real = Image.open
|
||
monkeypatch.setattr(thumbs._Image, "open", lambda *a, **k: (opened.append(1), real(*a, **k))[1])
|
||
first = [thumbs.drawn_size(tmp_path / "a.png") for _ in range(3)]
|
||
_image(tmp_path / "a.png", (120, 480))
|
||
os.utime(tmp_path / "a.png", ns=(1, 1))
|
||
again = thumbs.drawn_size(tmp_path / "a.png")
|
||
assert first == [(300, 200)] * 3 and again == (120, 480), (first, again)
|
||
assert len(opened) == 2, opened
|
||
|
||
|
||
def test_a_file_replaced_in_place_keeps_no_stale_size(tmp_path):
|
||
"""HRÖSKVA R7: `cp -p` over a file keeps its inode and restores its mtime,
|
||
and a same-length replacement kept its size too, so the cache served the
|
||
old picture's size. The change time moves on every write."""
|
||
pytest.importorskip("PIL.Image")
|
||
from PIL import PngImagePlugin
|
||
from booth import thumbs
|
||
|
||
def png(size, pad):
|
||
info = PngImagePlugin.PngInfo()
|
||
info.add_text("pad", "x" * pad)
|
||
from PIL import Image
|
||
import io
|
||
buf = io.BytesIO()
|
||
Image.new("RGB", size, (90, 120, 160)).save(buf, "PNG", pnginfo=info)
|
||
return buf.getvalue()
|
||
a, b = png((300, 200), 0), png((120, 480), 0)
|
||
a, b = (png((300, 200), max(0, len(b) - len(a))), png((120, 480), max(0, len(a) - len(b))))
|
||
assert len(a) == len(b), (len(a), len(b))
|
||
f = tmp_path / "a.png"
|
||
f.write_bytes(a)
|
||
st = os.stat(f)
|
||
thumbs._read_size.cache_clear()
|
||
first = thumbs.drawn_size(f)
|
||
time.sleep(0.01)
|
||
with open(f, "r+b") as fh: # in place: same inode, same length
|
||
fh.write(b)
|
||
os.utime(f, ns=(st.st_atime_ns, st.st_mtime_ns))
|
||
assert os.stat(f).st_ino == st.st_ino and os.stat(f).st_size == st.st_size
|
||
assert (first, thumbs.drawn_size(f)) == ((300, 200), (120, 480))
|