Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e41108cd3 |
@@ -258,6 +258,14 @@ declare_pick(booth, "batch", {
|
||||
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
|
||||
```
|
||||
|
||||
**A pick can be about ONE item, not just the booth.** Pass `target` — an item's
|
||||
booth-relative path — and the question renders beside that artifact:
|
||||
|
||||
```python
|
||||
declare_pick(booth, "which-crop", {"prompt": "Which crop?", "options": ["tight", "wide"]},
|
||||
target="v3/DSC03389.jpg")
|
||||
```
|
||||
|
||||
**A partial answer is recorded, not refused.** A question left blank is a
|
||||
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
|
||||
`unanswered`, stays absent from `answers` unless it carried a note, and
|
||||
@@ -272,6 +280,14 @@ files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
|
||||
booth marks-import r18-ab # idempotent; the sidecars stay on disk
|
||||
```
|
||||
|
||||
If the stem is already a mark the declaration is skipped, but a legacy answer
|
||||
still gets adopted, so the operator's recorded choice is never stranded on disk.
|
||||
|
||||
**If a booth's `.marks.json` is damaged**, reads degrade to "no marks" so the page
|
||||
still loads, and every WRITE refuses with a 409 rather than replacing the file —
|
||||
which would otherwise wipe every judgment in that booth. Repair or move the file
|
||||
by hand; nothing deletes it for you.
|
||||
|
||||
## Upload for pickup
|
||||
|
||||
The reverse direction — put files in through the web, pick them up by id:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.2.0` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
Current version: `0.2.1` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
|
||||
## v1 target
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ from booth.asks import ( # noqa: E402
|
||||
)
|
||||
from booth.marks import ( # noqa: E402
|
||||
MARKS_FILE,
|
||||
MarksCorrupt,
|
||||
answer_pick,
|
||||
as_dict,
|
||||
declare_pick,
|
||||
@@ -578,6 +579,25 @@ def create_app(
|
||||
# test needs a handle on the env that the app actually renders with.
|
||||
app.state.templates = templates
|
||||
|
||||
@app.exception_handler(MarksCorrupt)
|
||||
async def _marks_corrupt(request: Request, exc: MarksCorrupt):
|
||||
"""A write was refused because the booth's mark file is damaged.
|
||||
|
||||
409, not 500: the service is fine and the request was well-formed — the
|
||||
state on disk is not, and the refusal is deliberate. Says what to do,
|
||||
because the alternative the operator will otherwise reach for is
|
||||
deleting the file, which is the thing being protected.
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "this booth's .marks.json cannot be read, so nothing was written",
|
||||
"detail": str(exc),
|
||||
"why": "writing would replace every mark in the booth with just this one",
|
||||
"fix": "repair or move the file by hand; the marks panel still renders as empty",
|
||||
},
|
||||
)
|
||||
|
||||
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
|
||||
base_ctx = {
|
||||
"ttl_hours": ttl_display,
|
||||
|
||||
+83
-8
@@ -59,6 +59,20 @@ from booth.asks import (
|
||||
valid_stem,
|
||||
)
|
||||
|
||||
class MarksCorrupt(RuntimeError):
|
||||
"""The mark file exists but cannot be parsed, and a WRITE was attempted.
|
||||
|
||||
The read path is deliberately lenient — `marks_for` returns [] so a review
|
||||
page still loads. The write path must not inherit that leniency: reading a
|
||||
damaged file as "no marks" and then atomically replacing it destroys every
|
||||
judgment in the booth from one click, silently. Shipped in v0.2.0 and found
|
||||
by a cross-frontier contract panel, not by the suite.
|
||||
|
||||
A page that renders without an annotation is recoverable. A file that
|
||||
overwrote the operator's judgment is not.
|
||||
"""
|
||||
|
||||
|
||||
MARKS_FILE = ".marks.json"
|
||||
MARKS_LOCK = ".marks.lock"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -176,6 +190,34 @@ def _fingerprint(entries: list[dict]) -> str:
|
||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||
|
||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||
case where writing would destroy something.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
if not text.strip():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
|
||||
raise MarksCorrupt(f"{path} is not a marks document")
|
||||
entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
|
||||
if len(entries) != len(raw["marks"]):
|
||||
raise MarksCorrupt(f"{path} holds entries this version cannot read")
|
||||
return entries
|
||||
|
||||
|
||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
"""Atomic replace, so a reader never sees a half-written document and a
|
||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||
@@ -215,7 +257,16 @@ class _Locked:
|
||||
self._made_lock = True
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
self.entries = _read_raw(self.booth)
|
||||
try:
|
||||
# STRICT here, lenient in marks_for — see MarksCorrupt.
|
||||
self.entries = _read_raw_strict(self.booth)
|
||||
except MarksCorrupt:
|
||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||
self._lf.close()
|
||||
self._lf = None
|
||||
if self._made_lock:
|
||||
lock.unlink(missing_ok=True)
|
||||
raise
|
||||
self._before = _fingerprint(self.entries)
|
||||
return self
|
||||
|
||||
@@ -357,16 +408,25 @@ def as_dict(mark: Mark) -> dict:
|
||||
# ---- write ------------------------------------------------------------------
|
||||
|
||||
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
"""A session poses a pick.
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict, target: str | None = None) -> Mark:
|
||||
"""A session poses a pick, about the booth or about ONE item in it.
|
||||
|
||||
Validated through `normalize_ask` BEFORE anything is written, so a session
|
||||
cannot land a question the renderer would refuse. Re-declaring an existing
|
||||
id replaces the declaration and CLEARS its answer: the question changed, so
|
||||
the old judgment is not an answer to it.
|
||||
the old judgment is not an answer to it — and it may move the target, since
|
||||
a re-declaration is a new question.
|
||||
|
||||
`target` is an `Item.rel`, or None for the booth. It exists because the
|
||||
2026-09-09 ruling is that a question belongs WITH the artifact it is about: a
|
||||
four-voice audition wants the radio group under that voice. The record and
|
||||
the renderer both supported it before this parameter did, which meant a
|
||||
session could not actually produce one.
|
||||
"""
|
||||
if not valid_stem(mark_id):
|
||||
raise AskError("bad mark id: letters, digits, . _ - only")
|
||||
if not _valid_target(target):
|
||||
raise AskError("a pick's target must be a path inside the booth")
|
||||
normalize_ask(doc, mark_id) # raises AskError; nothing written yet
|
||||
with _Locked(booth) as lk:
|
||||
existing = lk.find(mark_id)
|
||||
@@ -375,7 +435,7 @@ def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
entry = {
|
||||
"id": mark_id,
|
||||
"shape": PICK,
|
||||
"target": existing.get("target") if existing else None,
|
||||
"target": target,
|
||||
"created": existing.get("created") if existing else now_stamp(),
|
||||
"declaration": doc,
|
||||
"answer": None,
|
||||
@@ -551,10 +611,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
|
||||
created: list[dict] = []
|
||||
with _Locked(booth) as lk:
|
||||
have = {e.get("id") for e in lk.entries}
|
||||
by_id = {e.get("id"): e for e in lk.entries}
|
||||
for mtime, stem, decl, err in found:
|
||||
if stem in have:
|
||||
continue
|
||||
answer = None
|
||||
ap = booth / f"{stem}{ANSWER_SUFFIX}"
|
||||
try:
|
||||
@@ -563,6 +621,23 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
answer = loaded
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
prior = by_id.get(stem)
|
||||
if prior is not None:
|
||||
# The stem is already a mark, so the DECLARATION is not imported
|
||||
# — that is the idempotence rule, and a mark declared since the
|
||||
# sidecar outranks it. But a legacy ANSWER must not be stranded:
|
||||
# if the existing mark is an unanswered pick and the sidecar
|
||||
# holds the operator's choice, adopt it. Ordinary reads are
|
||||
# forbidden from looking at sidecars, so a skip here would lose
|
||||
# that judgment permanently.
|
||||
if (answer is not None
|
||||
and prior.get("shape") == PICK
|
||||
and prior.get("answer") is None):
|
||||
prior["answer"] = answer
|
||||
created.append(prior)
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"id": stem,
|
||||
"shape": PICK,
|
||||
|
||||
@@ -208,10 +208,13 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
`asks.html:11` — and three in Python — `app.py:271` (the index badge),
|
||||
`app.py:750` and `app.py:751` (the verbatim-booth chip).
|
||||
- **INV-3 — the judgment travels, like the caption.** U1's rule, extended:
|
||||
every surface that renders an item renders that item's marks. Gallery tile,
|
||||
zoom view, doc view. *Falsifiable:* fetch `/b/<n>/view?f=<img>` for a flagged
|
||||
item carrying a note and assert both the flag state and the note text are in
|
||||
the served HTML.
|
||||
every surface that renders an item renders that item's marks. *Falsifiable,
|
||||
once per surface* — the first draft named three surfaces and checked one, which
|
||||
all four panel arms flagged as the document's strongest ambiguity: (a) the
|
||||
**gallery tile** shows the flag control in its current state and the item's
|
||||
notes; (b) the **zoom view** `/b/<n>/view?f=<img>` carries the flag state and
|
||||
the note text; (c) the **doc view** `/b/<n>/view?f=<doc>` carries the note
|
||||
text. Three tests, not one.
|
||||
- **INV-4 — the pick semantics are byte-identical.** `build_answer` produces,
|
||||
for every input, the document `write_answer` produced. *Falsifiable:* the
|
||||
existing `test_asks.py` answer assertions pass against `build_answer` with
|
||||
@@ -275,6 +278,66 @@ out here because it is a visible change to what the index shows, it is the kind
|
||||
of thing that looks like a bug when it lands, and the operator should get to
|
||||
veto it rather than discover it.
|
||||
|
||||
## Cross-frontier contract panel — 2026-09-22, four arms, artifact-only
|
||||
|
||||
`/heid-contract-review` panel (Gróa / Hulda / Regin / Kimi), thread
|
||||
`01M33VSNFER4N1554G0Y0VC9C8`, dispatched before implementation and triaged after
|
||||
it. Every quoted passage was verified verbatim by Heid; no arm fabricated an
|
||||
identifier. Triaged per the five-category rule — what follows is the disposition,
|
||||
not the reply.
|
||||
|
||||
**Three of these were defects in shipped code, not ambiguities in prose.** v0.2.0
|
||||
was already tagged and announced to 15 handles when they landed.
|
||||
|
||||
| finding | arms | category | disposition |
|
||||
|---|---|---|---|
|
||||
| **A write over a corrupt `.marks.json` silently replaced every mark in the booth.** The read path is deliberately lenient (unparseable → `[]` so the page loads); the write path inherited that through the same reader, so one flag click appended to an empty list and atomically replaced the file. | Kimi F2, Hulda F3 | **1 — genuine add** | **FIXED.** `MarksCorrupt`, raised by a strict `_read_raw_strict` used only by the write path. Read stays lenient, write goes strict; the damaged bytes are left on disk. Routes return 409, not 500. Reproduced first, then fixed. |
|
||||
| **`declare_pick` had no `target`**, so a pick could not be attached to an item — though `Mark.target` carried one, `marks_for_target` retrieved it, and `_marks.html` already rendered "on \<item\>". | Hulda F1, Regin | **1 — genuine add** | **FIXED.** `declare_pick(..., target=None)`, validated like every other target. A re-declaration may move it. |
|
||||
| **The importer stranded a legacy answer.** A stem already present as a mark was skipped wholesale, so a re-declared-but-unanswered pick with the operator's choice sitting in `<stem>.answer.json` lost that choice permanently — reads are forbidden from looking at sidecars. | Gróa F10 | **1 — genuine add** | **FIXED.** The declaration is still skipped (idempotence), but a legacy answer is ADOPTED when the existing mark is an unanswered pick. An answer made through marks is never overwritten. |
|
||||
| **INV-3 names "doc view" as a protected surface; nothing tested it.** Shipping the doc view unmarked would have passed. | 4/4 — the panel's strongest convergence | **1 — genuine add** | **TEST ADDED.** The behaviour was already implemented; the gate caught that nothing held it. INV-3's falsifiable below now covers all three surfaces. |
|
||||
| **The broken-declaration path is three different doors and none is written:** validate-before-write, stored-raw-with-read-time-error, and unparseable-file-yields-`[]`. | 4/4 | **1 — genuine add, prose only** | **PINNED below.** All three are real and distinct cases; the code always handled them separately. The contract conflated them. |
|
||||
| **"What counts as open" is defined three ways** across assumptions, the signature comment and a test row. | Gróa F1, Regin F5, Hulda F4 | **1 — genuine add, prose only** | **PINNED below.** Code and tests were already correct (partial = open). |
|
||||
| **INV-2 and INV-5's checks comply in letter:** openness can be re-derived as `(answer or {}).get("complete")` with the grep still green; `importlib` inside a function defeats the AST walk. | Gróa F5/F7, Kimi F4/F5 | **4 — out of place** | Accepted as true and NOT closed. Both describe a future careless change, and the honest statement is that these checks raise the cost of drifting rather than making it impossible. Recorded rather than papered over. |
|
||||
| **INV-6 named `_with_marks(booth)`; the code has `_Locked`.** And its falsifiable makes the mandated helper unimplementable, since the helper must itself call `os.replace`. | Gróa F8, Kimi F8 | **2 — sharpening** | **FIXED below** — the name and the exemption. |
|
||||
| `set_flag`'s annotation forbids a booth-level flag; never stated as a decision. | Regin F6 | **2 — sharpening** | It IS a decision: a flag means *this one*, so it needs an item. Stated in the signature. |
|
||||
| "Cleaning" note text is defined by example only. | Kimi F6, Hulda F6 | **2 — sharpening** | `_clean_text` is CRLF-normalize, strip, truncate at `TEXT_MAX`. Documented at the function. |
|
||||
| INV-1 self-conflict: the rule allows one function, the check and assumptions exempt `booth_items`' name check. | Gróa F6 | **3 — settled prior** | Already resolved by the seam review (SR-9): the exemption is a NAME check, never a content read. |
|
||||
|
||||
**The methodology note the arms volunteered, which is worth more than any single
|
||||
flag:** this contract's own frontmatter carries a plain-language narrative, so the
|
||||
paraphrase half was partly re-reading the author's framing back to him. Regin and
|
||||
Kimi both said the stronger shape for a narrative-heavy contract is the ambiguity
|
||||
pass with the paraphrase cut to a drift-check. That is a finding about the
|
||||
*mechanism*, not this document, and it belongs in the skill rather than here.
|
||||
|
||||
### The three pinnings
|
||||
|
||||
**Openness, normatively, once.** A mark is open when `shape == "pick"` **and** it
|
||||
has no `error` **and** (`answer is None` **or** `answer["complete"]` is false). A
|
||||
**partially answered pick is OPEN.** Every other sentence in this document about
|
||||
openness is descriptive; this one governs, and `open_marks` is its only
|
||||
implementation.
|
||||
|
||||
**A broken declaration, normatively — three distinct cases, not one.**
|
||||
|
||||
1. `declare_pick` validates through `normalize_ask` and **raises `AskError`
|
||||
before writing anything.** A session cannot land a refused question. The
|
||||
function never returns an invalid mark.
|
||||
2. A declaration that is invalid **in the stored file** — reachable via the
|
||||
importer, or a hand-edit — is hydrated with `error` set and is rendered, so a
|
||||
question the session believes it posted is never silently hidden. It is not
|
||||
open (it can never be answered), and it cannot be answered: `answer_pick`
|
||||
re-validates and raises.
|
||||
3. A **whole file** that cannot be parsed is not a broken declaration. `marks_for`
|
||||
returns `[]` so the page loads; every WRITE refuses with `MarksCorrupt`.
|
||||
|
||||
**INV-6, corrected.** Every writer goes through the one `_Locked(booth)` context
|
||||
manager, which holds an exclusive flock on `<booth>/.marks.lock` across read,
|
||||
mutate and atomic replace. *Falsifiable:* no function outside `_Locked` calls
|
||||
`_write_raw` or `os.replace` on the mark file. (The first draft named a
|
||||
`_with_marks` helper that does not exist, and forbade the very calls the helper
|
||||
must make.)
|
||||
|
||||
## Slices
|
||||
|
||||
Vertical, each one shippable and green before the next starts.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+3
-1
@@ -255,7 +255,7 @@ print("removed: %s %s" % (removed["desc"], removed["url"]))
|
||||
import os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
from booth.asks import AskError
|
||||
from booth.marks import declare_pick
|
||||
from booth.marks import MarksCorrupt, declare_pick
|
||||
booth, mid, prompt, *opts = sys.argv[1:]
|
||||
try:
|
||||
declare_pick(pathlib.Path(booth), mid,
|
||||
@@ -263,6 +263,8 @@ try:
|
||||
"notes": os.environ["ASK_NOTES"] == "1"})
|
||||
except AskError as exc:
|
||||
sys.exit("bad pick: %s" % exc)
|
||||
except MarksCorrupt as exc:
|
||||
sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
|
||||
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||||
echo "$URL/b/$name/#mark-$mid"
|
||||
;;
|
||||
|
||||
@@ -713,3 +713,171 @@ def test_a_real_write_then_a_no_op_leaves_the_file_alone(tmp_path):
|
||||
|
||||
set_flag(booth, "a.png", True) # idempotent: already flagged
|
||||
assert path.stat().st_mtime == before, "an idempotent flag rewrote the file"
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier contract panel, 2026-09-22 -------------
|
||||
#
|
||||
# Heid panel (thread 01M33VSNFER4N1554G0Y0VC9C8). Four arms, artifact-only.
|
||||
|
||||
|
||||
def test_a_write_over_a_corrupt_marks_file_refuses_instead_of_replacing(tmp_path):
|
||||
"""DATA LOSS, shipped in v0.2.0. Found by Kimi (flag 2), converged with Hulda.
|
||||
|
||||
`marks_for` is deliberately lenient — an unparseable file reads as "no marks"
|
||||
so a review page still loads. The write path inherited that leniency through
|
||||
the same reader, so the next flag toggle appended one entry to an empty list
|
||||
and atomically replaced the file: every judgment in that booth gone, from one
|
||||
click, silently.
|
||||
|
||||
The read stays lenient and the WRITE goes strict. That asymmetry is the fix —
|
||||
a page that renders without an annotation is recoverable, a file that
|
||||
overwrote the operator's judgment is not, and this repo's standing rule is
|
||||
that nothing deletes his data.
|
||||
"""
|
||||
from booth.marks import MarksCorrupt, set_flag, write_note
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
write_note(booth, "a.png", "judgment one")
|
||||
write_note(booth, "b.png", "judgment two")
|
||||
raw = (booth / MARKS_FILE).read_text()
|
||||
(booth / MARKS_FILE).write_text(raw[: len(raw) // 2]) # truncated mid-write
|
||||
|
||||
with pytest.raises(MarksCorrupt):
|
||||
set_flag(booth, "c.png", True)
|
||||
|
||||
# The damaged bytes are still on disk — untouched, recoverable by hand.
|
||||
assert (booth / MARKS_FILE).read_text() == raw[: len(raw) // 2]
|
||||
# And the read path is still lenient, so the page renders rather than 500s.
|
||||
assert marks_for(booth) == []
|
||||
|
||||
|
||||
def test_an_absent_or_empty_marks_file_is_not_corrupt(tmp_path):
|
||||
"""The strict write path must not mistake "nothing yet" for "damaged"."""
|
||||
from booth.marks import set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
assert set_flag(booth, "a.png", True) is not None # no file at all
|
||||
(booth / MARKS_FILE).write_text("")
|
||||
assert set_flag(booth, "b.png", True) is not None # zero bytes
|
||||
(booth / MARKS_FILE).write_text('{"version": 1, "marks": []}')
|
||||
assert set_flag(booth, "c.png", True) is not None # valid but empty
|
||||
|
||||
|
||||
def test_a_pick_can_target_one_item(tmp_path):
|
||||
"""Found by Hulda (flag 1), converged with Regin.
|
||||
|
||||
`Mark.target` carries an item rel, `marks_for_target` retrieves by it, and
|
||||
the panel template already renders "on <item>" for a pick — but
|
||||
`declare_pick` had no target parameter, so a session could not actually
|
||||
produce one. A question about ONE artifact is the 2026-09-09 ruling's whole
|
||||
point; the record supported it and the door was missing.
|
||||
"""
|
||||
from booth.marks import marks_for_target
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
declare_pick(booth, "which-crop", _single(), target="v3/DSC03389.jpg")
|
||||
m = marks_for(booth)[0]
|
||||
assert m.target == "v3/DSC03389.jpg"
|
||||
assert [x.id for x in marks_for_target(marks_for(booth), "v3/DSC03389.jpg")] == ["which-crop"]
|
||||
# and it still answers normally
|
||||
answer_pick(booth, "which-crop", "A — baseline")
|
||||
assert marks_for(booth)[0].answer["complete"] is True
|
||||
|
||||
|
||||
def test_a_pick_target_cannot_escape_the_booth(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
for bad in ("../outside.png", "/etc/passwd"):
|
||||
with pytest.raises(AskError):
|
||||
declare_pick(booth, "p", _single(), target=bad)
|
||||
|
||||
|
||||
def test_redeclaring_a_pick_may_move_its_target(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
declare_pick(booth, "p", _single(), target="a.png")
|
||||
declare_pick(booth, "p", _single(), target="b.png")
|
||||
assert marks_for(booth)[0].target == "b.png"
|
||||
|
||||
|
||||
def test_import_adopts_a_legacy_answer_for_an_already_declared_pick(tmp_path):
|
||||
"""Found by Gróa (flag 10).
|
||||
|
||||
The idempotence rule skipped any stem already present as a mark. If a
|
||||
session had re-declared that stem through marks (so the mark exists, still
|
||||
unanswered) while the operator's answer sat in the legacy sidecar, the import
|
||||
skipped and that answer was stranded on disk forever — with the read path
|
||||
forbidden from looking at sidecars. Adopting the answer preserves both rules:
|
||||
idempotent, and never clobbers a NEWER judgment.
|
||||
"""
|
||||
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())
|
||||
doc = build_answer(normalize_ask(_single(), "winner"), "B — async", notes="from the sidecar")
|
||||
(booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(doc))
|
||||
declare_pick(booth, "winner", _single()) # re-declared, unanswered
|
||||
assert marks_for(booth)[0].answer is None
|
||||
|
||||
import_legacy_asks(booth)
|
||||
got = marks_for(booth)[0]
|
||||
assert got.answer is not None, "the legacy answer was stranded"
|
||||
assert got.answer["choice"] == "B — async"
|
||||
assert open_marks(marks_for(booth)) == []
|
||||
|
||||
|
||||
def test_import_never_overwrites_an_answer_made_through_marks(tmp_path):
|
||||
"""The other half of the same rule: a judgment recorded SINCE the sidecar
|
||||
outranks it, and adoption must not reach back over it."""
|
||||
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())
|
||||
old = build_answer(normalize_ask(_single(), "winner"), "A — baseline")
|
||||
(booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(old))
|
||||
declare_pick(booth, "winner", _single())
|
||||
answer_pick(booth, "winner", "B — async") # the operator changed his mind
|
||||
|
||||
import_legacy_asks(booth)
|
||||
assert marks_for(booth)[0].answer["choice"] == "B — async"
|
||||
|
||||
|
||||
def test_the_doc_view_carries_the_marks(client):
|
||||
"""INV-3's third surface — flagged 4/4 by the panel as named in the rule but
|
||||
covered by no test, so shipping it unmarked would have passed."""
|
||||
from booth.marks import write_note
|
||||
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "notes.md").write_text("# report\n\nprose here\n")
|
||||
write_note(b, "notes.md", "this section is wrong")
|
||||
|
||||
html = c.get("/b/b/view?f=notes.md").text
|
||||
assert "prose here" in html
|
||||
assert "this section is wrong" in html
|
||||
|
||||
|
||||
def test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500(client):
|
||||
"""The request was fine and the service is fine — the state on disk is not,
|
||||
and the refusal is deliberate. A 500 would read as "the Booth is broken" and
|
||||
send the operator looking for something to restart."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
from booth.marks import write_note
|
||||
write_note(b, "a.png", "keep me")
|
||||
(b / MARKS_FILE).write_text("{truncated")
|
||||
|
||||
r = c.post("/b/b/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
|
||||
assert r.status_code == 409
|
||||
body = r.json()
|
||||
assert "cannot be read" in body["error"] and body["fix"]
|
||||
# the page still renders, so the operator can see the booth at all
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert c.get("/b/b/marks.json").status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user