The proofread checkpoint gets its place: nav order, badge, and a rename

Eric's observation: cleaning up raw reads happens BEFORE resolve, but
the app ordered Review ahead of Catalog and never said proofreading was
a step at all. The pipeline has two human checkpoints — proofread the
reads (after extract), decide the matches (after resolve) — and now the
app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue,
Library, Help in true workflow order; the Titles page (né Catalog — the
old name suggested a finished collection, which is the Library's job;
/catalog redirects) gets a badge counting unresolved shaky reads; an
edit marks its entry human-verified so the badge drains as you
proofread; the extract stage card nudges toward the proofread before
resolving; and the Help flow is rewritten as six stages + two
checkpoints with the loop called out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 00:19:59 -04:00
parent 25c1432a03
commit f19b861251
14 changed files with 163 additions and 155 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
## Commands
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
- `uv run bggpipe web` — the app: six pages (Pipeline `/`, Photos, Review, Catalog, Queue, Library) in a shared sidebar shell; stage runs execute one-at-a-time in a background job.
- `uv run bggpipe web` — the app: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell; stage runs execute one-at-a-time in a background job.
- `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path.
- `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`.
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
+2 -2
View File
@@ -60,7 +60,7 @@ 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
```
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), **Catalog** (every extracted title, alphabetized, with curation: 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:
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:
```sh
uv run bggpipe extract # photos → titles.json (+ retake prompts)
@@ -80,7 +80,7 @@ Tabletop RPGs aren't in BGG's board-game database — they live on RPGGeek, whic
### Fixing what the model gets wrong
Vision reads aren't perfect, and you know things the photos don't show. The Catalog page lets you **edit** a title (fix a misspelling, add publisher/edition/year/language cues you know offhand), **split** a line into per-photo copies when one title is actually several boxes, and **remove** lines that aren't games at all. Every one of these is durable: the decision lands in a small committed store (`data/title_edits.json`, `data/title_splits.json`, `data/title_removals.json`) that is replayed on every rebuild — re-running extract or resolve can never undo your curation. Undo any decision by deleting its record from the store. The in-app **Help** page documents all of it.
Vision reads aren't perfect, and you know things the photos don't show. The Titles page lets you **edit** a title (fix a misspelling, add publisher/edition/year/language cues you know offhand), **split** a line into per-photo copies when one title is actually several boxes, and **remove** lines that aren't games at all. Every one of these is durable: the decision lands in a small committed store (`data/title_edits.json`, `data/title_splits.json`, `data/title_removals.json`) that is replayed on every rebuild — re-running extract or resolve can never undo your curation. Undo any decision by deleting its record from the store. The in-app **Help** page documents all of it.
### Taking good shelf photos
+4
View File
@@ -2,5 +2,9 @@
{
"match": "Hangermuger 4 800",
"title_raw": "Huggermugger"
},
{
"match": "DOCTOR WHO THE GAME OF TIME",
"title_raw": "DOCTOR WHO THE card GAME"
}
]
+62
View File
@@ -0,0 +1,62 @@
[
{
"title": "THE CATAN CARD GAME",
"photos": [
"IMG_4523.jpeg"
]
},
{
"title": "SLUGFEST GAMES",
"photos": [
"IMG_4528.jpeg"
]
},
{
"title": "SCRABBLE",
"photos": [
"IMG_4508.jpeg"
]
},
{
"title": "SCERU MERRY MEN",
"photos": [
"IMG_4518.jpeg"
]
},
{
"title": "Patchwork",
"photos": [
"IMG_4556.jpeg"
]
},
{
"title": "Jokin Ha...",
"photos": [
"IMG_4506.jpeg"
]
},
{
"title": "CHEAPASS GAMES",
"photos": [
"IMG_4519.jpeg"
]
},
{
"title": "CATAN",
"photos": [
"IMG_4523.jpeg"
]
},
{
"title": "CAR WARS",
"photos": [
"IMG_4532.jpeg"
]
},
{
"title": "Bird on Your Bread?!",
"photos": [
"IMG_4517.jpeg"
]
}
]
+2 -132
View File
@@ -428,19 +428,6 @@
],
"title_normalized": "mantis falls game of trust"
},
{
"title_raw": "Jokin Ha...",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "White box with simple stick-figure character illustration in blue shirt, partially obscured by a blue 'Nostalgia Pack' sleeve item and card deck; likely 'Joking Hazard' based on visible fragment and cyanide & happiness-style art",
"source_photos": [
"IMG_4506.jpeg"
],
"title_normalized": "jokin ha"
},
{
"title_raw": "Herbaceous",
"confidence": "high",
@@ -522,19 +509,6 @@
],
"title_normalized": "bugs in kitchen"
},
{
"title_raw": "SCRABBLE",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red box edge visible top right, only partial letters 'RABBLE' legible",
"source_photos": [
"IMG_4508.jpeg"
],
"title_normalized": "scrabble"
},
{
"title_raw": "CATAN DICE GAME",
"confidence": "high",
@@ -899,19 +873,6 @@
],
"title_normalized": "pie face"
},
{
"title_raw": "Bird on Your Bread?!",
"confidence": "low",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Blue box with cartoon bird/character illustration, tall narrow spine",
"source_photos": [
"IMG_4517.jpeg"
],
"title_normalized": "bird on your bread"
},
{
"title_raw": "Walk the Plank!",
"confidence": "high",
@@ -966,20 +927,7 @@
"title_normalized": "mage wars academy core set"
},
{
"title_raw": "SCERU MERRY MEN",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Orange box with dinosaur/creature illustration, fantasy medieval theme art",
"source_photos": [
"IMG_4518.jpeg"
],
"title_normalized": "sceru merry men"
},
{
"title_raw": "DOCTOR WHO THE GAME OF TIME",
"title_raw": "DOCTOR WHO THE card GAME",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
@@ -989,7 +937,7 @@
"source_photos": [
"IMG_4518.jpeg"
],
"title_normalized": "doctor who game of time"
"title_normalized": "doctor who card game"
},
{
"title_raw": "DUNGEONS & DRAGONS",
@@ -1017,19 +965,6 @@
],
"title_normalized": "dungeons and dragons fantasy game rules"
},
{
"title_raw": "CHEAPASS GAMES",
"confidence": "low",
"publisher_hint": "Cheapass Games",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Vertical white spine text near right side of shelf, appears to be publisher branding rather than a title",
"source_photos": [
"IMG_4519.jpeg"
],
"title_normalized": "cheapass games"
},
{
"title_raw": "Before I Kill You, Mister Bond...",
"confidence": "high",
@@ -1161,32 +1096,6 @@
],
"title_normalized": "sleeping gods distant skies"
},
{
"title_raw": "CATAN",
"confidence": "high",
"publisher_hint": "Catan",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red spine, small circular emblem visible on spine",
"source_photos": [
"IMG_4523.jpeg"
],
"title_normalized": "catan"
},
{
"title_raw": "THE CATAN CARD GAME",
"confidence": "high",
"publisher_hint": "Catan",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red/orange spine with yellow banner text",
"source_photos": [
"IMG_4523.jpeg"
],
"title_normalized": "catan card game"
},
{
"title_raw": "THE RIVALS FOR CATAN CARD GAME",
"confidence": "high",
@@ -1304,19 +1213,6 @@
],
"title_normalized": "wiz war"
},
{
"title_raw": "SLUGFEST GAMES",
"confidence": "low",
"publisher_hint": "Slugfest Games",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Partial box visible at top of image, red/orange background with illustrated characters, this may be a publisher logo rather than a game title",
"source_photos": [
"IMG_4528.jpeg"
],
"title_normalized": "slugfest games"
},
{
"title_raw": "RISK THE LORD OF THE RINGS: The Middle-earth Conquest Game",
"confidence": "high",
@@ -1382,19 +1278,6 @@
],
"title_normalized": "gloomhaven"
},
{
"title_raw": "CAR WARS",
"confidence": "medium",
"publisher_hint": "sjgames.com",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red box top left showing miniature cars, partially visible at top of frame",
"source_photos": [
"IMG_4532.jpeg"
],
"title_normalized": "car wars"
},
{
"title_raw": "DEADLY DOODLES",
"confidence": "high",
@@ -1720,19 +1603,6 @@
],
"title_normalized": "kill doctor lucky"
},
{
"title_raw": "Patchwork",
"confidence": "low",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Orange/tan box, only partial text '...TCH ...ORK' visible with quilt/patchwork square pattern",
"source_photos": [
"IMG_4556.jpeg"
],
"title_normalized": "patchwork"
},
{
"title_raw": "ALICE IS MISSING SILENT FALLS EXPANSION",
"confidence": "high",
+1
View File
@@ -243,6 +243,7 @@ def _merge(a: dict, b: dict) -> dict:
# the fields a human correction may override on an extracted entry
EDIT_FIELDS = (
"title_raw",
"confidence",
"publisher_hint",
"edition_hint",
"year_hint",
+1
View File
@@ -74,6 +74,7 @@ async function refreshBadges() {
if (el) el.textContent = n > 0 ? String(n) : "";
};
set("photos", p.reshoot);
set("titles", p.shaky_reads);
set("review", p.pending_review);
set("queue", p.to_add + p.to_update);
return p;
+11 -10
View File
@@ -2,7 +2,7 @@
<div class="pagebar">
<a href="#flow">The flow</a>
<a href="#pages">Pages</a>
<a href="#curation">Fixing the catalog</a>
<a href="#curation">Fixing the titles</a>
<a href="#statuses">Statuses</a>
<a href="#keys">Keyboard</a>
<a href="#files">Your data</a>
@@ -10,34 +10,35 @@
<h2 id="flow">The flow: shelves → collection</h2>
<div class="card">
<p>Six stages, run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<p>Six pipeline stages and two checkpoints that are yours. Stages run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<ol>
<li><b>extract</b> — every photo goes to Claude vision once; titles and edition cues (publisher, edition wording, year, language) land in the catalog. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
<li><b>resolve</b> — titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged for review, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
<li><b>review</b>you decide the flagged ones on the <a href="/review">Review</a> page. Every decision saves immediately.</li>
<li><b>extract</b> — every photo goes to Claude vision once; what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
<li><b>proofread</b> <i>(you, on <a href="/titles">Titles</a>)</i> — fix misread titles, add cues you know, split multi-box lines, remove non-games, <a href="#curation">details below</a>. Worth doing <i>before</i> resolve: a fix made now is one BGG search done right; a fix made later sends the title back through resolve again.</li>
<li><b>resolve</b>titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
<li><b>review</b> <i>(you, on <a href="/review">Review</a>)</i> — decide the flagged ones: which game, which edition, whether two reads are one box. Every decision saves immediately.</li>
<li><b>diff</b> — your existing BGG collection is fetched and compared, per copy. What's genuinely new lands in the <a href="/queue">Queue</a>.</li>
<li><b>upload</b> — a real browser logs into BGG and adds each queued game, slowly and politely. Always dry-run first; the buttons enforce that order.</li>
<li><b>enrich</b> — full metadata (players, weight, rank, artwork) fills the <a href="/library">Library</a>.</li>
</ol>
<p>Every stage is resumable: stop anything mid-run and nothing is lost. Re-runs skip work already done.</p>
<p>It's a loop, not a line: new photos, edits, and splits feed both checkpoints again, and the sidebar badges show when a page has work for you. Every stage is resumable stop anything mid-run and nothing is lost; re-runs skip work already done.</p>
</div>
<h2 id="pages">What each page is for</h2>
<div class="card">
<p><b><a href="/">Pipeline</a></b> — 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.</p>
<p><b><a href="/photos">Photos</a></b> — drag photos in (or drop them in the <code>photos/</code> folder). 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 with the same name re-extracts it.</p>
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts shaky reads (the model wasn't sure) that haven't been resolved or human-verified yet.</p>
<p><b><a href="/review">Review</a></b> — 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 <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/catalog">Catalog</a></b> — every title read from your shelves, alphabetized, with its status and photos. This is also where you <a href="#curation">fix the data itself</a>: edit, split, remove.</p>
<p><b><a href="/queue">Queue</a></b> — 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.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.</p>
</div>
<h2 id="curation">Fixing the catalog: edit, split, remove</h2>
<h2 id="curation">Fixing the titles: edit, split, remove</h2>
<div class="card">
<p>Vision reads aren't perfect, and you know things the photos don't show. Every catalog line has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
<p><b>remove from catalog</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p>
<p><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p>
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
</div>
+6 -1
View File
@@ -46,7 +46,12 @@ function render() {
· <a href="/queue">inspect the queue</a>`;
document.getElementById("stages").innerHTML = [
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`, runBtn("extract")),
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`
+ (P.titles > 0
? P.shaky_reads > 0
? ` · <b>${P.shaky_reads}</b> shaky read(s) worth a <a href="/titles">proofread</a> before resolving`
: ` · <a href="/titles">proofread the titles</a> before resolving`
: ""), runBtn("extract")),
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
+1 -1
View File
@@ -142,7 +142,7 @@ function render() {
<div class="done">
<h2>${waiting
? "Resolved set fully reviewed"
: "All reviewed — the catalog is diff-ready"}</h2>
: "All reviewed — every title is diff-ready"}</h2>
<div class="nums">
<div>${s.summary.extracted}<span>extracted</span></div>
<div>${s.summary.recognized}<span>recognized</span></div>
@@ -1,4 +1,4 @@
<h1>Catalog</h1>
<h1>Titles</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter catalog titles">
+21 -5
View File
@@ -1,6 +1,6 @@
"""The local web app: six pages in a shared sidebar shell.
Pipeline (/), Photos, Review, Catalog, Queue, and Library rendered
Pipeline (/), Photos, Titles, Review, Queue, Library, and Help rendered
server-side from templates/shell.html plus one fragment per page, with
static/app.css as the design system and static/app.js as shared client
plumbing. No template engine and no build step; served on localhost only.
@@ -228,11 +228,13 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
# (href, page-name, label, badge-name) — badge names match app.js
# ordered as the real workflow runs: photograph, proofread the reads
# (catalog), resolve + decide (review), then what ships (queue, library)
NAV_PAGES = (
("/", "pipeline", "Pipeline", ""),
("/photos", "photos", "Photos", "photos"),
("/titles", "titles", "Titles", "titles"),
("/review", "review", "Review", "review"),
("/catalog", "catalog", "Catalog", ""),
("/queue", "queue", "Queue", "queue"),
("/library", "library", "Library", ""),
("/help", "help", "Help", ""),
@@ -544,9 +546,14 @@ def create_app(
def review_page() -> str:
return render_page("review")
@app.get("/catalog", response_class=HTMLResponse)
def catalog_page() -> str:
return render_page("catalog")
@app.get("/titles", response_class=HTMLResponse)
def titles_page() -> str:
return render_page("titles")
@app.get("/catalog")
def catalog_redirect() -> Response:
# the page's old name; bookmarks keep working
return Response(status_code=307, headers={"Location": "/titles"})
@app.get("/queue", response_class=HTMLResponse)
def queue_page() -> str:
@@ -644,6 +651,14 @@ def create_app(
"photos": len(photo_names()),
"reshoot": len(open_sightings()),
"titles": len(session.titles),
# unresolved shaky reads: the proofread-before-resolve
# signal (an edit marks the entry high — human-verified)
"shaky_reads": sum(
1
for e in session.titles
if e.confidence != "high"
and e.title_raw not in {r["title_raw"] for r in session.rows}
),
"matches": dict(match_counts),
"pending_review": len(session.pending_rows())
+ len(session.version_rows()),
@@ -860,6 +875,7 @@ def create_app(
record["year_hint"] = int(year) if year else None
if not (record.keys() - {"match", "photos"}):
raise HTTPException(400, "nothing to change")
record["confidence"] = "high" # a human verified this line
# write order is crash-safety: drop the stale rows first (worst
# case on a crash: resolve recreates them from the uncorrected
# entry), then the durable record (replayed by every future
+1 -1
View File
@@ -226,7 +226,7 @@ def test_every_page_serves_with_shared_shell(tmp_path):
("/", "Pipeline"),
("/photos", "Reshoot"),
("/review", "Review"),
("/catalog", "Catalog"),
("/titles", "Titles"),
("/queue", "Upload queue"),
("/library", "Library"),
):
+49 -1
View File
@@ -772,6 +772,54 @@ def test_help_page_serves_and_is_in_nav(tmp_path):
web, _ = make_client(tmp_path)
res = web.get("/help")
assert res.status_code == 200
assert "Fixing the catalog" in res.text
assert "Fixing the titles" in res.text
# every page's nav includes the Help entry
assert 'href="/help"' in web.get("/").text
def test_shaky_reads_badge_counts_and_drains_on_edit(tmp_path):
cfg = make_cfg(tmp_path)
titles = json.loads(cfg.titles_path.read_text())
titles.append(
{
"title_raw": "Hebarceos",
"confidence": "low",
"source_photos": ["shelf.jpg"],
}
)
cfg.titles_path.write_text(json.dumps(titles))
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
assert web.get("/api/pipeline").json()["shaky_reads"] == 1
# an edit means a human read the line: it is verified, the badge drains
assert (
web.post(
"/api/edit-title",
json={
"title_raw": "Hebarceos",
"source_photos": "shelf.jpg",
"title_new": "Herbaceous",
},
).status_code
== 200
)
assert web.get("/api/pipeline").json()["shaky_reads"] == 0
def test_nav_order_matches_workflow(tmp_path):
web, _ = make_client(tmp_path)
html = web.get("/").text
order = [
html.index(f'href="{href}"')
for href in (
"/",
"/photos",
"/titles",
"/review",
"/queue",
"/library",
"/help",
)
]
assert order == sorted(order)
# the old address still lands on the page
assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles"