Six-page app: sidebar shell with Juniper's portrait, whole-workflow IA
The two-page dashboard/review split becomes a proper information architecture: Pipeline (stages + live activity), Photos (drag-and-drop, gallery with per-photo extraction state, reshoot tickets — photo work lives with photos), Review (decisions only, keyboard-first), Catalog (the full title ledger with filtering), Queue (what upload will do and everything it has done), and Library (the enriched collection browser, with an honest empty state until real BGG data lands). Pages render server-side from a shared shell — sidebar rail with the rainbow path running its edge, live count badges on Photos/Review/Queue, and Juniper's full portrait finally displayed, with her credit and a standard third-party trademark attribution beneath it (one notice, not per-mention symbols — the convention for referring to another party's mark). Shared client plumbing moves to static/app.js (escaping contract documented at the innerHTML sink). New endpoints: /api/photos-list, /api/queue, /api/library, plus a reshoot count in /api/pipeline. Screenshot review caught two real bugs: photos-list crashed on bare-array raw caches, and .DS_Store was listed as a shelf photo — photo_names() now filters by suffix everywhere, including the /photos allowlist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,3 +214,104 @@ def test_both_pages_carry_navigation_and_skip_link(tmp_path):
|
||||
def test_activity_region_announces_politely(tmp_path):
|
||||
html = _app(_cfg(tmp_path)).get("/").text
|
||||
assert 'aria-live="polite"' in html
|
||||
|
||||
|
||||
# -- six-page shell -----------------------------------------------------
|
||||
|
||||
|
||||
def test_every_page_serves_with_shared_shell(tmp_path):
|
||||
web = _app(_cfg(tmp_path))
|
||||
for path, marker in (
|
||||
("/", "Pipeline"),
|
||||
("/photos", "Reshoot"),
|
||||
("/review", "Review"),
|
||||
("/catalog", "Catalog"),
|
||||
("/queue", "Upload queue"),
|
||||
("/library", "Library"),
|
||||
):
|
||||
html = web.get(path).text
|
||||
assert marker in html, path
|
||||
assert 'nav aria-label="Primary"' in html, path
|
||||
assert 'aria-current="page"' in html, path
|
||||
assert "logo-full.jpg" in html, path # Juniper's portrait in the rail
|
||||
assert "art by Juniper" in html, path
|
||||
assert "trademarks of" in html, path # attribution notice
|
||||
|
||||
|
||||
def test_photos_list_reports_extraction_state(tmp_path):
|
||||
import json as _json
|
||||
|
||||
cfg = _cfg(tmp_path)
|
||||
(cfg.photos_dir / "done.jpg").write_bytes(b"x")
|
||||
(cfg.photos_dir / "fresh.jpg").write_bytes(b"x")
|
||||
cfg.extract_raw_dir.mkdir(parents=True)
|
||||
(cfg.extract_raw_dir / "done.jpg.json").write_text(
|
||||
_json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]})
|
||||
)
|
||||
listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()}
|
||||
assert listing["done.jpg"] == {
|
||||
"name": "done.jpg",
|
||||
"extracted": True,
|
||||
"titles": 1,
|
||||
"unidentified": 2,
|
||||
}
|
||||
assert listing["fresh.jpg"]["extracted"] is False
|
||||
|
||||
|
||||
def test_queue_endpoint_serves_all_three_ledgers(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
(cfg.to_add_path).write_text("bgg_id,bgg_name\n13,Catan\n")
|
||||
q = _app(cfg).get("/api/queue").json()
|
||||
assert q["to_add"] == [{"bgg_id": "13", "bgg_name": "Catan"}]
|
||||
assert q["to_update"] == [] and q["log"] == []
|
||||
|
||||
|
||||
def test_library_serves_games_sorted_or_empty(tmp_path):
|
||||
import json as _json
|
||||
|
||||
cfg = _cfg(tmp_path)
|
||||
web = _app(cfg)
|
||||
assert web.get("/api/library").json() == []
|
||||
cfg.games_path.write_text(
|
||||
_json.dumps(
|
||||
{
|
||||
"13": {"name": "Catan", "year": 1995},
|
||||
"266192": {"name": "Wingspan", "year": 2019},
|
||||
"1": {"name": "aliens", "year": 2000},
|
||||
}
|
||||
)
|
||||
)
|
||||
names = [g["name"] for g in web.get("/api/library").json()]
|
||||
assert names == ["aliens", "Catan", "Wingspan"] # casefold sort
|
||||
|
||||
|
||||
def test_pipeline_reports_reshoot_count(tmp_path):
|
||||
import json as _json
|
||||
|
||||
cfg = _cfg(tmp_path)
|
||||
cfg.unidentified_path.write_text(
|
||||
_json.dumps({"a.jpg": [{"location": "top shelf"}]})
|
||||
)
|
||||
assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1
|
||||
|
||||
|
||||
def test_photos_list_tolerates_bare_array_raw_cache(tmp_path):
|
||||
import json as _json
|
||||
|
||||
cfg = _cfg(tmp_path)
|
||||
(cfg.photos_dir / "old.jpg").write_bytes(b"x")
|
||||
cfg.extract_raw_dir.mkdir(parents=True)
|
||||
(cfg.extract_raw_dir / "old.jpg.json").write_text(
|
||||
_json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}])
|
||||
)
|
||||
(item,) = _app(cfg).get("/api/photos-list").json()
|
||||
assert item["extracted"] is True and item["titles"] == 2
|
||||
|
||||
|
||||
def test_non_photo_files_are_invisible(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
(cfg.photos_dir / "shelf.jpg").write_bytes(b"x")
|
||||
(cfg.photos_dir / ".DS_Store").write_bytes(b"junk")
|
||||
web = _app(cfg)
|
||||
assert [p["name"] for p in web.get("/api/photos-list").json()] == ["shelf.jpg"]
|
||||
assert web.get("/photos/.DS_Store").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user