From 0cdbf74a025e30bfd7acba229fc0c272542d8774 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 3 Aug 2026 17:41:35 -0400 Subject: [PATCH] Credibility pass 2: the copy-then-drift tells, excised Two blind reviewers swept the 33 commits since 10f65d8 for signs of machine generation. Verdict: production code and copy largely clean; the tells clustered in duplication and tests. JS: the six-times-pasted change-detection loop (three pages honoring a LAST-after-render invariant, three violating it) becomes one changeGate() factory in app.js; the reshoot ticket renderer and dismiss wiring, duplicated across photos/photo pages, become ticketCard()/wireDismiss(); review.html's hand-rolled fetch/post collapse onto fetchJSON/apiPost keeping only its unique saved-but-render-failed path; dead lastGood deleted; page-state naming unified to CAPS (ACTIVE, RUNNING); a dead defensive rowix branch gone. CSS: header no longer claims "two pages"; --focus derives from --accent; five state tints become tokens (the header's tokens-for-roles promise, kept); component button rules drop declarations the global rule supplies; duplicate color declarations trimmed. Python: dead seen_per_title vestige removed from resolve; redundant ternary arm in the catalog builder collapsed; csv import hoisted; twin VetoBody/SplitBody merged into RowRef; warn-once idiom deduplicated into a closure; a stray "a bare arrays" typo. Tests: the one assertion that could never fail (aria-current check with an always-true fallback) replaced by a strict per-page check across all seven pages; the traversal test asserts escape unconditionally; stale "both pages" names updated; nine redundant function-local imports hoisted to their module tops. Docs: aria role="status" set once in the shell instead of per call; joblog gets role="log"; README's --lan paragraph becomes a proper "From your phone" quickstart subsection with the command visible, and the seven-page list stops restating the screenshot captions; Help's re-extract claim matches actual behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- README.md | 14 +++++- src/bggpipe/extract.py | 2 +- src/bggpipe/resolve.py | 3 -- src/bggpipe/static/app.css | 47 +++++++++----------- src/bggpipe/static/app.js | 54 +++++++++++++++++++++-- src/bggpipe/templates/pages/help.html | 2 +- src/bggpipe/templates/pages/library.html | 8 +--- src/bggpipe/templates/pages/photo.html | 34 ++------------ src/bggpipe/templates/pages/photos.html | 42 +++--------------- src/bggpipe/templates/pages/pipeline.html | 18 +++----- src/bggpipe/templates/pages/queue.html | 7 +-- src/bggpipe/templates/pages/review.html | 39 +++++----------- src/bggpipe/templates/pages/titles.html | 18 +++----- src/bggpipe/templates/shell.html | 2 +- src/bggpipe/webreview.py | 40 +++++++---------- tests/test_config.py | 6 +-- tests/test_extract.py | 5 +-- tests/test_review.py | 4 +- tests/test_web_dashboard.py | 43 +++++++----------- tests/test_webreview.py | 8 ++-- 20 files changed, 164 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 90dfa71..9231d4f 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,19 @@ Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) li uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser ``` -The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a pairing link carrying an access key (`?k=...`) plus a QR code — point the phone's camera at the terminal and tap. Pairing is one-time per device: the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the app to the phone's home screen for the full-screen treatment (piper icon included). On the Photos page the phone can shoot straight into the pipeline: tap the drop zone, choose "Take Photo," and the shot uploads with visible progress — camera captures get unique `shelf-` names, so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead). +### From your phone -Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Titles** (every read off your shelves, alphabetized — and where you proofread them: fix misreads, add cues, split multi-copy lines, remove non-games), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state: +The app is localhost-only by default. To use it from a phone or tablet on your network — proofreading from the couch, or shooting shelf photos straight into the pipeline — serve it to the LAN instead: + +```sh +uv run bggpipe web --lan # localhost + your network, behind an access key +``` + +Startup prints a pairing link (`?k=...`) and a QR code: point the phone's camera at the terminal and tap. Pairing is one-time per device — the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the page to the phone's home screen for the full-screen treatment, piper icon included. + +To photograph shelves from the phone: on the Photos page, tap the drop zone and choose "Take Photo." The upload narrates its progress, and camera captures get unique `shelf-` names so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead). + +Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, and Help, each pictured above. The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state: ```sh uv run bggpipe extract # photos → titles.json (+ retake prompts) diff --git a/src/bggpipe/extract.py b/src/bggpipe/extract.py index 4f79489..4b8c295 100644 --- a/src/bggpipe/extract.py +++ b/src/bggpipe/extract.py @@ -432,7 +432,7 @@ def rebuild_artifacts( ) -> tuple[list[dict], dict[str, list[dict]]]: """Regenerate titles.json and unidentified.json from the per-photo raw cache. A raw file is either an object with titles/unidentified or a bare - arrays — still readable.""" + array — still readable.""" entries: list[dict] = [] unidentified: dict[str, list[dict]] = {} for raw_file in sorted(raw_dir.glob("*.json")): diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index fb536c1..3610c91 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -587,8 +587,6 @@ def run_resolve( return candidates[0] return None - seen_per_title: dict[str, int] = {} - new_rows: list[MatchRow] = [] skipped = 0 photos_updated = False @@ -607,7 +605,6 @@ def run_resolve( paired_by_id[id(entry)] = row_dict for entry in entries: - seen_per_title[entry.title_raw] = seen_per_title.get(entry.title_raw, 0) + 1 row_dict = paired_by_id.get(id(entry)) if row_dict is not None: photos = ";".join(entry.source_photos) diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index ac8830d..52b8439 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -1,4 +1,4 @@ -/* bggpipe design system — one stylesheet, two pages (dashboard, review). +/* bggpipe design system — one stylesheet for the whole app. * * The visual world is Juniper's mascot drawing: a sky-blue day, flat cel * color inside confident dark outlines, a cream game board with a rainbow @@ -30,7 +30,12 @@ --gold: #f2c04b; /* pipe fittings: rings, trim, hovers */ --gold-ink: #8a6414; --ticket: #fdf3d2; /* reshoot work-orders */ - --focus: #8330c2; + --go-tint: #e2f2e4; /* pale state washes of the role colors */ + --stop-tint: #fbe3da; + --gold-tint: #fdeebb; + --accent-tint: #ece5f7; + --navy-tint: #e3ecf3; + --focus: var(--accent); --path: linear-gradient(90deg, #f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0); --path-v: linear-gradient(180deg, @@ -59,7 +64,6 @@ body { } main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; } h2 { - color: var(--ink); font-family: var(--font-display); font-weight: 700; font-size: 1.1rem; letter-spacing: .02em; margin: 2rem 0 .8rem; @@ -164,7 +168,7 @@ kbd { border: var(--line); background: var(--board); } -.banner.error { border-color: var(--stop); color: var(--stop-ink); background: #fbe3da; } +.banner.error { border-color: var(--stop); color: var(--stop-ink); background: var(--stop-tint); } .banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); } /* -- buttons ----------------------------------------------------------- */ @@ -243,10 +247,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .cname { font-weight: 600; } .cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; } .rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; } -.rowactions button { - font: inherit; border: var(--line); background: #fff; - border-radius: var(--radius); padding: .25rem .7rem; cursor: pointer; -} +.rowactions button { padding: .25rem .7rem; box-shadow: none; } .rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); } .rowactions input[type=text] { font: inherit; width: 8.5em; padding: .25rem .5rem; @@ -276,10 +277,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .ticket .partial { font-family: var(--font-mono); font-size: .85rem; } .ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; } .ticket button { - font: inherit; font-size: .8rem; margin-top: .5rem; + font-size: .8rem; margin-top: .5rem; background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink); - border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; - box-shadow: none; + padding: .2rem .6rem; box-shadow: none; } /* -- all-done celebration ---------------------------------------------- */ @@ -292,7 +292,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # border-radius: var(--radius-lg); border: var(--line); background: var(--sky); } -.done h2 { color: var(--ink); margin-top: 0; } +.done h2 { margin-top: 0; } .done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; } .done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); } .done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } @@ -323,11 +323,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # } .catalog a:hover { color: var(--accent-ink); } .catalog td.actions { text-align: right; white-space: nowrap; } -.catalog td.actions button { - font: inherit; font-size: .78rem; border: var(--line); background: #fff; - border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; - box-shadow: none; -} +.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; } .catalog td.actions button:hover { background: var(--board); } .catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; } .editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; } @@ -355,11 +351,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # } .card ol { padding-left: 1.3rem; } .card ol li { margin: .35rem 0; } -.chip.ok { background: #e2f2e4; color: var(--go-ink); } +.chip.ok { background: var(--go-tint); color: var(--go-ink); } .chip.wait { background: var(--ticket); color: var(--gold-ink); } -.chip.no { background: #fbe3da; color: var(--stop-ink); } -.chip.open { background: #ece5f7; color: var(--accent-ink); } -.chip.merged { background: #e3ecf3; color: var(--navy); } +.chip.no { background: var(--stop-tint); color: var(--stop-ink); } +.chip.open { background: var(--accent-tint); color: var(--accent-ink); } +.chip.merged { background: var(--navy-tint); color: var(--navy); } .chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); } /* -- merge notices: slim, undoable ------------------------------------- */ @@ -367,10 +363,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; } .card.merge .arrow { color: var(--ink-soft); } .card.merge button { - font: inherit; font-size: .8rem; margin-left: auto; + font-size: .8rem; margin-left: auto; background: none; border: 1px solid var(--board-edge); - border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; - box-shadow: none; + padding: .2rem .6rem; box-shadow: none; } .card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); } @@ -405,9 +400,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # font: inherit; cursor: pointer; box-shadow: none; } -#dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; } +#dropzone.hot, #dropzone:hover { border-style: solid; background: var(--gold-tint); } #dropzone[aria-busy="true"] { cursor: progress; opacity: .8; } -#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: #e2f2e4; } +#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: var(--go-tint); } #joblog { background: var(--navy); color: #eaf1fb; font-family: var(--font-mono); font-size: .78rem; diff --git a/src/bggpipe/static/app.js b/src/bggpipe/static/app.js index 208772d..c97d837 100644 --- a/src/bggpipe/static/app.js +++ b/src/bggpipe/static/app.js @@ -10,9 +10,7 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g, * pass through esc() at the call site. The API serves only local pipeline * data, but photo names and BGG titles still count as untrusted. */ function showBanner(html) { - const el = document.getElementById("banner"); - el.setAttribute("role", "status"); - el.innerHTML = html; + document.getElementById("banner").innerHTML = html; } function errorBanner(detail) { @@ -49,6 +47,21 @@ async function apiPost(url, body) { return res; } +/* Per-page change gate for poll loops: render only when the payload + * changed, and record it only after render returns — a throw leaves the + * page stale, so the next poll retries instead of freezing on "current". */ +function changeGate() { + let seen = null; + const gate = (value, render) => { + const key = JSON.stringify(value); + if (key === seen) return; + render(); + seen = key; + }; + gate.reset = () => { seen = null; }; + return gate; +} + /* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact * banner, and clear it (via `recovered`) on the next success. */ function pollLoop(fn, ms, recovered) { @@ -102,6 +115,41 @@ function metaLine(c) { ].filter(Boolean).join(" · "); } +/* Reshoot work-order card, shared by the photos gallery and the photo + * detail page (which drops the thumbnail and source line — the photo is + * right there). Dismiss buttons are wired by wireDismiss below. */ +function ticketCard(s, {showPhoto = true} = {}) { + const img = showPhoto && s.photo_exists + ? ` + photo ${esc(s.photo)}` + : ""; + return ` +
+ reshoot + ${img} +
+
${esc(s.location) || "somewhere in " + esc(s.photo)}
+ ${s.partial_text ? `
text visible: ${esc(s.partial_text)}
` : ""} + ${s.art_notes ? `
${esc(s.art_notes)}
` : ""} + ${showPhoto ? `
from ${esc(s.photo)} — take a closer shot and drop it above
` : ""} + +
+
`; +} + +function wireDismiss(root, done) { + root.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => { + const t = b.closest(".ticket"); + const res = await apiPost("/api/dismiss", { + photo: t.dataset.photo, location: t.dataset.location, + partial_text: t.dataset.partial, art_notes: t.dataset.art, + }); + if (res) done(); + })); +} + /* Status chip for a catalog entry — shared by the catalog and photo pages. */ function statusChip(c) { if (c.status === "awaiting_resolve") return `awaiting BGG`; diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 1aa064a..42bcd17 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -26,7 +26,7 @@

What each page is for

Pipeline — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.

-

Photos — drag photos in, drop them in the photos/ folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique shelf-… names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same file name deliberately replaces it and re-extracts.

+

Photos — drag photos in, drop them in the photos/ folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique shelf-… names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same file name deliberately replaces it, and the next extract run re-reads it.

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — the decisions only you can make: which game a title is, which edition a copy is, and whether two same-game reads are really one box (merges show a veto). Keyboard-first; see shortcuts.

Queue — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first.

diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html index a6642fe..cc15f3d 100644 --- a/src/bggpipe/templates/pages/library.html +++ b/src/bggpipe/templates/pages/library.html @@ -56,14 +56,10 @@ function render() { Run the pipeline through enrich to fill these shelves.`}

`; } -let LAST = null; +const GATE = changeGate(); async function refresh() { const games = await fetchJSON("/api/library"); - const payload = JSON.stringify(games); - if (payload === LAST) return; - LAST = payload; - GAMES = games; - render(); + GATE(games, () => { GAMES = games; render(); }); } document.getElementById("libsearch").addEventListener("input", render); diff --git a/src/bggpipe/templates/pages/photo.html b/src/bggpipe/templates/pages/photo.html index 0227d39..203258d 100644 --- a/src/bggpipe/templates/pages/photo.html +++ b/src/bggpipe/templates/pages/photo.html @@ -15,21 +15,6 @@ document.getElementById("photoname").textContent = NAME; document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`; document.title = `${NAME} · bggpipe`; -function ticket(s) { - return ` -
- reshoot -
-
${esc(s.location) || "somewhere in this photo"}
- ${s.partial_text ? `
text visible: ${esc(s.partial_text)}
` : ""} - ${s.art_notes ? `
${esc(s.art_notes)}
` : ""} - -
-
`; -} - function render(state, photos) { const info = photos.find(p => p.name === NAME); const body = document.getElementById("photobody"); @@ -72,30 +57,19 @@ function render(state, photos) { if (tickets.length) { html += `

Reshoot tickets — boxes seen here but not identified

`; - html += tickets.map(ticket).join(""); + html += tickets.map(s => ticketCard(s, {showPhoto: false})).join(""); } body.innerHTML = html; - - body.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => { - const t = b.closest(".ticket"); - const res = await apiPost("/api/dismiss", { - photo: t.dataset.photo, location: t.dataset.location, - partial_text: t.dataset.partial, art_notes: t.dataset.art, - }); - if (res) refresh().catch(() => {}); - })); + wireDismiss(body, () => refresh().catch(() => {})); } -let LAST = null; +const GATE = changeGate(); async function refresh() { const [state, photos] = await Promise.all([ fetchJSON("/api/state"), fetchJSON("/api/photos-list"), ]); - const payload = JSON.stringify([state.catalog, state.unidentified, photos]); - if (payload === LAST) return; - render(state, photos); - LAST = payload; // after render: a throw must not freeze the page as "current" + GATE([state.catalog, state.unidentified, photos], () => render(state, photos)); } document.addEventListener("keydown", e => { diff --git a/src/bggpipe/templates/pages/photos.html b/src/bggpipe/templates/pages/photos.html index ad1c1a4..8192e97 100644 --- a/src/bggpipe/templates/pages/photos.html +++ b/src/bggpipe/templates/pages/photos.html @@ -11,40 +11,12 @@
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 1a0dcd0..7a4ccd7 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -14,6 +14,7 @@ execute one at a time in a JobRunner. from __future__ import annotations +import csv import io import json import os @@ -148,13 +149,10 @@ class VersionBody(BaseModel): version_id: int | None = None -class VetoBody(BaseModel): - title_raw: str - source_photos: str - row_ix: int | None = None +class RowRef(BaseModel): + """Addresses one matches.csv row: by ordinal when given, else by key + (the veto and split endpoints both act on exactly this).""" - -class SplitBody(BaseModel): title_raw: str source_photos: str row_ix: int | None = None @@ -316,6 +314,10 @@ def create_app( # unreadable thumbnails, torn artifacts) app_warnings: list[str] = list(startup_notes) + def warn_once(note: str) -> None: + if note not in app_warnings: + app_warnings.append(note) + ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} | (allowed_hosts or set()) LAN_COOKIE = "bggpipe_key" @@ -428,12 +430,10 @@ def create_app( data = json.loads(cfg.unidentified_path.read_text()) except (json.JSONDecodeError, OSError) as err: # one torn file must not 500 every page and badge at once - note = ( + warn_once( f"{cfg.unidentified_path.name} unreadable ({err}) — " "reshoot list unavailable" ) - if note not in app_warnings: - app_warnings.append(note) return [] for photo, entries in data.items(): for s in entries: @@ -450,9 +450,7 @@ def create_app( try: return json.loads(cfg.games_path.read_text()) except (json.JSONDecodeError, OSError) as err: - note = f"{cfg.games_path.name} unreadable ({err}) — library unavailable" - if note not in app_warnings: - app_warnings.append(note) + warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable") return {} def _find_entry(title_raw: str, source_photos: str) -> TitleEntry | None: @@ -578,8 +576,6 @@ def create_app( # the row's own photos for split copies catalog.append( catalog_line(None if row and row.get("dedupe_veto") else entry, row) - if row - else catalog_line(entry, None) ) # surplus rows beyond the entry count — split copies — are real # physical games and get their own catalog lines @@ -725,9 +721,7 @@ def create_app( if not path.exists(): return [] with path.open(newline="") as f: - import csv as _csv - - return list(_csv.DictReader(f)) + return list(csv.DictReader(f)) return { "to_add": rows(cfg.to_add_path), @@ -755,9 +749,7 @@ def create_app( log_path = cfg.upload_log_path if log_path.exists(): with log_path.open(newline="") as f: - import csv as _csv - - log_counts = Counter(row["status"] for row in _csv.DictReader(f)) + log_counts = Counter(row["status"] for row in csv.DictReader(f)) games = len(read_games()) return { "boot": boot, @@ -922,7 +914,7 @@ def create_app( return state() @app.post("/api/split") - def api_split(body: SplitBody) -> dict: + def api_split(body: RowRef) -> dict: with lock: revision["n"] += 1 _refuse_if_rewriting() @@ -1088,7 +1080,7 @@ def create_app( return state() @app.post("/api/veto-merge") - def api_veto_merge(body: VetoBody) -> dict: + def api_veto_merge(body: RowRef) -> dict: with lock: revision["n"] += 1 _refuse_if_rewriting() @@ -1154,13 +1146,11 @@ def _dev_app() -> FastAPI: def _print_qr(url: str) -> None: """Pairing without typing: the phone's camera reads the URL straight off the terminal.""" - import io as _io - import qrcode qr = qrcode.QRCode(border=1) qr.add_data(url) - buffer = _io.StringIO() + buffer = io.StringIO() qr.print_ascii(out=buffer, invert=True) for line in buffer.getvalue().splitlines(): typer.echo(f" {line}") diff --git a/tests/test_config.py b/tests/test_config.py index dd737a1..884248b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,8 @@ from __future__ import annotations from pathlib import Path +import pytest + from bggpipe.config import Config, load_config @@ -40,8 +42,6 @@ def test_username_comes_from_env_only(tmp_path, monkeypatch): def test_unknown_toml_keys_warn(tmp_path, monkeypatch): # a typo'd knob must not silently fall back to defaults - import pytest - monkeypatch.delenv("BGG_USERNAME", raising=False) p = tmp_path / "config.toml" p.write_text('photo_dir = "oops"\n') @@ -50,7 +50,5 @@ def test_unknown_toml_keys_warn(tmp_path, monkeypatch): def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path): - import pytest - with pytest.raises(FileNotFoundError, match="does not exist"): load_config(tmp_path / "nope.toml") diff --git a/tests/test_extract.py b/tests/test_extract.py index 394d205..efc9c3d 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -486,9 +486,6 @@ def test_corrupt_raw_cache_is_reextracted_not_skipped(tmp_path): def test_systemic_failures_abort_and_exit_nonzero(tmp_path): - import pytest - import typer as _typer - calls = [] def broken_vision(image_b64, media_type): @@ -499,6 +496,6 @@ def test_systemic_failures_abort_and_exit_nonzero(tmp_path): cfg.photos_dir.mkdir() for i in range(6): _write_image(cfg.photos_dir / f"p{i}.jpg") - with pytest.raises(_typer.Exit): + with pytest.raises(typer.Exit): run_extract(cfg, vision=broken_vision) assert len(calls) == 3 # aborted after 3 identical failures diff --git a/tests/test_review.py b/tests/test_review.py index 9e802c7..5e2dfee 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -505,8 +505,6 @@ def test_split_row_makes_per_photo_vetoed_copies(tmp_path): def test_split_copies_survive_resolve_rerun(tmp_path): - import json as _json - from bggpipe.resolve import read_matches, run_resolve cfg = _setup( @@ -521,7 +519,7 @@ def test_split_copies_survive_resolve_rerun(tmp_path): ], ) (cfg.data_dir / "titles.json").write_text( - _json.dumps( + json.dumps( [{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}] ) ) diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py index 5b3bd4c..1c702a8 100644 --- a/tests/test_web_dashboard.py +++ b/tests/test_web_dashboard.py @@ -3,6 +3,7 @@ stage functions are injected so nothing slow or networked ever runs.""" from __future__ import annotations +import json import threading import time @@ -174,9 +175,11 @@ def test_photo_upload_rejects_non_photos_and_path_tricks(tmp_path): "/api/photos", files={"files": ("../../escape.jpg", b"x", "image/jpeg")}, ) - if res.status_code == 200: # client may strip the path; the name must be bare + # whether the server accepts a stripped bare name or rejects outright, + # nothing may land outside photos_dir + assert not (tmp_path / "escape.jpg").exists() + if res.status_code == 200: assert (cfg.photos_dir / "escape.jpg").exists() - assert not (tmp_path / "escape.jpg").exists() # -- pages -------------------------------------------------------------- @@ -191,7 +194,7 @@ def test_dashboard_and_review_pages_serve(tmp_path): # -- design system + navigation ----------------------------------------- -def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path): +def test_stylesheet_is_served_and_linked_by_every_page(tmp_path): web = _app(_cfg(tmp_path)) css = web.get("/static/app.css") assert css.status_code == 200 @@ -201,15 +204,12 @@ def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path): assert 'href="/static/app.css"' in web.get(path).text -def test_both_pages_carry_navigation_and_skip_link(tmp_path): +def test_every_page_marks_itself_current_in_the_nav(tmp_path): web = _app(_cfg(tmp_path)) - for path, current in (("/", 'href="/"'), ("/review", 'href="/review"')): + for path in ("/", "/photos", "/titles", "/review", "/queue", "/library", "/help"): html = web.get(path).text - assert 'aria-label="Primary"' in html - assert f'