feat(booth): kept boards — a .forever sentinel and a standing link board
Agent sessions hand the operator URLs and they drown in terminal scrollback. The Booth is the right home for them — it already has the one property that decides adoption, which is that a session can publish with mkdir and cp, no API key, no schema, no deploy — but everything in it dies in 24h. So: a booth containing `.forever` is never swept, and renders in its own Kept lane at the top of the index. Opt-in per booth, so the ephemeral default is untouched and nobody inherits a cleanup chore. `rm` the sentinel and the board rejoins the sweep; the CLI verbs are sugar over exactly that, which keeps the filesystem-is-the-state model honest. The pin is deliberately NOT wired into is_expired(). That stays a pure age question feeding the `expires_in` countdown; only sweep_once() honours the sentinel. Keeping expiry arithmetic and reaper policy apart means they cannot drift into each other. Kept cards are visually separated per Australis: a 2px top edge in aurora blue, the one accent border the system sanctions. They show "kept" instead of a countdown, and they deliberately lose the one-click wipe button — a × next to the durable stuff is a footgun, so removing a kept board is a two-step act. `booth link <url> [description]` appends to the standing `links` board, creating and keeping it on first use. Entries carry provenance (handle or hostname, plus a timestamp) because a bare URL is unreadable three days later. The append is one printf of one line to an O_APPEND fd — atomic under PIPE_BUF on POSIX — which matters because many agents post to one board and interleaved half-lines would be the obvious failure mode. Seven tests cover the sentinel: detection, survival of a sweep that wipes its neighbour, the deliberate is_expired/sweep_once split, the listing flag, the sentinel not inflating item counts, and both lane-rendering directions. Two of them originally asserted on the bare strings "Kept" and "kept-grid", which passed for the wrong reason — those also appear in the inlined stylesheet served on every page — so they now assert the full class attribute. 55 pass. Also corrects the Homepage card's description, which advertised a flat 24h TTL that is no longer the whole story.
This commit is contained in:
@@ -39,6 +39,50 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||
|
||||
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
||||
|
||||
## Kept boards — the one exception to the 24h rule
|
||||
|
||||
A booth containing a **`.forever`** dotfile is **never swept**, and renders in
|
||||
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
|
||||
countdown, no one-click wipe). Everything else is unchanged: the default is
|
||||
still ephemeral, so nobody inherits a cleanup chore they didn't ask for.
|
||||
|
||||
```bash
|
||||
booth keep my-board # drop the sentinel — exempt from the sweep, forever
|
||||
booth unkeep my-board # remove it — the board rejoins the sweep
|
||||
```
|
||||
|
||||
It is just a file, so the manual forms work identically and are the honest
|
||||
mental model:
|
||||
|
||||
```bash
|
||||
touch ~/booth-data/my-board/.forever # keep
|
||||
rm ~/booth-data/my-board/.forever # unkeep
|
||||
rm -rf ~/booth-data/my-board # delete outright, whenever you like
|
||||
```
|
||||
|
||||
**Why this exists:** agent sessions hand the operator URLs — a booth of renders,
|
||||
a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where
|
||||
those go instead.
|
||||
|
||||
### The standing link board
|
||||
|
||||
```bash
|
||||
booth link <url> [description]
|
||||
```
|
||||
|
||||
Appends one line to the **`links`** board (`$BOOTH_LINKS_BOARD`, default
|
||||
`links`), creating it and marking it kept on first use. Each entry carries
|
||||
provenance — who posted it and when — because a bare URL is unreadable three
|
||||
days later. `links.md` renders as a readable page in the booth.
|
||||
|
||||
The append is a single `printf` of a single line to an `O_APPEND` fd, which is
|
||||
atomic under `PIPE_BUF` on POSIX. That matters here specifically: many agents
|
||||
post to one board, and interleaved half-lines would be the obvious failure.
|
||||
|
||||
Deliberately **not** a database. The board is a markdown file — editable with
|
||||
any editor, greppable, and trivially prunable by hand, which is the whole point
|
||||
of the Booth's filesystem-is-the-state model.
|
||||
|
||||
## Upload for pickup
|
||||
|
||||
The reverse direction — put files in through the web, pick them up by id:
|
||||
|
||||
@@ -10,6 +10,12 @@ Model (deliberately dead-simple, no database):
|
||||
* 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's
|
||||
age is measured from the *newest* mtime in its tree, so it lives while it's being
|
||||
worked on and self-destructs TTL hours after the last activity.
|
||||
* KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt
|
||||
from the sweep and renders in its own lane above the ephemeral grid. That is the
|
||||
home for durable operator-facing boards — chiefly the standing link board agent
|
||||
sessions post to, whose whole purpose is to survive longer than the scrollback
|
||||
it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody
|
||||
inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep.
|
||||
|
||||
State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point.
|
||||
"""
|
||||
@@ -57,6 +63,13 @@ MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
|
||||
TEXT_EXTS = {".txt", ".text", ".log"}
|
||||
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
|
||||
|
||||
# Sentinel dotfile that exempts a booth from the TTL sweep — see the "kept
|
||||
# booths" note in the module docstring. A dotfile because the existing listing
|
||||
# code already skips dotfiles, so it costs nothing in item counts or galleries,
|
||||
# and because `touch`/`rm` is the entire user interface: no flag to remember, no
|
||||
# state anywhere but the filesystem.
|
||||
KEEP_MARKER = ".forever"
|
||||
|
||||
|
||||
def doc_kind(name: str) -> str | None:
|
||||
"""'markdown' | 'text' | None — a booth file viewable as a readable page."""
|
||||
@@ -135,14 +148,30 @@ def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||
|
||||
|
||||
def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool:
|
||||
"""Pure age question. Deliberately does NOT consider the keep sentinel.
|
||||
|
||||
Expiry arithmetic (what `expires_in` renders) and reaper policy (what
|
||||
actually gets deleted) are kept apart so they cannot drift into each other.
|
||||
Only `sweep_once` honours the pin.
|
||||
"""
|
||||
return booth_age_seconds(path, now) > ttl_seconds
|
||||
|
||||
|
||||
def is_kept(path: Path) -> bool:
|
||||
"""True if this booth carries the keep sentinel and must never be swept."""
|
||||
return (path / KEEP_MARKER).exists()
|
||||
|
||||
|
||||
def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]:
|
||||
"""Wipe every direct-child booth older than the TTL. Returns names wiped.
|
||||
|
||||
Only ever removes direct children of data_dir (never data_dir itself), and
|
||||
skips dotfolders so a stray control dir can opt out.
|
||||
|
||||
A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
|
||||
the one escape hatch from the 24h contract, and it is opt-in per booth: the
|
||||
default stays ephemeral, so nobody inherits a cleanup chore they did not ask
|
||||
for. Removing the sentinel hands the booth straight back to the sweeper.
|
||||
"""
|
||||
wiped: list[str] = []
|
||||
if not data_dir.is_dir():
|
||||
@@ -151,6 +180,8 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
if not child.is_dir() or child.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if is_kept(child):
|
||||
continue
|
||||
if is_expired(child, ttl_seconds, now):
|
||||
shutil.rmtree(child)
|
||||
wiped.append(child.name)
|
||||
@@ -185,6 +216,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"thumb_url": thumb_url,
|
||||
"has_index": (child / "index.html").is_file(),
|
||||
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||||
"kept": is_kept(child),
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
}
|
||||
@@ -454,7 +486,12 @@ def create_app(
|
||||
app = FastAPI(title="The Booth", lifespan=lifespan)
|
||||
|
||||
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
|
||||
base_ctx = {"ttl_hours": ttl_display, "host": host_label, "data_dir": str(data_dir)}
|
||||
base_ctx = {
|
||||
"ttl_hours": ttl_display,
|
||||
"host": host_label,
|
||||
"data_dir": str(data_dir),
|
||||
"keep_marker": KEEP_MARKER, # shown in the kept lane so the mechanism is discoverable
|
||||
}
|
||||
|
||||
def resolve_booth(name: str) -> Path:
|
||||
if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
|
||||
@@ -471,8 +508,20 @@ def create_app(
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
# Two lanes, split here rather than in the template: kept boards are a
|
||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
||||
# operator-facing — and burying them in a feed that turns over daily is
|
||||
# exactly how they would get lost, which is the problem they exist to
|
||||
# solve. Kept renders first.
|
||||
everything = list_booths(data_dir, ttl_seconds)
|
||||
return templates.TemplateResponse(
|
||||
request, "index.html", {**base_ctx, "booths": list_booths(data_dir, ttl_seconds)}
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"kept": [b for b in everything if b["kept"]],
|
||||
"booths": [b for b in everything if not b["kept"]],
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -108,6 +108,21 @@
|
||||
.card .thumb{position:relative}
|
||||
.thumb .badge{position:absolute;top:.5rem;left:.5rem;box-shadow:var(--shadow-2)}
|
||||
|
||||
/* Kept lane. The accent is a 2px TOP edge in aurora blue — the one accent
|
||||
border Australis sanctions (never a coloured left border), and it marks the
|
||||
card as featured without changing its fill, so kept and ephemeral still
|
||||
read as the same family of object. */
|
||||
.lane-head{margin:1.9rem 0 .8rem;font-family:var(--font-mono);font-size:.68rem;font-weight:600;
|
||||
letter-spacing:.14em;text-transform:uppercase;color:var(--aus-bright-cyan);
|
||||
display:flex;align-items:center;gap:.7rem}
|
||||
.lane-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
|
||||
.lane-note{font-weight:400;letter-spacing:.06em;color:var(--fg-3);text-transform:none}
|
||||
.lane-note code{font-size:.95em;color:var(--fg-2)}
|
||||
.kept-grid{margin-bottom:.4rem}
|
||||
.card-kept{border-top:2px solid var(--aus-blue)}
|
||||
.card-kept:hover{border-color:var(--aus-blue);border-top-color:var(--aus-bright-blue)}
|
||||
.badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)}
|
||||
|
||||
.pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle);
|
||||
border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well);
|
||||
font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)}
|
||||
@@ -202,7 +217,7 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="dot"></span><span class="name">The Booth</span></a>
|
||||
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h</span>
|
||||
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
<footer class="foot">
|
||||
|
||||
@@ -10,10 +10,48 @@
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
|
||||
{% if kept %}
|
||||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||||
operator-facing things (the agent link board, standing reports) and the
|
||||
point of the lane is that they cannot be lost in a feed that turns over
|
||||
every day. No countdown — they have no expiry to advertise. #}
|
||||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||||
<div class="grid kept-grid">
|
||||
{% for b in kept %}
|
||||
<article class="card card-kept">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
<span class="badge badge-kept">★ kept</span>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
</div>
|
||||
{# No × here. Wiping a kept board should be a deliberate act — remove the
|
||||
sentinel first (it rejoins the sweep), or delete the folder by hand. A
|
||||
one-click wipe next to the durable stuff is a footgun. #}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not booths %}
|
||||
{% if not kept %}
|
||||
<div class="empty">
|
||||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for b in booths %}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# booth — post media to The Booth (dead simple). A booth is just a folder under
|
||||
# $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL back.
|
||||
# booth — post media and links to The Booth (dead simple). A booth is just a
|
||||
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
|
||||
# back.
|
||||
#
|
||||
# booth new <name> make an empty booth, print its URL
|
||||
# booth add <name> <file>... copy files into a booth (creates it), print URL
|
||||
# booth url <name> print a booth's URL
|
||||
# booth ls list booths
|
||||
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
||||
# booth new <name> make an empty booth, print its URL
|
||||
# booth add <name> <file>... copy files into a booth (creates it), print URL
|
||||
# booth url <name> print a booth's URL
|
||||
# booth ls list booths (kept ones marked ★)
|
||||
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
||||
#
|
||||
# booth keep <name> exempt a booth from the 24h sweep, forever
|
||||
# booth unkeep <name> hand it back to the sweeper
|
||||
# booth link <url> [description] append a link to the standing link board
|
||||
#
|
||||
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
||||
# activity — that is the contract, and it is why nobody has to clean up after
|
||||
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
|
||||
# sweep and moves it into its own lane at the top of the index. Use it for
|
||||
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
|
||||
# the sentinel, so putting a board back under the sweeper costs nothing.
|
||||
#
|
||||
# `link` is the reason the exception exists: agent sessions hand the operator
|
||||
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
||||
# instead, with provenance, so they outlive the session that produced them.
|
||||
#
|
||||
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
|
||||
# rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||
@@ -14,8 +30,13 @@ set -euo pipefail
|
||||
|
||||
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
|
||||
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
|
||||
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
|
||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||
|
||||
usage() { echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>}" >&2; exit 2; }
|
||||
usage() {
|
||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
@@ -36,12 +57,46 @@ case "$cmd" in
|
||||
echo "$URL/b/$1/"
|
||||
;;
|
||||
ls)
|
||||
ls -1 -- "$DATA" 2>/dev/null || true
|
||||
[ -d "$DATA" ] || exit 0
|
||||
for d in "$DATA"/*/; do
|
||||
[ -d "$d" ] || continue
|
||||
n="$(basename -- "$d")"
|
||||
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
|
||||
done
|
||||
;;
|
||||
rm)
|
||||
[ $# -ge 1 ] || usage
|
||||
rm -rf -- "${DATA:?}/$1"
|
||||
echo "wiped $1"
|
||||
;;
|
||||
keep)
|
||||
[ $# -ge 1 ] || usage
|
||||
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
|
||||
: > "$DATA/$1/$KEEP"
|
||||
echo "kept (exempt from the sweep): $URL/b/$1/"
|
||||
;;
|
||||
unkeep)
|
||||
[ $# -ge 1 ] || usage
|
||||
rm -f -- "$DATA/$1/$KEEP"
|
||||
echo "unkept — $1 rejoins the 24h sweep"
|
||||
;;
|
||||
link)
|
||||
[ $# -ge 1 ] || usage
|
||||
link_url="$1"; shift
|
||||
desc="${*:-}"
|
||||
board="$DATA/$LINKS_BOARD"
|
||||
mkdir -p -- "$board"
|
||||
: > "$board/$KEEP" # the board is durable by definition
|
||||
# Provenance, because a bare URL is unreadable three days later: who posted
|
||||
# it, from where, and when.
|
||||
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||||
when="$(date '+%Y-%m-%d %H:%M')"
|
||||
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
|
||||
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
|
||||
# matters here precisely because many agents post to one board.
|
||||
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
|
||||
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
|
||||
echo "$URL/b/$LINKS_BOARD/"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import (
|
||||
FAVICON_LINK,
|
||||
KEEP_MARKER,
|
||||
build_gallery,
|
||||
classify,
|
||||
create_app,
|
||||
@@ -14,6 +15,8 @@ from booth.app import (
|
||||
generate_pickup_id,
|
||||
human_dur,
|
||||
is_expired,
|
||||
is_kept,
|
||||
list_booths,
|
||||
render_doc,
|
||||
safe_upload_name,
|
||||
sweep_once,
|
||||
@@ -528,3 +531,122 @@ def test_view_single_image_no_nav(client):
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
href: http://10.100.10.50:8090/
|
||||
icon: mdi-filmstrip
|
||||
siteMonitor: http://10.100.10.50:8090/healthz
|
||||
description: Ephemeral media drop + upload-for-pickup (human-readable ids) — nh3-dev, 24h TTL
|
||||
description: Media drop + upload-for-pickup + the standing agent link board — nh3-dev, 24h TTL except kept boards
|
||||
- Voice Design Studio:
|
||||
href: http://10.100.79.3:8216/
|
||||
icon: mdi-microphone
|
||||
|
||||
Reference in New Issue
Block a user