"""Marks: one primitive for operator judgment attached to an artifact. `pick` (the session asks), `note` (the operator tells), `flag` (the operator points at the good ones) — three shapes, one storage model, one read path. See docs/contracts/u2_marks.contract.md. """ import ast import json import pathlib import sys import pytest from booth.asks import ASK_SUFFIX, AskError, build_answer, normalize_ask from booth.marks import ( MARKS_FILE, Mark, answer_pick, as_dict, declare_pick, marks_for, open_marks, ) def _single(): return {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]} def _multi(): return { "title": "R18 batch review", "questions": [ {"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"]}, {"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}, ], } # ---- slice 1: storage, the record, the read path ---------------------------- def test_pick_round_trips_through_marks_json(tmp_path): """A declared pick lands in ONE file and reads back normalized.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) assert (booth / MARKS_FILE).is_file() marks = marks_for(booth) assert len(marks) == 1 m = marks[0] assert isinstance(m, Mark) assert (m.id, m.shape, m.target) == ("winner", "pick", None) assert m.answer is None assert m.prompt == "Which render wins?" assert m.multi is False # normalize_ask emits `notes` (bool); the record carries it as notes_enabled (SR-2) assert m.notes_enabled is True assert m.notes_label == "notes" assert [o["id"] for o in m.options] == ["A — baseline", "B — async"] assert len(m.questions) == 1 and m.questions[0]["key"] is None def test_marks_json_is_a_dotfile_so_it_is_not_an_item(tmp_path): """The dotfile skip in booth_items already covers it. Asserted, not assumed.""" from booth.items import booth_items booth = tmp_path / "b" booth.mkdir() (booth / "a.png").write_bytes(b"\x89PNG") declare_pick(booth, "winner", _single()) assert MARKS_FILE.startswith(".") assert [i.rel for i in booth_items(booth)] == ["a.png"] def test_corrupt_marks_json_renders_as_empty(tmp_path): """A booth with no marks and a booth with a broken mark file both render as 'no marks'. Neither is a 500.""" booth = tmp_path / "b" booth.mkdir() (booth / MARKS_FILE).write_text("{not json at all") assert marks_for(booth) == [] def test_missing_booth_and_missing_file_are_empty(tmp_path): assert marks_for(tmp_path / "nope") == [] (tmp_path / "b").mkdir() assert marks_for(tmp_path / "b") == [] def test_broken_pick_declaration_surfaces_error(tmp_path): """A declaration that cannot be rendered comes back with `error` set, so the page can SAY so rather than silently hiding a question the session believes it posted. Preserved from list_asks.""" booth = tmp_path / "b" booth.mkdir() (booth / MARKS_FILE).write_text(json.dumps({ "version": 1, "marks": [{"id": "bad", "shape": "pick", "target": None, "created": "2026-09-21T10:00:00-07:00", "declaration": {"prompt": "no options"}, "answer": None}], })) m = marks_for(booth)[0] assert m.error is not None assert m.id == "bad" def test_declare_pick_validates_before_writing(tmp_path): """A session cannot land a question the renderer would refuse.""" booth = tmp_path / "b" booth.mkdir() with pytest.raises(AskError): declare_pick(booth, "bad", {"prompt": "only one", "options": ["just me"]}) assert not (booth / MARKS_FILE).exists() def test_declare_pick_rejects_a_bad_id(tmp_path): booth = tmp_path / "b" booth.mkdir() with pytest.raises(AskError): declare_pick(booth, "../escape", _single()) def test_redeclaring_a_pick_clears_its_answer(tmp_path): """The question changed, so the old judgment is not an answer to it.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) answer_pick(booth, "winner", "A — baseline") assert marks_for(booth)[0].answer is not None declare_pick(booth, "winner", {"prompt": "Which one now?", "options": ["X", "Y"]}) marks = marks_for(booth) assert len(marks) == 1 assert marks[0].answer is None assert marks[0].prompt == "Which one now?" def test_marks_are_oldest_first_by_created(tmp_path): booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "first", _single()) declare_pick(booth, "second", _single()) assert [m.id for m in marks_for(booth)] == ["first", "second"] # ---- openness: THE one predicate (INV-2) ------------------------------------ def test_open_marks_counts_an_unanswered_pick(tmp_path): booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) assert [m.id for m in open_marks(marks_for(booth))] == ["winner"] def test_open_marks_drops_a_complete_answer(tmp_path): booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) answer_pick(booth, "winner", "A — baseline") assert open_marks(marks_for(booth)) == [] def test_a_partial_answer_counts_as_open(tmp_path): """SR-7, the declared behaviour change. Today the index badge tests `answer is None` and so reports a half-answered four-question pick as closed, while the panel renders it `◐ partial`. A booth that still owes an answer is open — which is also what makes U4's pin rule correct.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "batch", _multi()) answer_pick(booth, "batch", {"q1": "keep"}) # q2 left alone m = marks_for(booth)[0] assert m.answer is not None assert m.answer["complete"] is False assert m.answer["unanswered"] == ["q2"] assert [x.id for x in open_marks(marks_for(booth))] == ["batch"] # ---- the pick semantics, preserved by NOT rewriting them (INV-4) ------------ def test_partial_answer_is_recorded_not_refused(tmp_path): """Operator ruling 2026-09-09, as a regression test.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "batch", _multi()) m = answer_pick(booth, "batch", {"q1": "keep", "q2": ""}) assert m.answer["answers"]["q1"]["label"] == "keep" assert "q2" not in m.answer["answers"] assert m.answer["unanswered"] == ["q2"] assert m.answer["complete"] is False def test_nothing_to_record_is_still_refused(tmp_path): booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "batch", _multi()) with pytest.raises(AskError): answer_pick(booth, "batch", {"q1": "", "q2": ""}) def test_an_offered_but_invalid_option_is_an_error(tmp_path): """A broken form, not a skipped question.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) with pytest.raises(AskError): answer_pick(booth, "winner", "C — never offered") def test_answering_an_unknown_pick_raises(tmp_path): """SR-4: load_ask used to own this AskError. Extracting build_answer moved the path to the caller, and a stale form POST must be a 400, not a silent no-op.""" booth = tmp_path / "b" booth.mkdir() with pytest.raises(AskError): answer_pick(booth, "ghost", "A — baseline") def test_re_answering_overwrites(tmp_path): """The mark is the CURRENT judgment, not a log.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) answer_pick(booth, "winner", "A — baseline") m = answer_pick(booth, "winner", "B — async") assert m.answer["label"] == "B — async" assert len(marks_for(booth)) == 1 def test_build_answer_matches_the_old_sidecar_document(tmp_path): """INV-4. build_answer is write_answer's logic with the I/O removed; the document it produces is the one the sidecar carried.""" ask = normalize_ask(_single(), "winner") doc = build_answer(ask, "B — async", notes="less banding", who="10.0.0.1") assert doc["stem"] == "winner" assert doc["choice"] == "B — async" assert doc["choice_index"] == 1 assert doc["label"] == "B — async" assert doc["notes"] == "less banding" assert doc["complete"] is True assert doc["unanswered"] == [] assert doc["answered_by"] == "10.0.0.1" assert "answered_at" in doc def test_build_answer_sources_stem_from_the_ask(tmp_path): """SR-3: the extraction takes a pre-loaded ask and reads `stem` off it.""" ask = normalize_ask(_multi(), "batch") doc = build_answer(ask, {"q1": "keep", "q2": "drop"}) assert doc["stem"] == "batch" assert doc["complete"] is True # ---- the JSON boundary (SR-8) ----------------------------------------------- def test_as_dict_round_trips_through_json(tmp_path): booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "winner", _single()) m = marks_for(booth)[0] out = json.loads(json.dumps(as_dict(m))) assert out["id"] == "winner" assert out["shape"] == "pick" assert out["answer"] is None # ---- the stdlib-only invariant (INV-5) -------------------------------------- @pytest.mark.parametrize("module", ["marks", "asks", "links"]) def test_stdlib_only(module): """INV-5. scripts/booth imports these under the system python3 with NO venv, through a `python3 -c` heredoc that no AST extractor can see — so nothing but this test stands between a casual third-party import and `booth ask` breaking on every fleet host.""" src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.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) and node.level == 0 and node.module: roots.add(node.module.split(".")[0]) outside = {r for r in roots if r != "booth" and r not in sys.stdlib_module_names} assert not outside, f"booth/{module}.py imports non-stdlib: {sorted(outside)}" # ---- slice 2: flag — the shape with no existing code ------------------------ def test_flag_is_an_upsert_and_unflag_removes(tmp_path): """One item has at most one flag state, so flagging twice is idempotent and clearing REMOVES the mark rather than storing `value: false` — an absent flag and a false flag are the same judgment.""" from booth.marks import set_flag booth = tmp_path / "b" booth.mkdir() m = set_flag(booth, "DSC03389.jpg", True, who="10.0.0.1") assert m is not None and m.shape == "flag" and m.flagged is True assert m.target == "DSC03389.jpg" set_flag(booth, "DSC03389.jpg", True) # again assert len(marks_for(booth)) == 1 assert set_flag(booth, "DSC03389.jpg", False) is None assert marks_for(booth) == [] def test_unflagging_something_unflagged_is_not_an_error(tmp_path): from booth.marks import set_flag booth = tmp_path / "b" booth.mkdir() assert set_flag(booth, "nope.png", False) is None def test_flags_on_different_items_coexist(tmp_path): from booth.marks import marks_for_target, set_flag booth = tmp_path / "b" booth.mkdir() set_flag(booth, "a.png", True) set_flag(booth, "b.png", True) marks = marks_for(booth) assert len(marks) == 2 assert [m.id for m in marks_for_target(marks, "a.png")] == ["flag:a.png"] def test_a_flag_never_counts_as_open(tmp_path): """Only a pick can owe an answer. A flag is born resolved.""" from booth.marks import set_flag booth = tmp_path / "b" booth.mkdir() set_flag(booth, "a.png", True) assert open_marks(marks_for(booth)) == [] def test_a_flag_target_cannot_escape_the_booth(tmp_path): from booth.marks import set_flag booth = tmp_path / "b" booth.mkdir() for bad in ("../outside.png", "/etc/passwd", ""): with pytest.raises(AskError): set_flag(booth, bad, True) # ---- slice 3: note ---------------------------------------------------------- def test_note_allows_several_per_target(tmp_path): """An item may carry several notes, so each gets a generated id.""" from booth.marks import marks_for_target, write_note booth = tmp_path / "b" booth.mkdir() write_note(booth, "a.png", "banding in the gradient") write_note(booth, "a.png", "and the highlight clips") marks = marks_for(booth) assert len(marks) == 2 assert {m.id for m in marks} == {"note-1", "note-2"} assert [m.text for m in marks_for_target(marks, "a.png")] == [ "banding in the gradient", "and the highlight clips"] def test_booth_level_note_has_target_none(tmp_path): """The booth itself is a legal target.""" from booth.marks import marks_for_target, write_note booth = tmp_path / "b" booth.mkdir() write_note(booth, None, "ship v3, not v4") m = marks_for(booth)[0] assert m.target is None assert marks_for_target(marks_for(booth), None) == [m] def test_an_empty_note_is_refused(tmp_path): """Nothing to record — the same posture as an empty pick submission.""" from booth.marks import write_note booth = tmp_path / "b" booth.mkdir() for blank in ("", " ", "\n\n", None): with pytest.raises(AskError): write_note(booth, "a.png", blank) assert marks_for(booth) == [] def test_a_note_never_counts_as_open(tmp_path): from booth.marks import write_note booth = tmp_path / "b" booth.mkdir() write_note(booth, "a.png", "seen it") assert open_marks(marks_for(booth)) == [] def test_delete_mark_is_the_operators_undo(tmp_path): from booth.marks import delete_mark, write_note booth = tmp_path / "b" booth.mkdir() write_note(booth, "a.png", "wrong call") assert delete_mark(booth, "note-1") is True assert marks_for(booth) == [] assert delete_mark(booth, "note-1") is False # ---- the lock (INV-6) ------------------------------------------------------- def test_concurrent_writers_do_not_lose_a_mark(tmp_path): """INV-6. Two processes writing the same booth's marks at once: both land. Processes, not threads — the flock is what is under test, and a thread-level test would pass on the GIL alone.""" import subprocess import sys as _sys booth = tmp_path / "b" booth.mkdir() repo = str(pathlib.Path(__file__).parent.parent) prog = ( "import sys; sys.path.insert(0, %r);" "from booth.marks import set_flag;" "set_flag(%r, sys.argv[1], True)" % (repo, str(booth)) ) procs = [subprocess.Popen([_sys.executable, "-c", prog, f"item{i}.png"]) for i in range(8)] for p in procs: assert p.wait() == 0 got = {m.target for m in marks_for(booth)} assert got == {f"item{i}.png" for i in range(8)}, f"lost a mark: {sorted(got)}" # ---- slice 4: the legacy sidecars — imported, NEVER deleted ---------------- def _sidecar(booth, stem, doc, mtime=None): booth.mkdir(parents=True, exist_ok=True) p = booth / f"{stem}{ASK_SUFFIX}" p.write_text(json.dumps(doc)) if mtime is not None: import os as _os _os.utime(p, (mtime, mtime)) return p def test_import_reads_a_sidecar_into_marks(tmp_path): from booth.marks import import_legacy_asks booth = tmp_path / "b" _sidecar(booth, "winner", _single()) got = import_legacy_asks(booth) assert [m.id for m in got] == ["winner"] m = marks_for(booth)[0] assert m.shape == "pick" assert m.prompt == "Which render wins?" assert m.answer is None def test_import_is_idempotent_and_keeps_the_sidecar(tmp_path): """INV-7. The ROADMAP's non-goal is explicit: no migration that deletes anything. Four of these are live and unanswered right now.""" from booth.marks import import_legacy_asks booth = tmp_path / "b" p = _sidecar(booth, "winner", _single()) import_legacy_asks(booth) assert import_legacy_asks(booth) == [] # second run is a no-op assert len(marks_for(booth)) == 1 assert p.is_file(), "the importer deleted the operator's sidecar" def test_import_carries_an_existing_answer(tmp_path): from booth.asks import ANSWER_SUFFIX, build_answer, normalize_ask from booth.marks import import_legacy_asks booth = tmp_path / "b" _sidecar(booth, "winner", _single()) # The legacy answer sidecar, written the way the retired `write_answer` did. doc = build_answer(normalize_ask(_single(), "winner"), "B — async", notes="less banding") (booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(doc)) import_legacy_asks(booth) m = marks_for(booth)[0] assert m.answer["label"] == "B — async" assert m.answer["notes"] == "less banding" assert open_marks(marks_for(booth)) == [] def test_import_does_not_clobber_a_newer_answer(tmp_path): """A stem already present as a mark is skipped outright.""" from booth.marks import import_legacy_asks booth = tmp_path / "b" _sidecar(booth, "winner", _single()) declare_pick(booth, "winner", _single()) answer_pick(booth, "winner", "A — baseline") assert import_legacy_asks(booth) == [] assert marks_for(booth)[0].answer["label"] == "A — baseline" def test_import_preserves_mtime_ordering(tmp_path): """SR-5. list_asks ordered by file mtime; marks_for orders by the stored `created`. Seed it from the sidecar's mtime or the four live asks silently reorder on import.""" from booth.marks import import_legacy_asks booth = tmp_path / "b" _sidecar(booth, "later", _single(), mtime=2_000_000_000) _sidecar(booth, "earlier", _single(), mtime=1_000_000_000) import_legacy_asks(booth) assert [m.id for m in marks_for(booth)] == ["earlier", "later"] def test_import_surfaces_a_broken_sidecar_instead_of_dropping_it(tmp_path): """A question the session believes it posted stays visible.""" from booth.marks import import_legacy_asks booth = tmp_path / "b" _sidecar(booth, "broke", {"prompt": "no options here"}) import_legacy_asks(booth) m = marks_for(booth)[0] assert m.id == "broke" assert m.error is not None def test_import_ignores_a_bad_stem(tmp_path): """A filename that could not have been written by `booth ask` is left alone rather than becoming a mark with an unusable id.""" from booth.marks import import_legacy_asks booth = tmp_path / "b" booth.mkdir() (booth / f"..evil{ASK_SUFFIX}").write_text(json.dumps(_single())) assert import_legacy_asks(booth) == [] def test_import_on_a_booth_with_nothing_is_empty(tmp_path): from booth.marks import import_legacy_asks assert import_legacy_asks(tmp_path / "nope") == [] (tmp_path / "b").mkdir() assert import_legacy_asks(tmp_path / "b") == [] # ---- the deterministic-order invariant (operator directive, 2026-09-21) ------ def test_marks_order_is_deterministic_across_reads(tmp_path): """Every ordered collection the Booth renders needs a STATED rule, because the operator judges positionally — "the third one", "the one after the banded one" — and an order that moves between renders misfiles the judgment instead of crashing. Marks order by `(created, id)`.""" booth = tmp_path / "b" booth.mkdir() declare_pick(booth, "b-pick", _single()) from booth.marks import set_flag, write_note write_note(booth, "z.png", "later note") set_flag(booth, "a.png", True) write_note(booth, None, "booth note") first = [m.id for m in marks_for(booth)] for _ in range(5): assert [m.id for m in marks_for(booth)] == first def test_same_second_marks_tie_break_on_id(tmp_path): """The `created` stamp is second-resolution, so two marks written inside one second would order by whatever json listed them. The id is the tie-break.""" booth = tmp_path / "b" booth.mkdir() stamp = "2026-09-21T12:00:00-07:00" (booth / MARKS_FILE).write_text(json.dumps({"version": 1, "marks": [ {"id": "zeta", "shape": "note", "target": None, "created": stamp, "text": "z"}, {"id": "alpha", "shape": "note", "target": None, "created": stamp, "text": "a"}, ]})) assert [m.id for m in marks_for(booth)] == ["alpha", "zeta"] # and reversing the stored order changes nothing (booth / MARKS_FILE).write_text(json.dumps({"version": 1, "marks": [ {"id": "alpha", "shape": "note", "target": None, "created": stamp, "text": "a"}, {"id": "zeta", "shape": "note", "target": None, "created": stamp, "text": "z"}, ]})) assert [m.id for m in marks_for(booth)] == ["alpha", "zeta"] def test_open_marks_preserves_the_read_order(tmp_path): """The filtered view must not re-order — the panel and the badge have to agree with the list they came from.""" booth = tmp_path / "b" booth.mkdir() for mid in ("c", "a", "b"): declare_pick(booth, mid, _single()) marks = marks_for(booth) assert [m.id for m in open_marks(marks)] == [m.id for m in marks] # ---- INV-3: the judgment travels, like the caption -------------------------- def _png(p): p.write_bytes( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00" b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) @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 test_zoom_carries_the_marks(client): """U1's rule, extended from the caption to the judgment: full size is where the operator is actually deciding, so the flag state and the notes have to be there.""" from booth.marks import set_flag, write_note c, data = client b = data / "b" b.mkdir() _png(b / "a.png") set_flag(b, "a.png", True) write_note(b, "a.png", "banding in the gradient") html = c.get("/b/b/view?f=a.png").text assert "banding in the gradient" in html assert "✔ flagged" in html assert 'action="/b/b/flag"' in html def test_the_tile_carries_the_marks(client): from booth.marks import set_flag, write_note c, data = client b = data / "b" b.mkdir() _png(b / "a.png") _png(b / "b.png") set_flag(b, "a.png", True) write_note(b, "b.png", "this one is soft") html = c.get("/b/b/").text assert "✔ flagged" in html and "○ flag" in html # a flagged one and an unflagged one assert "this one is soft" in html assert 'id="item-a.png"' in html def test_flag_and_note_round_trip_through_the_browser(client): c, data = client b = data / "b" b.mkdir() _png(b / "a.png") assert c.post("/b/b/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False).status_code == 303 assert c.post("/b/b/note", data={"target": "a.png", "text": "soft"}, follow_redirects=False).status_code == 303 doc = c.get("/b/b/marks.json").json() assert {m["shape"] for m in doc["marks"]} == {"flag", "note"} assert doc["open"] == [] # neither owes an answer # and the operator can withdraw one note_id = next(m["id"] for m in doc["marks"] if m["shape"] == "note") assert c.post("/b/b/unmark", data={"mark": note_id}, follow_redirects=False).status_code == 303 assert [m["shape"] for m in c.get("/b/b/marks.json").json()["marks"]] == ["flag"] def test_a_no_op_write_does_not_touch_the_booth(tmp_path): """A booth's TTL is measured from its newest mtime, INCLUDING dotfiles — so writing `.marks.json` resets the clock. Marking IS activity and should; a write that changes nothing is not activity and must not. Unflagging something never flagged would otherwise keep a dead booth alive.""" from booth.marks import delete_mark, set_flag booth = tmp_path / "b" booth.mkdir() assert set_flag(booth, "ghost.png", False) is None assert delete_mark(booth, "nothing") is False assert not (booth / MARKS_FILE).exists(), "a no-op write created the mark file" def test_a_real_write_then_a_no_op_leaves_the_file_alone(tmp_path): import os from booth.marks import set_flag booth = tmp_path / "b" booth.mkdir() set_flag(booth, "a.png", True) path = booth / MARKS_FILE os.utime(path, (1_000_000_000, 1_000_000_000)) before = path.stat().st_mtime set_flag(booth, "a.png", True) # idempotent: already flagged assert path.stat().st_mtime == before, "an idempotent flag rewrote the file"