feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation.
This commit is contained in:
+10
-9
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
|
||||
assert booth["count"] == 1
|
||||
|
||||
|
||||
def test_index_separates_kept_from_ephemeral(client):
|
||||
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
|
||||
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
|
||||
This test used to require a kept LANE rendered before the ephemeral grid.
|
||||
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
|
||||
nothing — and orders by what needs the operator instead (tested in
|
||||
tests/test_flow.py). What survives is the fact: a kept booth still says it
|
||||
is kept, on its own row."""
|
||||
c, data = client
|
||||
_touch(data / "scratch" / "a.png")
|
||||
_touch(data / "links" / "a.png")
|
||||
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
|
||||
|
||||
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")
|
||||
assert 'data-booth="links" data-kept="1"' in html
|
||||
assert 'data-booth="scratch" data-kept="0"' in html
|
||||
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
|
||||
|
||||
|
||||
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -224,3 +225,185 @@ def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landin
|
||||
r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == landing
|
||||
|
||||
|
||||
# ---- C4: the Desk -------------------------------------------------------------
|
||||
|
||||
def _at(path: pathlib.Path, t: float) -> None:
|
||||
import os
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _desk(body: str) -> dict[str, list[str]]:
|
||||
"""section -> the booths it renders, in render order."""
|
||||
out = {}
|
||||
for sec, inner in re.findall(r'<section class="desk-sec"[^>]*data-section="(\w+)"[^>]*>(.*?)</section>',
|
||||
body, re.S):
|
||||
out[sec] = re.findall(r'<article class="desk-row[^"]*" data-booth="([^"]+)"', inner)
|
||||
return out
|
||||
|
||||
|
||||
def test_the_desk_triages_needs_you_then_new_then_everything_else(tmp_path):
|
||||
"""The tracer for C4: three sections, always in this order, each booth in
|
||||
exactly one of them."""
|
||||
from booth.marks import declare_pick
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
asks = _booth(tmp_path, "asks", {"a.png": PNG})
|
||||
declare_pick(asks, "q", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
_booth(tmp_path, "fresh", {"a.png": PNG}) # never looked at
|
||||
seen = _booth(tmp_path, "seen", {"a.png": PNG})
|
||||
_at(seen / "a.png", t0)
|
||||
(seen / ".viewed").write_bytes(b"")
|
||||
_at(seen / ".viewed", t0 + 60) # looked AFTER it landed
|
||||
body = _client(tmp_path).get("/").text
|
||||
assert _desk(body) == {"needs": ["asks"], "new": ["fresh"], "rest": ["seen"]}
|
||||
assert body.index('data-section="needs"') < body.index('data-section="new"') \
|
||||
< body.index('data-section="rest"')
|
||||
|
||||
|
||||
def _set_created(booth: pathlib.Path, mark_id: str, created: str) -> None:
|
||||
import json
|
||||
doc = json.loads((booth / ".marks.json").read_text())
|
||||
for m in doc["marks"]:
|
||||
if m["id"] == mark_id:
|
||||
m["created"] = created
|
||||
(booth / ".marks.json").write_text(json.dumps(doc))
|
||||
|
||||
|
||||
def test_needs_you_orders_by_the_parsed_stamp_not_the_string(tmp_path):
|
||||
"""`created` is a string. As text, 11:00-07:00 sorts before 12:30-05:00;
|
||||
as time it is 18:00Z against 17:30Z, so the second question is OLDER and
|
||||
leads. An unparseable stamp, and a booth whose marks cannot be read, sort
|
||||
after every parseable one; name breaks the tie."""
|
||||
from booth.marks import declare_pick
|
||||
for n in ("alpha", "bravo", "charlie", "delta"):
|
||||
b = _booth(tmp_path, n, {"a.png": PNG})
|
||||
if n != "delta":
|
||||
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
|
||||
_set_created(tmp_path / "alpha", "q", "2026-09-22T11:00:00-07:00")
|
||||
_set_created(tmp_path / "bravo", "q", "2026-09-22T12:30:00-05:00")
|
||||
_set_created(tmp_path / "charlie", "q", "last tuesday")
|
||||
(tmp_path / "delta" / ".marks.json").write_text("{not json")
|
||||
body = _client(tmp_path).get("/").text
|
||||
assert _desk(body)["needs"] == ["bravo", "alpha", "charlie", "delta"]
|
||||
assert "marks unreadable" in body
|
||||
|
||||
|
||||
def test_flags_and_notes_alone_do_not_make_a_booth_need_you(tmp_path):
|
||||
"""Needs-you means a question TO the operator. Flags and notes are the
|
||||
operator's own judgment."""
|
||||
from booth.marks import write_note
|
||||
b = _booth(tmp_path, "judged", {"a.png": PNG})
|
||||
set_flag(b, "a.png", True)
|
||||
write_note(b, None, "done here")
|
||||
assert "needs" not in _desk(_client(tmp_path).get("/").text)
|
||||
|
||||
|
||||
def test_new_since_you_looked_reads_content_not_activity(tmp_path):
|
||||
"""INV-5, the two clocks. A flag made after the last look is ACTIVITY and
|
||||
must not make a booth look new; a file landed after the last look is
|
||||
CONTENT and must. Newest content first."""
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
judged = _booth(tmp_path, "judged", {"a.png": PNG})
|
||||
delivered = _booth(tmp_path, "delivered", {"a.png": PNG})
|
||||
later = _booth(tmp_path, "later", {"a.png": PNG})
|
||||
for b in (judged, delivered, later):
|
||||
_at(b / "a.png", t0)
|
||||
(b / ".viewed").write_bytes(b"")
|
||||
_at(b / ".viewed", t0 + 10)
|
||||
set_flag(judged, "a.png", True) # activity after the look
|
||||
(delivered / "b.png").write_bytes(PNG)
|
||||
_at(delivered / "b.png", t0 + 20) # content after the look
|
||||
(later / "b.png").write_bytes(PNG)
|
||||
_at(later / "b.png", t0 + 30) # ...and later still
|
||||
desk = _desk(_client(tmp_path).get("/").text)
|
||||
assert desk["new"] == ["later", "delivered"]
|
||||
assert desk["rest"] == ["judged"]
|
||||
|
||||
|
||||
def test_an_empty_section_renders_nothing_and_a_full_one_renders(tmp_path):
|
||||
"""The negative half of the kept-lane pair, carried forward: a section with
|
||||
no booths has no heading and no box. Checked against the element, never a
|
||||
bare word the stylesheet also contains."""
|
||||
c = _client(tmp_path)
|
||||
body = c.get("/").text
|
||||
for sec in ("needs", "new", "rest"):
|
||||
assert f'data-section="{sec}"' not in body
|
||||
assert 'data-panel="benches"' not in body and 'data-panel="bookmarks"' not in body
|
||||
_booth(tmp_path, "fresh", {"a.png": PNG})
|
||||
body = c.get("/").text
|
||||
assert 'data-section="new"' in body
|
||||
assert 'data-section="needs"' not in body and 'data-section="rest"' not in body
|
||||
|
||||
|
||||
def test_a_look_then_a_judgment_leaves_the_booth_out_of_new(tmp_path):
|
||||
"""Through the routes, not hand-set markers. Every Booth write that CREATES
|
||||
a dotfile — `.viewed`, the marks file's temp-and-replace — bumps the booth
|
||||
DIRECTORY's mtime. A `landed_at` that read the directory would make the
|
||||
flag you set after looking read as a fresh delivery."""
|
||||
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
|
||||
b = _booth(tmp_path, "g", {"a.png": PNG})
|
||||
_at(b / "a.png", t0)
|
||||
_at(b, t0)
|
||||
c = _client(tmp_path)
|
||||
assert _desk(c.get("/").text) == {"new": ["g"]}
|
||||
c.get("/b/g/")
|
||||
time.sleep(0.02)
|
||||
# Not following the 303: following it GETs the booth page, which records a
|
||||
# fresh look and would hide the defect this pins. A session writing a mark
|
||||
# from the CLI never looks at the page at all.
|
||||
c.post("/b/g/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
|
||||
assert _desk(c.get("/").text) == {"rest": ["g"]}
|
||||
|
||||
|
||||
def _link(desc: str, url: str, who: str = "x-dev") -> str:
|
||||
return f"- [{desc}]({url}) <sub>· {who} · 2026-09-01 10:00</sub>\n"
|
||||
|
||||
|
||||
def test_the_side_column_shows_live_benches_and_non_booth_bookmarks(tmp_path):
|
||||
"""Benches: non-retired, registry order. Bookmarks: the board the CLI
|
||||
writes, booth URLs left out (a booth announces itself on the Desk), pinned
|
||||
first then newest, capped at eight with the way to the rest."""
|
||||
from booth.benches import set_bench_state, upsert_bench
|
||||
upsert_bench(tmp_path, "http://h:1/", "live one", "a-dev")
|
||||
retired, _ = upsert_bench(tmp_path, "http://h:2/", "old one", "a-dev")
|
||||
set_bench_state(tmp_path, retired.id, "retired")
|
||||
rows = "".join(_link(f"ref {n}", f"http://ref/{n}") for n in range(10))
|
||||
rows += _link("a booth", "http://10.0.0.1:8090/b/somebooth/")
|
||||
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
||||
(board / ".forever").write_bytes(b"")
|
||||
body = _client(tmp_path).get("/").text
|
||||
benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0)
|
||||
assert "live one" in benches and "old one" not in benches
|
||||
marks = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
||||
shown = re.findall(r'class="desk-mark[^"]*" href="([^"]+)"', marks)
|
||||
assert shown == [f"http://ref/{n}" for n in (9, 8, 7, 6, 5, 4, 3, 2)] # newest first, 8
|
||||
assert "all 10 on the board" in marks
|
||||
|
||||
|
||||
def test_a_damaged_bench_registry_says_so_rather_than_rendering_empty(tmp_path):
|
||||
(tmp_path / ".benches.json").write_text("{broken")
|
||||
body = _client(tmp_path).get("/").text
|
||||
panel = re.search(r'data-panel="benches".*?</section>', body, re.S)
|
||||
assert panel and "could not be read" in panel.group(0)
|
||||
|
||||
|
||||
def test_a_row_previews_four_images_keeps_blur_and_counts_flags(tmp_path):
|
||||
"""The originals shown small (no generated thumbnail), the first four in
|
||||
item order, a blurred one still blurred. The flag count is on the row."""
|
||||
from booth.app import set_blurred
|
||||
b = _booth(tmp_path, "g", {f"{n}.png": PNG for n in "abcde"})
|
||||
set_blurred(b, "b.png", True)
|
||||
set_flag(b, "c.png", True)
|
||||
set_flag(b, "e.png", True)
|
||||
body = _client(tmp_path).get("/").text
|
||||
row = re.search(r'<article class="desk-row[^"]*" data-booth="g".*?</article>', body, re.S).group(0)
|
||||
imgs = re.findall(r'<img class="([^"]*)" loading="lazy" src="/b/g/([^"]+)"', row)
|
||||
assert imgs == [("", "a.png"), ("blurred-thumb", "b.png"), ("", "c.png"), ("", "d.png")]
|
||||
assert "2 flagged" in row
|
||||
|
||||
|
||||
def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
|
||||
_booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"})
|
||||
row = re.search(r'data-booth="songs".*?</article>', _client(tmp_path).get("/").text, re.S).group(0)
|
||||
assert "♪ audio" in row and "<img" not in row
|
||||
|
||||
Reference in New Issue
Block a user