fix(as-S1): the house clock — stamps read 0848, no IPs on the page
The anti-slop run (design-dev, 2026-09-28; operator: "start the fix slices") found raw ISO stamps with microseconds and offsets, the poster's IP address, and HH:MM in board rows and <time> tooltips. Operator convention 2026-09-24: a clock the operator reads is 24-hour local time as four digits, no colon. - `clock` filter: ISO (any precision, any offset), epoch, or the board's `YYYY-MM-DD HH:MM` -> `28 Sep 0848` local, year only when not this year's. Never raises; what it cannot read is shown as given. No regex (INV-3). - `byline` filter: a handle is shown, an IP address is not. Stored `by` and `answered_by` are unchanged (u2 still records the client host). - Applied to the marks' answer and memo lines, the inline ask's state tag (so the embed chrome inherits it) and the link board's row time, each in a <time> whose datetime= carries the stored value exactly. - `date_stamp` (the created/updated tooltips) renders `YYYY-MM-DD HHMM`. Folded from the heid bug-hunt (panel 4/4, thread 01M3MGPFKWBX0SJK5HFE0P3AFM): clock converts a number inside its guard (an int past float range raised, Q1); a date or ISO week renders no invented 0000 (Q8); byline also hides addr:port, [v6]:port, addr/prefix and addresses behind invisible characters (Q7); the board row's author is bylined (Q5). Refuted: Q3 (default Jinja Undefined has length 0; the test stays as a StrictUndefined guard). Accepted with reasons: Q4, Q6. Contract: docs/contracts/as_antislop.contract.md S1. Falsifiers: antislop.toml 15/15 proved (S1); all 12 tables 295/295 proved on this tree.
This commit is contained in:
+89
-1
@@ -37,6 +37,8 @@ import asyncio
|
|||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import ipaddress
|
||||||
|
import unicodedata
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -262,12 +264,96 @@ def date_iso(epoch: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def date_stamp(epoch: float) -> str:
|
def date_stamp(epoch: float) -> str:
|
||||||
|
"""The full stamp for a <time>'s title, in the house clock form
|
||||||
|
(operator, 2026-09-24): four digits, no colon — `2026-09-28 0848`."""
|
||||||
try:
|
try:
|
||||||
return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch))
|
return time.strftime("%Y-%m-%d %H%M", time.localtime(epoch))
|
||||||
except _BAD_DATE:
|
except _BAD_DATE:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# as_antislop S1 — THE house clock. A clock time the operator reads is local
|
||||||
|
# 24-hour time as four digits with no colon: `28 Sep 0848`. Marks and answers
|
||||||
|
# store ISO with microseconds and an offset (`now_stamp`), and `booth link`
|
||||||
|
# rows store `YYYY-MM-DD HH:MM`; this is the ONE place either becomes visible
|
||||||
|
# text. The stored value is untouched and rides in the <time>'s `datetime`.
|
||||||
|
|
||||||
|
|
||||||
|
def _as_epoch(value) -> float | None:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
# convert INSIDE the guard: `math.isfinite` on an int past float range
|
||||||
|
# raises OverflowError (heid bug-hunt Q1, 3 of 4 arms)
|
||||||
|
try:
|
||||||
|
f = float(value)
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
return f if math.isfinite(f) else None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# 3.11+ reads the board's `YYYY-MM-DD HH:MM` as well as full ISO, so
|
||||||
|
# no regex: app.py keeps its ONE (INV-3, test_no_regex_touches_author_html).
|
||||||
|
dt = datetime.fromisoformat(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# naive = local (the board's rows, and any stamp written without a zone)
|
||||||
|
return dt.timestamp()
|
||||||
|
except _BAD_DATE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clock(value, now: float | None = None) -> str:
|
||||||
|
"""`28 Sep 0848`, with the year only when it is not this year's
|
||||||
|
(`6 Sep 2025 2335`). NEVER RAISES: a value it cannot read is returned as
|
||||||
|
given — never a guess, and never a 500 for a page of rows. Empty is ""."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return ""
|
||||||
|
epoch = _as_epoch(value)
|
||||||
|
if epoch is None:
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
day = date_day(epoch, now)
|
||||||
|
if isinstance(value, str) and ":" not in value:
|
||||||
|
# a date (or an ISO week) carries no clock time: never print an invented 0000
|
||||||
|
return day or value
|
||||||
|
try:
|
||||||
|
hhmm = time.strftime("%H%M", time.localtime(epoch))
|
||||||
|
except _BAD_DATE:
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
return f"{day} {hhmm}" if day else (value if isinstance(value, str) else "")
|
||||||
|
|
||||||
|
|
||||||
|
def byline(who) -> str:
|
||||||
|
"""Who recorded a mark, as the operator should read it: a handle is shown,
|
||||||
|
an address is not. The u2 record keeps `request.client.host` as it always
|
||||||
|
has; an IP on the page is noise at best and a leak at worst."""
|
||||||
|
if not isinstance(who, str):
|
||||||
|
return ""
|
||||||
|
# invisible characters (zero-width, bidi, controls) cannot disguise an address
|
||||||
|
bare = "".join(ch for ch in who if unicodedata.category(ch) not in ("Cf", "Cc")).strip()
|
||||||
|
if not bare:
|
||||||
|
return ""
|
||||||
|
return "" if _is_address(bare) else who
|
||||||
|
|
||||||
|
|
||||||
|
def _is_address(s: str) -> bool:
|
||||||
|
"""An IP, bare or dressed as `addr:port`, `[v6]:port` or `addr/prefix`."""
|
||||||
|
candidates = {s, s.split("/", 1)[0]}
|
||||||
|
if s.startswith("[") and "]" in s:
|
||||||
|
candidates.add(s[1:s.index("]")])
|
||||||
|
if s.count(":") == 1:
|
||||||
|
candidates.add(s.split(":", 1)[0])
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(c)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def date_day(epoch: float, now: float | None = None) -> str:
|
def date_day(epoch: float, now: float | None = None) -> str:
|
||||||
"""'12 Sep', with the year only when it is not this year's; '' when the
|
"""'12 Sep', with the year only when it is not this year's; '' when the
|
||||||
calendar cannot hold it."""
|
calendar cannot hold it."""
|
||||||
@@ -1089,6 +1175,8 @@ def create_app(
|
|||||||
env.filters["stamp"] = date_stamp
|
env.filters["stamp"] = date_stamp
|
||||||
env.filters["day"] = date_day
|
env.filters["day"] = date_day
|
||||||
env.filters["ago"] = date_ago
|
env.filters["ago"] = date_ago
|
||||||
|
env.filters["clock"] = clock
|
||||||
|
env.filters["byline"] = byline
|
||||||
templates = Jinja2Templates(env=env)
|
templates = Jinja2Templates(env=env)
|
||||||
|
|
||||||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
|
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
|
||||||
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
||||||
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
|
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
|
||||||
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
|
||||||
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>
|
||||||
{%- else %}? submit your picks{% endif %}</span>
|
{%- else %}? submit your picks{% endif %}</span>
|
||||||
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
||||||
{% if a.notes_enabled %}
|
{% if a.notes_enabled %}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
|
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
|
||||||
<span class="board-spacer"></span>
|
<span class="board-spacer"></span>
|
||||||
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
||||||
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
{% if a.answer %}<span class="mark-when"><time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>{% if a.answer.answered_by|byline %} · {{ a.answer.answered_by|byline }}{% endif %}</span>{% endif %}
|
||||||
</header>
|
</header>
|
||||||
{% if a.error %}
|
{% if a.error %}
|
||||||
<p class="mark-error">This question could not be read: {{ a.error }}</p>
|
<p class="mark-error">This question could not be read: {{ a.error }}</p>
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||||
<span class="board-spacer"></span>
|
<span class="board-spacer"></span>
|
||||||
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
<span class="mark-when"><time datetime="{{ a.created }}">{{ a.created|clock }}</time>{% if a.by|byline %} · {{ a.by|byline }}{% endif %}</span>
|
||||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||||
|
|||||||
@@ -276,8 +276,8 @@
|
|||||||
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
|
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="board-meta">
|
<div class="board-meta">
|
||||||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}
|
||||||
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
|
{% if e.when %}<span class="board-when"><time datetime="{{ e.when }}">{{ e.when|clock }}</time></span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
||||||
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
contract_version: "0.1"
|
||||||
|
status: "PROPOSED 2026-09-28 by design-dev. The operator ordered the fix slices from the anti-slop run (booth `booth-antislop`, report `~/.local/share/design-dev/research/booth-antislop-2026-09-28.md`) in design-dev's session: \"go with your recommendations, push, start the fix slices\". Each slice is staged as its own ref (`design-dev/antislop-sN`) for booth-dev's gate: suite, mutation tables and a bug-hunt."
|
||||||
|
module: "the Booth's rendered surface: filters in booth/app.py, templates, booth/static/embed.js"
|
||||||
|
purpose: "Fix what the anti-slop run found (the Impeccable detector at 1280 and 390 in light and dark, plus a Vercel Web Interface Guidelines review), one slice at a time, without moving any invariant."
|
||||||
|
depends_on:
|
||||||
|
- "app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`."
|
||||||
|
- "marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`)."
|
||||||
|
- "links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`)."
|
||||||
|
language: "python + jinja"
|
||||||
|
complexity: "low per slice"
|
||||||
|
touches:
|
||||||
|
- "booth/app.py (filters)"
|
||||||
|
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
|
||||||
|
- "tests/test_antislop.py; tests/mutations/antislop.toml"
|
||||||
|
assumptions:
|
||||||
|
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
|
||||||
|
- "Stored data does not change shape. Every slice changes only what is RENDERED: `.marks.json`, `links.md` and the answer records keep their exact bytes."
|
||||||
|
- "Hardening of `render_doc` (raw HTML in docs) and front matter are booth-dev's, by agreement on 2026-09-28; this contract does not touch `render_doc`."
|
||||||
|
---
|
||||||
|
|
||||||
|
# The anti-slop fix slices
|
||||||
|
|
||||||
|
The run found that the Booth is sound on desktop and has a set of problems a viewer feels: clock times that break the house form, layouts that break at phone width, controls you can barely see in the light theme, and keyboard and screen-reader plumbing. The slices below fix them in an order that keeps each ref small enough to gate. Every slice keeps the six invariants (CLAUDE.md), and in particular:
|
||||||
|
- the server renders every state, and scripts only place it;
|
||||||
|
- autoescape stays on;
|
||||||
|
- every ordered surface keeps its stated order;
|
||||||
|
- blur honesty holds.
|
||||||
|
|
||||||
|
## S1 — the house clock
|
||||||
|
|
||||||
|
**The rule** (operator convention, 2026-09-24): a clock time the operator reads is 24-hour local time (US Pacific), written as four digits with no colon (`0848`). Raw ISO stamps, `HH:MM`, microseconds, offsets and a poster's IP address do not appear in visible text.
|
||||||
|
|
||||||
|
- **One filter decides the visible form: `clock`.**
|
||||||
|
- It takes an ISO-8601 string (with or without microseconds and an offset), an epoch number, or the link board's `YYYY-MM-DD HH:MM`.
|
||||||
|
- It returns `D Mon HHMM` in local time (for example `28 Sep 0848`), with the year after the month only when it is not the current year (`6 Sep 2025 2335`).
|
||||||
|
- A value it cannot read is returned **as given**, never a guess and never an exception: the Desk and the board render many rows in one response, and one bad stamp must not 500 the page. An empty value returns `""`.
|
||||||
|
- *Falsifiable:* a `clock` that formats `%H:%M` fails `test_clock_forms`. A `clock` that raises on garbage fails `test_clock_never_raises`.
|
||||||
|
- **One filter decides who is shown: `byline`.** It returns the recorded `by` / `answered_by` unless it parses as an IP address (v4 or v6), in which case it returns `""`. The stored value is unchanged; the u2 contract still records the client host.
|
||||||
|
- *Falsifiable:* a `byline` that passes IPs through fails `test_byline_hides_addresses`.
|
||||||
|
- **Where the filters apply.** Every visible stamp goes through `clock` and every byline through `byline`, and each clock sits in a `<time>` whose `datetime` carries the value exactly as stored:
|
||||||
|
- a pick's answer line and a memo's line (`_marks.html`);
|
||||||
|
- the inline ask's state tag (`_ask_inline.html`, so the embed chrome inherits it);
|
||||||
|
- the link board's row time (`booth.html`).
|
||||||
|
- *Falsifiable:* the marks page, the lightbox's verdict aside, the embed fragments and the board carry no visible `HH:MM`, no `T08:48`-shaped stamp and no IP: `test_rendered_marks_use_the_house_clock`, `test_board_rows_use_the_house_clock`, `test_embed_fragment_uses_the_house_clock`. Removing the filter from any one of those templates turns its test red.
|
||||||
|
- **The date tooltips follow suit.** `date_stamp` (the `title` of every created/updated `<time>`) renders `YYYY-MM-DD HHMM`.
|
||||||
|
- *Falsifiable:* `%H:%M` in `date_stamp` fails `test_date_stamp_is_house_form`.
|
||||||
|
|
||||||
|
- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MGPFKWBX0SJK5HFE0P3AFM`):
|
||||||
|
- `clock` converts a number inside its guard: an int past float range was an `OverflowError` (Q1).
|
||||||
|
- A date or an ISO week renders its day and no invented `0000` (Q8).
|
||||||
|
- `byline` also hides an address dressed as `addr:port`, `[v6]:port` or `addr/prefix`, or behind invisible characters (Q7).
|
||||||
|
- The board row's author goes through `byline` like every other surface (Q5).
|
||||||
|
- *Falsifiable:* the rows marked Q1, Q5, Q7 and Q8 in `antislop.toml`.
|
||||||
|
- **Refuted, with the reason:** a malformed answer missing `unanswered` does not 500 the marks panel (Q3). The Booth's Jinja uses the default `Undefined`, whose `|length` is 0; the no-op fix was reverted when its falsifier stayed green. `test_a_malformed_answer_costs_its_line_not_the_page` stays, as a guard against a switch to `StrictUndefined`.
|
||||||
|
- **Accepted as known risk, with reasons:**
|
||||||
|
- Zone-less mark stamps are read as local by `clock` and as UTC by the ordering path (Q4). No writer produces one: `now_stamp` and the legacy import both stamp with `.astimezone()`. Only a hand-edited file could.
|
||||||
|
- A board time inside the spring-forward gap renders the normalised hour (Q6). No clock can write a local time that does not exist.
|
||||||
|
|
||||||
|
**Out of S1:** the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; `clock` reads both forms.
|
||||||
|
|
||||||
|
## S2 to S6
|
||||||
|
|
||||||
|
These are added to this contract as each slice is staged, in the order of the report's plan:
|
||||||
|
- S2, legibility;
|
||||||
|
- S3, phone layouts;
|
||||||
|
- S4, reading measure;
|
||||||
|
- S5, interaction and screen readers;
|
||||||
|
- S6, the operator's rulings (tagline, needs-you stripe, matte brand dot, the Wipe-now stripe on `::before`).
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# The anti-slop fix slices: every falsifier the contract claims
|
||||||
|
# (docs/contracts/as_antislop.contract.md), and the change each forbids.
|
||||||
|
|
||||||
|
unit = "anti-slop fix slices (the house clock, and the slices after it)"
|
||||||
|
|
||||||
|
# ---- S1: the house clock
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 clock renders HH:MM"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_clock_forms"
|
||||||
|
old = ''' hhmm = time.strftime("%H%M", time.localtime(epoch))'''
|
||||||
|
new = ''' hhmm = time.strftime("%H:%M", time.localtime(epoch))'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 clock raises on a stamp it cannot read"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_clock_never_raises"
|
||||||
|
old = ''' if epoch is None:
|
||||||
|
return value if isinstance(value, str) else ""'''
|
||||||
|
new = ''' if epoch is None:
|
||||||
|
raise ValueError(value)'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 byline passes an address through"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_byline_hides_addresses"
|
||||||
|
old = ''' return "" if _is_address(bare) else who'''
|
||||||
|
new = ''' return who'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 the date tooltips keep HH:MM"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_date_stamp_is_house_form"
|
||||||
|
old = ''' return time.strftime("%Y-%m-%d %H%M", time.localtime(epoch))'''
|
||||||
|
new = ''' return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch))'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a pick's answer line shows the raw stamp"
|
||||||
|
file = "booth/templates/_marks.html"
|
||||||
|
test = "tests/test_antislop.py::test_rendered_marks_use_the_house_clock"
|
||||||
|
old = '''{{ a.answer.answered_at|clock }}'''
|
||||||
|
new = '''{{ a.answer.answered_at }}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a memo line shows the raw stamp"
|
||||||
|
file = "booth/templates/_marks.html"
|
||||||
|
test = "tests/test_antislop.py::test_rendered_marks_use_the_house_clock"
|
||||||
|
old = '''{{ a.created|clock }}'''
|
||||||
|
new = '''{{ a.created }}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a memo line shows the poster's address"
|
||||||
|
file = "booth/templates/_marks.html"
|
||||||
|
test = "tests/test_antislop.py::test_rendered_marks_use_the_house_clock"
|
||||||
|
old = '''{% if a.by|byline %} · {{ a.by|byline }}{% endif %}'''
|
||||||
|
new = '''{% if a.by %} · {{ a.by }}{% endif %}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 an answer line shows the answerer's address"
|
||||||
|
file = "booth/templates/_marks.html"
|
||||||
|
test = "tests/test_antislop.py::test_rendered_marks_use_the_house_clock"
|
||||||
|
old = '''{% if a.answer.answered_by|byline %} · {{ a.answer.answered_by|byline }}{% endif %}'''
|
||||||
|
new = '''{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 the inline ask's state tag shows the raw stamp"
|
||||||
|
file = "booth/templates/_ask_inline.html"
|
||||||
|
test = "tests/test_antislop.py::test_embed_fragment_uses_the_house_clock"
|
||||||
|
old = '''✓ answered <time datetime="{{ a.answer.answered_at }}">{{ a.answer.answered_at|clock }}</time>'''
|
||||||
|
new = '''✓ answered {{ a.answer.answered_at }}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a board row shows HH:MM"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_board_rows_use_the_house_clock"
|
||||||
|
old = '''{{ e.when|clock }}'''
|
||||||
|
new = '''{{ e.when }}'''
|
||||||
|
|
||||||
|
# ---- S1, folded from the heid bug-hunt (thread 01M3MGPFKWBX0SJK5HFE0P3AFM)
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 clock raises OverflowError on an int past float range (Q1)"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_clock_never_raises"
|
||||||
|
old = ''' try:
|
||||||
|
f = float(value)
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
return f if math.isfinite(f) else None'''
|
||||||
|
new = ''' return float(value) if math.isfinite(value) else None'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a date-only stamp prints an invented 0000 (Q8)"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_clock_forms"
|
||||||
|
old = ''' if isinstance(value, str) and ":" not in value:'''
|
||||||
|
new = ''' if False:'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 an address with a port prints (Q7)"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_byline_hides_addresses"
|
||||||
|
old = ''' if s.count(":") == 1:
|
||||||
|
candidates.add(s.split(":", 1)[0])'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a zero-width character disguises an address (Q7)"
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_antislop.py::test_byline_hides_addresses"
|
||||||
|
old = ''' bare = "".join(ch for ch in who if unicodedata.category(ch) not in ("Cf", "Cc")).strip()'''
|
||||||
|
new = ''' bare = who.strip()'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S1 a board row's author is not bylined (Q5)"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_board_rows_use_the_house_clock"
|
||||||
|
old = '''{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}'''
|
||||||
|
new = '''{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}'''
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user