Files
booth/tests/test_antislop.py
T
vh 54f3531833 fix(as-S2): legibility — nothing fades, labels 11px, sentences 12px
From the anti-slop run (design-dev, 2026-09-28). Faded is not legible:
opacity divides whatever contrast a line had.

- Review arrows: the chip under the thin chevron is 82% dense, not 60%;
  the glyph now clears 7:1 over a white stage by colour (was 3.84:1), and
  reads at pixel level where the detector sampled a 2.9:1 median.
- Filmstrip numbers, the marks' state stamp and the inline ask's state tag
  are labels at 11px (were 9.5 / 10.5 / 10.5px).
- The Desk's section rules, the board note and the bench note are
  sentences at 12px.
- Retired benches: no opacity; the link and URL take --text-muted.
- Embed chrome: answered-ask details and the "recorded:" line inherit the
  host's text colour at full strength (the embed cannot know the host's
  palette); the notes placeholder inherits it at 75%, not the UA grey.

Folded from the heid bug-hunt: the embed's ask title no longer fades
either (Q9), and the legibility claims are also held on the browser's
COMPUTED style (tests/test_antislop_s2_browser.py): a stylesheet grep
cannot see a later rule in the cascade (font-size:1px, color:transparent,
filter:grayscale, a placeholder at opacity:0); the browser can.

Folded after the first gate run: the flagged tray's number, the tile's
"flagged" stamp and compare's A/B badge were still under the 11px label
floor; they take --size-micro too. Two film-number rows are re-anchored
on the .film-ord selector: the tray's line is now identical to it, and
the runner mutates the first match.

Contract: as_antislop S2. Falsifiers: antislop.toml 34/34 proved (S1+S2);
all 12 tables 314/314 proved on this tree.
2026-09-28 13:30:31 -07:00

286 lines
12 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)