import os
import re
import time
import pytest
from fastapi.testclient import TestClient
from booth.app import (
FAVICON_LINK,
KEEP_MARKER,
build_gallery,
classify,
create_app,
doc_kind,
generate_pickup_id,
human_dur,
is_expired,
is_kept,
list_booths,
render_doc,
safe_upload_name,
sweep_once,
wrap_verbatim_html,
)
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
# ---- pure helpers -----------------------------------------------------------
def test_classify():
assert classify("a.PNG") == "image"
assert classify("clip.webm") == "video"
assert classify("v.mp4") == "video"
assert classify("song.mp3") == "audio"
assert classify("notes.txt") == "other"
assert classify("archive.tar.gz") == "other"
def test_human_dur():
assert human_dur(0) == "expired"
assert human_dur(-5) == "expired"
assert human_dur(30) == "<1m"
assert human_dur(90) == "1m"
assert human_dur(3600) == "1h"
assert human_dur(3660) == "1h 1m"
def _touch(path, when=None):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x")
if when is not None:
os.utime(path, (when, when))
def test_is_expired_uses_newest_mtime(tmp_path):
booth = tmp_path / "b"
_touch(booth / "old.png", when=time.time() - 10_000)
# freshly touched second file keeps the booth alive despite the old one
_touch(booth / "new.png")
assert not is_expired(booth, ttl_seconds=3600)
old = tmp_path / "stale"
t = time.time() - 10_000
_touch(old / "x.png", when=t)
os.utime(old, (t, t))
assert is_expired(old, ttl_seconds=3600)
def test_sweep_only_removes_expired(tmp_path):
fresh = tmp_path / "fresh"
_touch(fresh / "a.png")
old = tmp_path / "old"
t = time.time() - 10_000
_touch(old / "a.png", when=t)
os.utime(old, (t, t))
dotdir = tmp_path / ".control"
t2 = time.time() - 10_000
dotdir.mkdir()
os.utime(dotdir, (t2, t2))
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["old"]
assert fresh.exists()
assert not old.exists()
assert dotdir.exists() # dotfolders are never swept
def test_build_gallery_folds_caption_sidecars(tmp_path):
booth = tmp_path / "b"
_touch(booth / "a.png")
(booth / "a.txt").write_text("variant A: cudaMalloc")
_touch(booth / "b.png")
(booth / "b.png.txt").write_text("variant B: cudaMallocAsync")
_touch(booth / "loose.txt") # no media partner -> shown as its own item
items = build_gallery(booth)
by_name = {it["name"]: it for it in items}
assert by_name["a.png"]["caption"] == "variant A: cudaMalloc"
assert by_name["b.png"]["caption"] == "variant B: cudaMallocAsync"
assert "a.txt" not in by_name and "b.png.txt" not in by_name
assert "loose.txt" in by_name # a caption with nothing to caption stays visible
# ---- HTTP surface -----------------------------------------------------------
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
def test_index_empty(client):
c, _ = client
r = c.get("/")
assert r.status_code == 200
assert "No booths yet" in r.text
def test_index_lists_booth(client):
c, data = client
_touch(data / "run1" / "a.png")
r = c.get("/")
assert r.status_code == 200
assert "run1" in r.text
def test_booth_autogallery_renders_media(client):
c, data = client
_touch(data / "run1" / "shot.png")
r = c.get("/b/run1/")
assert r.status_code == 200
assert "MY CUSTOM REPORT")
r = c.get("/b/custom/")
assert r.status_code == 200
assert "MY CUSTOM REPORT" in r.text
def test_booth_serves_file(client):
c, data = client
(data / "run1").mkdir()
(data / "run1" / "a.bin").write_bytes(b"\x00\x01payload")
r = c.get("/b/run1/a.bin")
assert r.status_code == 200
assert r.content == b"\x00\x01payload"
def test_booth_zip_download(client):
# a verbatim index.html booth (no per-file chrome) is still downloadable as a zip
c, data = client
d = data / "brief"
d.mkdir()
(d / "index.html").write_text("
own item
page = c.get("/b/run1/")
assert "view?f=readme.md" in page.text # md -> viewer
assert "view?f=notes.txt" in page.text # txt -> viewer
assert 'href="readme.md" download' not in page.text # not a forced download
# ---- image viewer prev/next nav ---------------------------------------------
def test_view_image_prev_next_nav(client):
c, data = client
d = data / "run1"; d.mkdir()
for n in ("a.png", "b.png", "c.png"):
_touch(d / n)
r = c.get("/b/run1/view", params={"f": "b.png"}) # middle -> prev=a, next=c
assert r.status_code == 200
assert 'class="vnav vprev" href="?f=a.png"' in r.text
assert 'class="vnav vnext" href="?f=c.png"' in r.text
def test_view_image_nav_wraps(client):
c, data = client
d = data / "run1"; d.mkdir()
for n in ("a.png", "b.png", "c.png"):
_touch(d / n)
first = c.get("/b/run1/view", params={"f": "a.png"}).text
assert 'vprev" href="?f=c.png"' in first and 'vnext" href="?f=b.png"' in first # first wraps prev->last
last = c.get("/b/run1/view", params={"f": "c.png"}).text
assert 'vnext" href="?f=a.png"' in last and 'vprev" href="?f=b.png"' in last # last wraps next->first
def test_view_single_image_no_nav(client):
c, data = client
d = data / "run1"; d.mkdir()
_touch(d / "only.png")
r = c.get("/b/run1/view", params={"f": "only.png"})
assert r.status_code == 200
# no arrow anchors with a single image (the .vnav CSS rule is always present)
assert 'class="vnav vprev"' not in r.text and 'class="vnav vnext"' not in r.text
# ---- kept booths: the `.forever` sentinel -----------------------------------
#
# The Booth's whole contract is "wiped 24h after last activity". A kept booth is
# the deliberate exception: an operator-facing board (agent-posted links, a
# standing report) that must outlive the sweep and stay separated from the
# ephemeral traffic so it does not get lost in it.
def _stale(path, seconds=10_000):
"""Age a booth and everything in it well past any test TTL."""
t = time.time() - seconds
for p in sorted(path.rglob("*"), reverse=True):
os.utime(p, (t, t))
os.utime(path, (t, t))
def test_is_kept_detects_the_sentinel(tmp_path):
plain = tmp_path / "plain"
plain.mkdir()
kept = tmp_path / "kept"
_touch(kept / KEEP_MARKER)
assert not is_kept(plain)
assert is_kept(kept)
def test_kept_booth_survives_the_sweep(tmp_path):
"""The point of the whole feature: expiry does not apply to a kept booth."""
doomed = tmp_path / "doomed"
_touch(doomed / "a.png")
_stale(doomed)
kept = tmp_path / "links"
_touch(kept / "a.png")
_touch(kept / KEEP_MARKER)
_stale(kept)
wiped = sweep_once(tmp_path, ttl_seconds=3600)
assert wiped == ["doomed"]
assert not doomed.exists()
assert kept.exists(), "a booth carrying the sentinel must never be swept"
def test_kept_booth_is_still_reported_expired_by_age(tmp_path):
"""is_expired stays a pure age question; only the sweeper honours the pin.
Keeping these separate means `expires_in` arithmetic and the reaper policy
cannot drift into each other.
"""
kept = tmp_path / "links"
_touch(kept / KEEP_MARKER)
_stale(kept)
assert is_expired(kept, ttl_seconds=3600)
assert sweep_once(tmp_path, ttl_seconds=3600) == []
def test_list_booths_flags_kept(tmp_path):
_touch(tmp_path / "ephemeral" / "a.png")
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
by_name = {b["name"]: b for b in list_booths(tmp_path, ttl_seconds=3600)}
assert by_name["ephemeral"]["kept"] is False
assert by_name["links"]["kept"] is True
def test_sentinel_is_not_counted_as_an_item(tmp_path):
"""It is a dotfile, so it must not inflate the item count or become a tile."""
_touch(tmp_path / "links" / "a.png")
_touch(tmp_path / "links" / KEEP_MARKER)
booth = next(b for b in list_booths(tmp_path, ttl_seconds=3600) if b["name"] == "links")
assert booth["count"] == 1
def test_index_separates_kept_from_ephemeral(client):
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
# Assert on the lane's markup, not on the word "Kept" — that string also
# appears in the stylesheet comment that is served on every page, so a bare
# substring check passes for the wrong reason.
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
assert 'class="card card-kept"' in html
# The kept lane is rendered before the ephemeral grid, so the operator sees
# durable boards first rather than hunting for them among the churn.
assert html.index("links") < html.index("scratch")
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
c, data = client
_touch(data / "links" / "a.png")
_touch(data / "links" / KEEP_MARKER)
html = c.get("/").text
assert "expires in" not in html, "a kept booth has no expiry to advertise"
def test_index_without_kept_booths_omits_the_lane(client):
c, data = client
_touch(data / "scratch" / "a.png")
html = c.get("/").text
# The full attribute form, because the bare class names also appear in the
# inlined stylesheet that ships on every page.
assert 'class="grid kept-grid"' not in html, "the lane must not render when nothing is kept"
assert 'class="card card-kept"' not in html