feat(u6): benches — a registry with identity, and the rule enforced
The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates.
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
"""U6 — benches: a running thing, registered.
|
||||
|
||||
The contract is docs/contracts/u6_benches.contract.md. Every test here names
|
||||
the invariant it falsifies, and each is written to go RED under the change that
|
||||
defeats that invariant — not merely to assert the outcome the author had in
|
||||
mind. (The U4 round shipped seven falsifiers of which five stayed green under
|
||||
the very change they forbade; see persistent-memory.d/2026-09-22-vacuous-falsifiers.md.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
|
||||
from booth.benches import ( # noqa: E402
|
||||
BENCHES_FILE,
|
||||
BENCH_STATES,
|
||||
Bench,
|
||||
normalize_bench_url,
|
||||
order_benches,
|
||||
read_benches,
|
||||
remove_bench,
|
||||
set_bench_state,
|
||||
upsert_bench,
|
||||
)
|
||||
from booth.links import booth_target # noqa: E402
|
||||
|
||||
|
||||
# ---- INV-6: the identity collapses a re-post and NOTHING else ---------------
|
||||
#
|
||||
# Both directions from ONE fixture. A test that only checked the talk collapse
|
||||
# would pass under origin normalization, which is the measurably wrong rule:
|
||||
# on the live board it merges eight distinct gitea repositories into one row.
|
||||
|
||||
# Measured on the live board, 2026-09-22.
|
||||
GITEA_EIGHT = [
|
||||
"https://gitea.phasefinal.com/vh/bifrost/issues/17",
|
||||
"https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md",
|
||||
"https://gitea.phasefinal.com/vh/cicada",
|
||||
"https://gitea.phasefinal.com/vh/draupnir",
|
||||
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0",
|
||||
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1",
|
||||
"https://gitea.phasefinal.com/vh/peedlar",
|
||||
"https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0",
|
||||
]
|
||||
TALK_FIVE = ["https://talk.nh3.phasefinal.com:8092/"] * 5
|
||||
|
||||
|
||||
def test_eight_distinct_repos_stay_eight(tmp_path):
|
||||
"""INV-6, the direction origin-normalization gets WRONG. Defeating change:
|
||||
normalizing to scheme://host:port. This goes red under it; the collapse
|
||||
test below does not."""
|
||||
for i, u in enumerate(GITEA_EIGHT):
|
||||
upsert_bench(tmp_path, u, f"repo {i}", "vh")
|
||||
benches, err = read_benches(tmp_path)
|
||||
assert err is None
|
||||
assert len(benches) == 8, [b.id for b in benches]
|
||||
|
||||
|
||||
def test_five_reposts_of_one_bench_collapse(tmp_path):
|
||||
"""INV-6, the direction the IA doc names. `talk` is on the live board five
|
||||
times; the registry must hold one row, carrying the LAST name."""
|
||||
for i, u in enumerate(TALK_FIVE):
|
||||
_, created = upsert_bench(tmp_path, u, f"talk v{i}", "nh3-dev")
|
||||
assert created is (i == 0)
|
||||
benches, _ = read_benches(tmp_path)
|
||||
assert len(benches) == 1
|
||||
assert benches[0].name == "talk v4"
|
||||
|
||||
|
||||
def test_two_lrpg_surfaces_on_one_origin_stay_two(tmp_path):
|
||||
"""INV-6. The IA doc's OWN example of two real benches shares an origin."""
|
||||
upsert_bench(tmp_path, "http://10.100.10.50:8321/Authoring%20Studio.dc.html", "authoring", "ldp-dev")
|
||||
upsert_bench(tmp_path, "http://10.100.10.50:8321/GM%20Playback.dc.html", "gm", "ldp-dev")
|
||||
assert len(read_benches(tmp_path)[0]) == 2
|
||||
|
||||
|
||||
def test_query_is_part_of_the_identity(tmp_path):
|
||||
"""INV-6. Three ShutterChute rows differ ONLY by `?token=`; they are three
|
||||
links, not one bench posted three times. Defeating change: dropping query."""
|
||||
base = "http://10.100.10.50:8477/?token="
|
||||
for tok in ("aaa", "bbb", "ccc"):
|
||||
upsert_bench(tmp_path, base + tok, "shutterchute", "nh3-dev")
|
||||
assert len(read_benches(tmp_path)[0]) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b", [
|
||||
("http://x.test/", "http://X.TEST"), # host case + bare-slash path
|
||||
("http://x.test:80/p", "http://x.test/p"), # default port
|
||||
("https://x.test:443/p", "https://x.test/p"),
|
||||
("http://x.test/p#frag", "http://x.test/p"), # fragment dropped
|
||||
(" http://x.test/p ", "http://x.test/p"), # whitespace
|
||||
])
|
||||
def test_these_pairs_are_one_bench(a, b):
|
||||
"""INV-6. Each pair is the SAME resource reached two ways."""
|
||||
assert normalize_bench_url(a) == normalize_bench_url(b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a,b", [
|
||||
("http://x.test/p", "http://x.test/p/"), # trailing slash on a REAL path
|
||||
("http://x.test/p", "http://x.test/P"), # path case
|
||||
("http://x.test/?a=1&b=2", "http://x.test/?b=2&a=1"), # query order is opaque
|
||||
("http://x.test:8092/", "https://x.test:8092/"), # scheme
|
||||
])
|
||||
def test_these_pairs_are_two_benches(a, b):
|
||||
"""INV-6, the other direction. Each pair MAY be two different resources, and
|
||||
the registry must not decide otherwise on the operator's behalf."""
|
||||
assert normalize_bench_url(a) != normalize_bench_url(b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"", " ", "not a url", "ftp://x.test/f", "file:///etc/passwd",
|
||||
"http://", "https:///path", "//x.test/p", "javascript:alert(1)",
|
||||
])
|
||||
def test_refused_urls_raise_with_a_reason(bad):
|
||||
with pytest.raises(ValueError) as e:
|
||||
normalize_bench_url(bad)
|
||||
assert str(e.value).strip(), "a refusal with no reason is a refusal the CLI cannot print"
|
||||
|
||||
|
||||
def test_credentials_are_refused_not_stripped():
|
||||
"""Stripping would register a bench whose URL no longer works while telling
|
||||
the poster it succeeded — and put a credential on an unauthenticated LAN
|
||||
surface on the way. Defeating change: `netloc.rpartition('@')[2]`."""
|
||||
with pytest.raises(ValueError):
|
||||
normalize_bench_url("https://user:hunter2@x.test/p")
|
||||
|
||||
|
||||
# ---- INV-7: `url` is what a click goes to; `id` is never the href -----------
|
||||
|
||||
|
||||
def test_the_stored_url_is_the_raw_string(tmp_path):
|
||||
"""INV-7. Defeating change: storing the normalized form as `url` because it
|
||||
is 'the clean one'. Every field that differs is asserted, byte for byte."""
|
||||
raw = " HTTP://X.Test:80/Some%20Path/?b=2&a=1#frag "
|
||||
bench, _ = upsert_bench(tmp_path, raw, "n", "o")
|
||||
assert bench.url == raw.strip()
|
||||
assert bench.id != bench.url
|
||||
assert bench.id == "http://x.test/Some%20Path/?b=2&a=1"
|
||||
assert read_benches(tmp_path)[0][0].url == raw.strip()
|
||||
|
||||
|
||||
# ---- INV-4: the rendered order is TOTAL and stated --------------------------
|
||||
|
||||
|
||||
def test_same_name_benches_do_not_swap(tmp_path):
|
||||
"""INV-4. Defeating change: dropping the `id` tie-break. Two benches with
|
||||
the SAME name, registered in both orders, must render identically — a test
|
||||
over distinct names passes with no tie-break at all."""
|
||||
def build(order):
|
||||
root = tmp_path / f"r{order}"
|
||||
root.mkdir()
|
||||
for u in (["http://a.test/", "http://b.test/"] if order else
|
||||
["http://b.test/", "http://a.test/"]):
|
||||
upsert_bench(root, u, "same name", "o")
|
||||
return [b.id for b in read_benches(root)[0]]
|
||||
assert build(0) == build(1)
|
||||
|
||||
|
||||
def test_state_ranks_before_name(tmp_path):
|
||||
"""INV-4. live → promoted → retired, THEN name. Defeating change: ordering
|
||||
by name alone, which a fixture of three same-state benches cannot see."""
|
||||
upsert_bench(tmp_path, "http://a.test/", "aaa", "o") # would sort first by name
|
||||
upsert_bench(tmp_path, "http://z.test/", "zzz", "o")
|
||||
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "retired")
|
||||
assert [b.name for b in read_benches(tmp_path)[0]] == ["zzz", "aaa"]
|
||||
|
||||
|
||||
def test_order_is_case_insensitive_on_name(tmp_path):
|
||||
upsert_bench(tmp_path, "http://b.test/", "Bravo", "o")
|
||||
upsert_bench(tmp_path, "http://a.test/", "alpha", "o")
|
||||
assert [b.name for b in read_benches(tmp_path)[0]] == ["alpha", "Bravo"]
|
||||
|
||||
|
||||
def test_order_benches_is_pure(tmp_path):
|
||||
"""INV-4. Defeating change: `order_benches` doing I/O or sorting in place.
|
||||
Called with records belonging to NO root, it must still answer."""
|
||||
made = [Bench(id=f"http://{c}.test/", url=f"http://{c}.test/", name=c,
|
||||
owner="o", state="live", added="", updated="") for c in "ba"]
|
||||
assert [b.name for b in order_benches(made)] == ["a", "b"]
|
||||
assert [b.name for b in made] == ["b", "a"], "input was mutated"
|
||||
|
||||
|
||||
# ---- INV-5: the read cannot raise, and cannot cost the caller unboundedly ---
|
||||
|
||||
|
||||
def _write_raw(root: pathlib.Path, payload: str) -> None:
|
||||
(root / BENCHES_FILE).write_text(payload)
|
||||
|
||||
|
||||
def test_absent_registry_is_not_an_error(tmp_path):
|
||||
benches, err = read_benches(tmp_path)
|
||||
assert benches == [] and err is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload,label", [
|
||||
("this is not json", "non-JSON bytes"),
|
||||
("[]", "valid JSON of the wrong top-level shape"),
|
||||
('{"benches": []}', "the list shape this unit deliberately does not use"),
|
||||
('{"http://a/": "a string, not a record"}', "a value of the wrong type"),
|
||||
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "a FIELD of the wrong type"),
|
||||
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "an unknown state"),
|
||||
])
|
||||
def test_damaged_registries_report_rather_than_raise(tmp_path, payload, label):
|
||||
"""INV-5. Defeating change: `json.load` with no guard, or `except: pass`
|
||||
which would report absent. The error must be NON-EMPTY — 'damaged' and
|
||||
'absent' must not render the same, because only one of them needs a human.
|
||||
The wrong-typed-FIELD row is the shape currently 500ing the gallery
|
||||
elsewhere in this service."""
|
||||
_write_raw(tmp_path, payload)
|
||||
benches, err = read_benches(tmp_path)
|
||||
assert err, f"{label} reported no error"
|
||||
assert benches == []
|
||||
|
||||
|
||||
def test_oversized_registry_is_refused_by_size_before_parsing(tmp_path):
|
||||
"""INV-5. Defeating change: parsing first and checking length after, which
|
||||
costs the caller the whole file. A FIFO has st_size 0, so the guard must
|
||||
bound the READ, not trust the stat — the 2026-09-22 hang lesson."""
|
||||
from booth.benches import BENCHES_MAX_BYTES
|
||||
_write_raw(tmp_path, '{"http://a/": {"name": "' + "x" * BENCHES_MAX_BYTES + '"}}')
|
||||
benches, err = read_benches(tmp_path)
|
||||
assert err and benches == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the mode bit")
|
||||
def test_unreadable_registry_reports_rather_than_raises(tmp_path):
|
||||
p = tmp_path / BENCHES_FILE
|
||||
p.write_text("{}")
|
||||
p.chmod(0o000)
|
||||
try:
|
||||
benches, err = read_benches(tmp_path)
|
||||
assert err and benches == []
|
||||
finally:
|
||||
p.chmod(0o644)
|
||||
|
||||
|
||||
# ---- INV-1: one module knows the registry's filename ------------------------
|
||||
|
||||
|
||||
def test_only_benches_py_names_the_registry_file():
|
||||
"""INV-1. Defeating change: a route reading `.benches.json` directly to save
|
||||
an import. Asserting that the panel renders would pass under exactly that."""
|
||||
root = pathlib.Path(__file__).parent.parent
|
||||
offenders = []
|
||||
for f in list((root / "booth").rglob("*.py")) + [root / "scripts" / "booth"]:
|
||||
if f.name == "benches.py":
|
||||
continue
|
||||
if ".benches.json" in f.read_text():
|
||||
offenders.append(str(f.relative_to(root)))
|
||||
assert not offenders, f"the registry filename is hard-coded outside benches.py: {offenders}"
|
||||
|
||||
|
||||
# ---- INV-9: stdlib-only, AND sibling-free (seam review SR-1) ----------------
|
||||
|
||||
|
||||
def test_benches_is_stdlib_only_and_imports_no_sibling():
|
||||
"""INV-9. The PARAMETRIZED test in test_marks.py exempts `booth` on purpose,
|
||||
so it cannot catch `from booth.links import booth_target` — which is exactly
|
||||
the import this unit tempts an implementer into. This is the strict copy,
|
||||
mirroring tests/test_manifest.py. Seam review SR-1."""
|
||||
src = pathlib.Path(__file__).parent.parent / "booth" / "benches.py"
|
||||
tree = ast.parse(src.read_text())
|
||||
roots = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
roots.update(a.name.split(".")[0] for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
roots.add("booth" if node.level else (node.module or "").split(".")[0])
|
||||
outside = {r for r in roots if r and r not in sys.stdlib_module_names}
|
||||
assert not outside, f"booth/benches.py imports outside the stdlib (booth.* included): {sorted(outside)}"
|
||||
|
||||
|
||||
# ---- INV-2: ONE predicate decides what a booth URL is -----------------------
|
||||
|
||||
# Every row is (url, expected booth name or None). Run against BOTH callers.
|
||||
BOOTH_URL_TABLE = [
|
||||
("http://10.100.10.50:8090/b/sindra-bakeoff/", "sindra-bakeoff"),
|
||||
("http://10.100.10.50:8090/b/sindra-bakeoff", "sindra-bakeoff"),
|
||||
("http://localhost:8090/b/x/", "x"),
|
||||
("http://NH3-DEV.nh3.internal:8090/b/x/", "x"), # host-agnostic, any case
|
||||
("https://10.100.10.50:8090/b/x/", "x"), # scheme-agnostic
|
||||
("http://10.100.10.50:8090/b/my%20booth/", "my booth"), # SR-7: decoded
|
||||
("http://10.100.10.50:8090/b/x/zoom/a.png", "x"), # nested path
|
||||
("http://10.100.10.50:8090/b/x/?q=1", "x"), # query
|
||||
("http://10.100.10.50:8090/b/x/#frag", "x"),
|
||||
("http://10.100.10.50:8090/", None), # the Booth root IS a bench
|
||||
("http://10.100.10.50:8090/b/", None), # no name
|
||||
("http://10.100.10.50:8090/b//", None),
|
||||
("http://10.100.10.50:8090/b/.hidden/", None), # resolve_booth's rules
|
||||
("http://10.100.10.50:8090/b/%2e%2e/", None), # decoded `..`
|
||||
("http://10.100.10.50:8090/b/a%2Fb/", None), # decoded separator
|
||||
("https://gitea.phasefinal.com/vh/peedlar", None),
|
||||
("not a url at all", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url,expected", BOOTH_URL_TABLE)
|
||||
def test_booth_target_classifies(url, expected):
|
||||
"""INV-2. The table is shared with the CLI refusal test and the dead-marker
|
||||
test, so a second implementation in either place goes red here or there."""
|
||||
assert booth_target(url) == expected
|
||||
|
||||
|
||||
def test_booth_target_never_raises():
|
||||
"""A board row is arbitrary operator-editable text; a predicate that raises
|
||||
on one row takes the whole page. Defeating change: `urlsplit` unguarded."""
|
||||
for junk in ["", " ", "http://[oops", "\x00", "://", "http://]"]:
|
||||
assert booth_target(junk) is None
|
||||
|
||||
|
||||
# ---- upsert semantics -------------------------------------------------------
|
||||
|
||||
|
||||
def test_added_survives_reregistration_updated_does_not(tmp_path):
|
||||
first, created = upsert_bench(tmp_path, "http://a.test/", "one", "o1")
|
||||
assert created
|
||||
second, created = upsert_bench(tmp_path, "http://a.test/", "two", "o2")
|
||||
assert not created
|
||||
assert second.added == first.added
|
||||
assert second.name == "two" and second.owner == "o2"
|
||||
|
||||
|
||||
def test_state_survives_reregistration(tmp_path):
|
||||
"""A promoted bench that re-announces itself is still promoted — otherwise
|
||||
every deploy silently demotes it."""
|
||||
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "promoted")
|
||||
again, _ = upsert_bench(tmp_path, "http://a.test/", "one again", "o")
|
||||
assert again.state == "promoted"
|
||||
|
||||
|
||||
def test_a_new_bench_is_live(tmp_path):
|
||||
bench, _ = upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||
assert bench.state == "live" and bench.state in BENCH_STATES
|
||||
|
||||
|
||||
def test_set_state_refuses_an_unknown_state(tmp_path):
|
||||
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||
with pytest.raises(ValueError):
|
||||
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "invented")
|
||||
|
||||
|
||||
def test_set_state_and_remove_miss_cleanly(tmp_path):
|
||||
assert set_bench_state(tmp_path, "http://nope/", "live") is None
|
||||
assert remove_bench(tmp_path, "http://nope/") is None
|
||||
|
||||
|
||||
def test_remove_returns_the_record_and_drops_it(tmp_path):
|
||||
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||
gone = remove_bench(tmp_path, normalize_bench_url("http://a.test/"))
|
||||
assert gone is not None and gone.name == "one"
|
||||
assert read_benches(tmp_path)[0] == []
|
||||
|
||||
|
||||
def test_fields_are_capped_at_the_write(tmp_path):
|
||||
from booth.benches import NAME_MAX, OWNER_MAX
|
||||
bench, _ = upsert_bench(tmp_path, "http://a.test/", "n" * 500, "o" * 500)
|
||||
assert len(bench.name) == NAME_MAX and len(bench.owner) == OWNER_MAX
|
||||
|
||||
|
||||
def test_a_write_over_a_damaged_registry_does_not_destroy_it(tmp_path):
|
||||
"""The 2026-09-21 lesson, in this unit's storage: reads are lenient, writes
|
||||
are STRICT. A damaged registry must not be silently replaced by a fresh one
|
||||
carrying only the new row — that is the marks-wipe bug in a new file."""
|
||||
_write_raw(tmp_path, '{"http://a/": {"name": "real", "owner": "o", "state": "live"}, BROKEN')
|
||||
before = (tmp_path / BENCHES_FILE).read_text()
|
||||
with pytest.raises(ValueError):
|
||||
upsert_bench(tmp_path, "http://b.test/", "new", "o")
|
||||
assert (tmp_path / BENCHES_FILE).read_text() == before
|
||||
|
||||
|
||||
def test_the_on_disk_shape_is_an_object_keyed_by_id(tmp_path):
|
||||
"""Two rows with one identity are then impossible BY CONSTRUCTION rather
|
||||
than by an upsert remembering to check."""
|
||||
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||
raw = json.loads((tmp_path / BENCHES_FILE).read_text())
|
||||
# The key is the NORMALIZED url, so the bare "/" is already gone — which is
|
||||
# the rule `test_these_pairs_are_one_bench` pins independently.
|
||||
assert isinstance(raw, dict) and list(raw) == ["http://a.test"]
|
||||
assert "id" not in raw["http://a.test"], "the key IS the id; storing it twice invites drift"
|
||||
|
||||
|
||||
# ---- the rendered surface ---------------------------------------------------
|
||||
#
|
||||
# The benches panel and the dead-row marker both live on the standing board's
|
||||
# page — the one booth carrying a links.md.
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from booth.app import create_app # noqa: E402
|
||||
|
||||
|
||||
def _board(root: pathlib.Path, rows: str) -> pathlib.Path:
|
||||
b = root / "links"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
(b / "links.md").write_text(rows)
|
||||
return b
|
||||
|
||||
|
||||
def _client(root):
|
||||
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False))
|
||||
|
||||
|
||||
ROW_LIVE = "- [still here](http://10.100.10.50:8090/b/alive/) <sub>· x · 2026-09-01 00:00</sub>\n"
|
||||
ROW_DEAD = "- [swept](http://10.100.10.50:8090/b/gone/) <sub>· x · 2026-09-01 00:00</sub>\n"
|
||||
ROW_REF = "- [a repo](https://gitea.phasefinal.com/vh/peedlar) <sub>· x · 2026-09-01 00:00</sub>\n"
|
||||
|
||||
|
||||
def _dead_rows(body: str) -> list[str]:
|
||||
"""Board ROWS carrying the dead class.
|
||||
|
||||
Scoped to `<div class="board-row ...">` on purpose: the class name also
|
||||
appears in base.html's stylesheet, so a whole-document substring test is
|
||||
always true and can never go red — a vacuous falsifier of exactly the shape
|
||||
persistent-memory.d/2026-09-22-vacuous-falsifiers.md describes.
|
||||
"""
|
||||
return [ln for ln in body.splitlines()
|
||||
if 'class="board-row' in ln and "board-dead" in ln]
|
||||
|
||||
|
||||
def test_a_dead_row_is_marked_and_a_live_one_is_not(tmp_path):
|
||||
"""The marker. Defeating change: marking every `/b/` row dead, or none.
|
||||
Both a live target and a dead one are in ONE fixture, so a marker that is
|
||||
constant in either direction goes red."""
|
||||
(tmp_path / "alive").mkdir()
|
||||
_board(tmp_path, ROW_LIVE + ROW_DEAD + ROW_REF)
|
||||
r = _client(tmp_path).get("/b/links/")
|
||||
assert r.status_code == 200
|
||||
rows = _dead_rows(r.text)
|
||||
assert len(rows) == 1, f"exactly one of the three rows is dead, got {rows}"
|
||||
assert "/b/gone/" in r.text and "booth is gone" in r.text
|
||||
|
||||
|
||||
def test_the_marker_never_takes_the_page(tmp_path):
|
||||
"""Seam review SR-2. `resolve_booth` RAISES HTTPException(404); calling it
|
||||
per row would turn one swept booth into a 404 for the whole board. This is
|
||||
the test that goes red under that exact implementation."""
|
||||
_board(tmp_path, ROW_DEAD * 5)
|
||||
assert _client(tmp_path).get("/b/links/").status_code == 200
|
||||
|
||||
|
||||
def test_a_percent_encoded_booth_is_not_marked_dead(tmp_path):
|
||||
"""Seam review SR-7. `quote(name, safe="")` is how the service emits these,
|
||||
so the marker must decode before it looks on disk. Defeating change:
|
||||
comparing the raw path segment — which marks this row dead forever."""
|
||||
(tmp_path / "my booth").mkdir()
|
||||
_board(tmp_path, "- [x](http://10.100.10.50:8090/b/my%20booth/) <sub>· x · 2026-09-01 00:00</sub>\n")
|
||||
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
|
||||
|
||||
|
||||
def test_a_reference_row_is_never_marked_dead(tmp_path):
|
||||
_board(tmp_path, ROW_REF)
|
||||
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
|
||||
|
||||
|
||||
def test_the_panel_renders_registered_benches(tmp_path):
|
||||
_board(tmp_path, ROW_REF)
|
||||
upsert_bench(tmp_path, "https://talk.nh3.phasefinal.com:8092/", "talk", "tts-dev")
|
||||
body = _client(tmp_path).get("/b/links/").text
|
||||
assert "talk" in body and "tts-dev" in body
|
||||
|
||||
|
||||
def test_the_anchor_href_is_the_raw_url_not_the_id(tmp_path):
|
||||
"""INV-7. Defeating change: rendering `bench.id` in the href because it is
|
||||
'the clean one'. The raw URL here normalizes differently in three ways."""
|
||||
raw = "HTTP://Talk.NH3.test:80/Some%20Path/?b=2&a=1#frag"
|
||||
_board(tmp_path, ROW_REF)
|
||||
upsert_bench(tmp_path, raw, "talk", "o")
|
||||
body = _client(tmp_path).get("/b/links/").text
|
||||
assert 'href="HTTP://Talk.NH3.test:80/Some%20Path/?b=2&a=1#frag"' in body, \
|
||||
"the href must be the URL as posted, byte for byte"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload,label", [
|
||||
(None, "absent"),
|
||||
("not json", "non-JSON"),
|
||||
("[]", "wrong top-level shape"),
|
||||
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "wrong-typed field"),
|
||||
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "unknown state"),
|
||||
])
|
||||
def test_a_damaged_registry_costs_its_panel_and_never_the_page(tmp_path, payload, label):
|
||||
"""INV-5, at the render. The v0.2.2 lesson: a poisoned sidecar returned 500
|
||||
for `/` and `/healthz` across all 25 booths. The wrong-typed-FIELD row is
|
||||
the shape currently 500ing the gallery elsewhere in this service, so it is
|
||||
the one that matters most."""
|
||||
_board(tmp_path, ROW_REF)
|
||||
if payload is not None:
|
||||
(tmp_path / BENCHES_FILE).write_text(payload)
|
||||
c = _client(tmp_path)
|
||||
assert c.get("/b/links/").status_code == 200, label
|
||||
assert c.get("/").status_code == 200, label
|
||||
assert c.get("/healthz").status_code == 200, label
|
||||
|
||||
|
||||
def test_damaged_and_absent_render_different_text(tmp_path):
|
||||
"""INV-5. Only ONE of them needs a human. Defeating change: `except: pass`
|
||||
returning ([], None), which renders damaged exactly like absent."""
|
||||
_board(tmp_path, ROW_REF)
|
||||
absent = _client(tmp_path).get("/b/links/").text
|
||||
(tmp_path / BENCHES_FILE).write_text("not json")
|
||||
damaged = _client(tmp_path).get("/b/links/").text
|
||||
assert absent != damaged
|
||||
|
||||
|
||||
def test_the_panel_does_not_render_on_an_ordinary_booth(tmp_path):
|
||||
"""A bench registry on every gallery page would be noise, and would cost a
|
||||
read per booth page view for a surface that belongs to exactly one."""
|
||||
(tmp_path / "ordinary").mkdir()
|
||||
(tmp_path / "ordinary" / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
||||
assert "talk" not in _client(tmp_path).get("/b/ordinary/").text
|
||||
|
||||
|
||||
def test_the_benches_routes_round_trip(tmp_path):
|
||||
_board(tmp_path, ROW_REF)
|
||||
c = _client(tmp_path)
|
||||
assert c.post("/b/links/bench-add", data={"url": "https://x.test/", "name": "ex"},
|
||||
follow_redirects=False).status_code in (302, 303)
|
||||
assert "ex" in c.get("/b/links/").text
|
||||
bid = normalize_bench_url("https://x.test/")
|
||||
c.post("/b/links/bench-state", data={"bench": bid, "state": "retired"},
|
||||
follow_redirects=False)
|
||||
assert read_benches(tmp_path)[0][0].state == "retired"
|
||||
c.post("/b/links/bench-remove", data={"bench": bid}, follow_redirects=False)
|
||||
assert read_benches(tmp_path)[0] == []
|
||||
|
||||
|
||||
def test_a_bad_url_posted_to_the_route_does_not_500(tmp_path):
|
||||
_board(tmp_path, ROW_REF)
|
||||
c = _client(tmp_path)
|
||||
r = c.post("/b/links/bench-add", data={"url": "ftp://x.test/f", "name": "ex"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 400)
|
||||
assert c.get("/b/links/").status_code == 200
|
||||
Reference in New Issue
Block a user