Files
bggpipe/tests/test_web_dashboard.py
T
Eric Wagoner 189c534315 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>
2026-08-02 17:34:17 -04:00

318 lines
9.9 KiB
Python

"""Dashboard/API tests: job lifecycle, pipeline status, photo upload —
stage functions are injected so nothing slow or networked ever runs."""
from __future__ import annotations
import threading
import typer
from fastapi.testclient import TestClient
from bggpipe.config import Config
from bggpipe.jobs import JobRunner
from bggpipe.webreview import create_app
def _cfg(tmp_path) -> Config:
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
cfg.photos_dir.mkdir(parents=True)
cfg.data_dir.mkdir(parents=True)
return cfg
def _app(cfg, stages=None, jobs=None) -> TestClient:
return TestClient(create_app(cfg, stages=stages or {}, jobs=jobs))
# -- JobRunner ----------------------------------------------------------
def test_job_captures_output_and_finishes():
runner = JobRunner()
def stage():
typer.echo("line one")
typer.echo("line two")
assert runner.start("extract", stage)
runner.wait()
snap = runner.snapshot()
assert snap["status"] == "done"
assert snap["log"] == ["line one", "line two"]
def test_job_failure_is_reported_not_swallowed():
runner = JobRunner()
runner.start("resolve", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
runner.wait()
snap = runner.snapshot()
assert snap["status"] == "failed"
assert "RuntimeError: boom" in snap["error"]
def test_typer_exit_code_counts_as_failure():
runner = JobRunner()
def stage():
raise typer.Exit(code=1)
runner.start("diff", stage)
runner.wait()
assert runner.snapshot()["status"] == "failed"
def test_single_slot_rejects_second_job():
runner = JobRunner()
release = threading.Event()
assert runner.start("extract", release.wait)
assert not runner.start("resolve", lambda: None) # slot busy
release.set()
runner.wait()
assert runner.start("resolve", lambda: None) # slot free again
runner.wait()
# -- /api/run + /api/job ------------------------------------------------
def test_run_stage_lifecycle_via_api(tmp_path):
cfg = _cfg(tmp_path)
ran = []
jobs = JobRunner()
web = _app(cfg, stages={"extract": lambda: ran.append(1)}, jobs=jobs)
res = web.post("/api/run/extract")
assert res.status_code == 200
jobs.wait()
assert ran == [1]
assert web.get("/api/job").json()["status"] == "done"
def test_unknown_stage_404s(tmp_path):
web = _app(_cfg(tmp_path))
assert web.post("/api/run/frobnicate").status_code == 404
def test_busy_runner_409s(tmp_path):
cfg = _cfg(tmp_path)
release = threading.Event()
jobs = JobRunner()
web = _app(
cfg, stages={"extract": release.wait, "resolve": lambda: None}, jobs=jobs
)
assert web.post("/api/run/extract").status_code == 200
assert web.post("/api/run/resolve").status_code == 409
release.set()
jobs.wait()
def test_upload_defaults_to_dry_run(tmp_path):
cfg = _cfg(tmp_path)
calls = []
jobs = JobRunner()
def upload(dry_run=True, limit=None):
calls.append({"dry_run": dry_run, "limit": limit})
web = _app(cfg, stages={"upload": upload}, jobs=jobs)
web.post("/api/run/upload") # no body: the safe direction
jobs.wait()
web.post("/api/run/upload", json={"dry_run": False, "limit": 2})
jobs.wait()
assert calls == [
{"dry_run": True, "limit": None},
{"dry_run": False, "limit": 2},
]
# -- /api/pipeline ------------------------------------------------------
def test_pipeline_reports_counts_and_never_values(tmp_path, monkeypatch):
monkeypatch.setenv("BGG_USERNAME", "supersecretname")
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
cfg = _cfg(tmp_path)
(cfg.photos_dir / "a.jpg").write_bytes(b"x")
web = _app(cfg)
payload = web.get("/api/pipeline").json()
assert payload["photos"] == 1
assert payload["env"]["BGG_USERNAME"] is True
assert payload["env"]["BGG_API_TOKEN"] is False
assert "supersecretname" not in web.get("/api/pipeline").text
def test_pipeline_flags_stub_data(tmp_path):
cfg = _cfg(tmp_path)
(cfg.data_dir / "STUB_DATA.marker").write_text("stub")
assert _app(cfg).get("/api/pipeline").json()["stub_data"] is True
# -- /api/photos --------------------------------------------------------
def test_photo_upload_saves_and_invalidates_raw_cache(tmp_path):
cfg = _cfg(tmp_path)
cfg.extract_raw_dir.mkdir(parents=True)
stale = cfg.extract_raw_dir / "shelf.jpg.json"
stale.write_text("{}")
web = _app(cfg)
res = web.post(
"/api/photos", files={"files": ("shelf.jpg", b"\xff\xd8jpegdata", "image/jpeg")}
)
assert res.status_code == 200
assert (cfg.photos_dir / "shelf.jpg").read_bytes() == b"\xff\xd8jpegdata"
assert not stale.exists() # re-upload means re-extract
def test_photo_upload_rejects_non_photos_and_path_tricks(tmp_path):
cfg = _cfg(tmp_path)
web = _app(cfg)
res = web.post("/api/photos", files={"files": ("notes.txt", b"hi", "text/plain")})
assert res.status_code == 400
res = web.post(
"/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
assert (cfg.photos_dir / "escape.jpg").exists()
assert not (tmp_path / "escape.jpg").exists()
# -- pages --------------------------------------------------------------
def test_dashboard_and_review_pages_serve(tmp_path):
web = _app(_cfg(tmp_path))
assert "Pipeline" in web.get("/").text
assert "bggpipe" in web.get("/review").text
# -- design system + navigation -----------------------------------------
def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path):
web = _app(_cfg(tmp_path))
css = web.get("/static/app.css")
assert css.status_code == 200
assert css.headers["content-type"].startswith("text/css")
assert "--accent" in css.text # the token layer, not an empty file
for path in ("/", "/review"):
assert 'href="/static/app.css"' in web.get(path).text
def test_both_pages_carry_navigation_and_skip_link(tmp_path):
web = _app(_cfg(tmp_path))
for path, current in (("/", 'href="/"'), ("/review", 'href="/review"')):
html = web.get(path).text
assert 'nav aria-label="Primary"' in html
assert f'<a {current} aria-current="page"' in html.replace("\n", " ") or (
current in html and 'aria-current="page"' in html
)
assert 'class="skip"' in html
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