Eric's wall answered the orientation question with a screenshot full of red: the capacity model assumed one flat stack per opening, so his spine-out cubes read 200% full. His reviewer's prescription, built to its acceptance cases: each opening packs a FLAT lane (thinnest axes against interior height, claiming the widest flat box's width) and a STANDING lane beside it (boxes on edge against the remaining width; standing boxes must fit height and depth upright). The largest- footprint class lies flat, smaller boxes stand, any box's lane is flippable per-assignment (▬/▮ toggle in the opening view, ✱ marks an override), and ⚠ now means NO packing fits — not merely "tall stack". Unmeasured boxes take no lane but keep their honesty tag; dual fill bars show each lane's budget; a broken opening is never offered by the suggester. On the real wall: the double-wides and half the cubes went green (7 games = 13.12" flat + 2.91" standing), and the remaining warnings mark cubes that genuinely hold 12-16 boxes. Both reviewer acceptance tests pass verbatim. And the export gains the humanity Eric asked for: game pages say where each box lives, containers list their contents, and a shelves page draws the wall as it physically stands — proportional cells, zones, descriptions, every opening linking its residents. Publishing a shelf layout is a choice: --no-shelves keeps the layer out. "shelves" joins "art" as a reserved slug. 387 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
407 lines
15 KiB
Python
407 lines
15 KiB
Python
"""Static export: pages, art downloads, badge slot, privacy. Offline —
|
|
cover downloads run against a mock CDN transport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from bggpipe.config import Config
|
|
from bggpipe.export import run_export
|
|
|
|
GAMES = {
|
|
"240:24621": {
|
|
"bgg_id": 240,
|
|
"type": "boardgame",
|
|
"name": "Britannia",
|
|
"year": 1986,
|
|
"description": "A game of <invasions> & settlement.",
|
|
"image": "https://cf.geekdo-images.com/orig/pic1.jpg",
|
|
"thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg",
|
|
"min_players": 3,
|
|
"max_players": 5,
|
|
"best_player_counts": ["4"],
|
|
"playtime": 240,
|
|
"designers": ["Lewis Pulsipher"],
|
|
"rank": 1210,
|
|
"weight": 3.16,
|
|
"version": {
|
|
"version_id": 24621,
|
|
"name": "Avalon Hill second edition",
|
|
"year": 1987,
|
|
"publishers": ["Avalon Hill"],
|
|
"languages": ["English"],
|
|
},
|
|
"source_photos": ["IMG_1.jpeg"],
|
|
},
|
|
"311654": {
|
|
"bgg_id": 311654,
|
|
"type": "rpgitem",
|
|
"name": "Alice is Missing",
|
|
"year": 2020,
|
|
"image": "https://cf.geekdo-images.com/orig/pic2.png",
|
|
"thumbnail": "",
|
|
"source_photos": ["IMG_2.jpeg"],
|
|
},
|
|
"local:homebrew:x.jpg": {
|
|
"type": "localgame",
|
|
"name": "Britannia", # deliberate name collision with 240:24621
|
|
"image": "/local-art/abc123.jpg",
|
|
"source_photos": ["x.jpg"],
|
|
},
|
|
}
|
|
|
|
|
|
def _cfg(tmp_path: Path) -> Config:
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
cfg.games_path.write_text(json.dumps(GAMES))
|
|
cfg.local_art_dir.mkdir()
|
|
(cfg.local_art_dir / "abc123.jpg").write_bytes(FAKE_JPEG)
|
|
return cfg
|
|
|
|
|
|
FAKE_JPEG = b"\xff\xd8" + b"\x00" * 100 # magic + enough body to pass sniffing
|
|
|
|
|
|
def _cdn(requests_log: list[str]) -> httpx.Client:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests_log.append(str(request.url))
|
|
return httpx.Response(200, content=FAKE_JPEG)
|
|
|
|
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
|
|
|
|
def test_export_writes_pages_art_and_no_shelf_photos(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
log: list[str] = []
|
|
summary = run_export(
|
|
cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None
|
|
)
|
|
assert summary == {"games": 3, "downloaded": 3, "failures": 0, "removed": 0}
|
|
|
|
index = (out / "index.html").read_text()
|
|
assert "Test Shelves" in index and "Powered by BGG" in index
|
|
assert "trademarks of" in index
|
|
|
|
# name collision -> distinct stable slugs
|
|
assert (out / "britannia" / "index.html").exists()
|
|
assert (out / "britannia-2" / "index.html").exists()
|
|
assert (out / "alice-is-missing" / "index.html").exists()
|
|
|
|
detail = (out / "britannia" / "index.html").read_text()
|
|
assert "Avalon Hill second edition" in detail
|
|
assert "<invasions> & settlement" in detail # escaped, not raw
|
|
# BGG double-encodes entities; the page shows the character, not ’
|
|
assert "&rsquo" not in detail
|
|
assert "boardgamegeek.com/boardgame/240" in detail
|
|
rpg = (out / "alice-is-missing" / "index.html").read_text()
|
|
assert "rpggeek.com/rpgitem/311654" in rpg
|
|
|
|
# covers landed locally and pages never hotlink the CDN
|
|
# 2 full + 1 thumb + local art + the colophon's piper
|
|
assert len(list((out / "art").glob("*"))) == 5
|
|
all_html = index + detail + rpg
|
|
assert "geekdo-images.com" not in all_html
|
|
|
|
# the privacy rule: shelf photo names appear nowhere in the output
|
|
assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html
|
|
|
|
|
|
def test_export_is_idempotent_and_offline_on_rerun(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
|
|
def refuse(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(f"re-run fetched {request.url}")
|
|
|
|
summary = run_export(
|
|
cfg,
|
|
out,
|
|
fetch=httpx.Client(transport=httpx.MockTransport(refuse)),
|
|
sleep=lambda s: None,
|
|
)
|
|
assert summary["downloaded"] == 0
|
|
|
|
|
|
def test_failed_cover_reports_and_ships_page_anyway(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
|
|
def flaky(request: httpx.Request) -> httpx.Response:
|
|
if "pic2" in str(request.url):
|
|
return httpx.Response(503)
|
|
return httpx.Response(200, content=FAKE_JPEG)
|
|
|
|
summary = run_export(
|
|
cfg,
|
|
tmp_path / "site",
|
|
fetch=httpx.Client(transport=httpx.MockTransport(flaky)),
|
|
sleep=lambda s: None,
|
|
)
|
|
assert summary["failures"] == 1
|
|
page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text()
|
|
assert "Alice is Missing" in page # the page exists, just coverless
|
|
# nothing half-written left behind to poison the idempotent re-run
|
|
assert not list((tmp_path / "site" / "art").glob("*.part"))
|
|
|
|
|
|
def test_official_badge_upgrades_the_footer(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert (out / "art" / "powered-by-bgg.png").exists()
|
|
index = (out / "index.html").read_text()
|
|
assert "art/powered-by-bgg.png" in index and "Powered by BGG" in index
|
|
detail = (out / "britannia" / "index.html").read_text()
|
|
assert "../art/powered-by-bgg.png" in detail
|
|
|
|
|
|
def test_export_without_games_json_exits_with_guidance(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
import typer
|
|
|
|
with pytest.raises(typer.Exit):
|
|
run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
|
|
|
|
|
|
def test_urls_survive_adding_and_removing_games(tmp_path):
|
|
"""The manifest's whole point: a published URL never moves and is
|
|
never stolen — not by an earlier-sorting new edition, not by the
|
|
base-slug holder leaving."""
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
original = (out / "britannia" / "index.html").read_text()
|
|
assert "Avalon Hill second edition" in original
|
|
|
|
# a new same-named edition whose key sorts FIRST must not steal slugs
|
|
games = json.loads(cfg.games_path.read_text())
|
|
games["100:1"] = {"bgg_id": 100, "type": "boardgame", "name": "Britannia"}
|
|
cfg.games_path.write_text(json.dumps(games))
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert (
|
|
"Avalon Hill second edition" in (out / "britannia" / "index.html").read_text()
|
|
)
|
|
assert (out / "britannia-3" / "index.html").exists() # the newcomer
|
|
|
|
# removing the base-slug holder must not renumber the survivors —
|
|
# and must actually remove the stale page
|
|
del games["240:24621"]
|
|
cfg.games_path.write_text(json.dumps(games))
|
|
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert summary["removed"] == 1
|
|
assert not (out / "britannia" / "index.html").exists()
|
|
assert (out / "britannia-2" / "index.html").exists() # localgame, unmoved
|
|
index = (out / "index.html").read_text()
|
|
assert "britannia/" not in index.replace("britannia-2/", "").replace(
|
|
"britannia-3/", ""
|
|
)
|
|
|
|
|
|
def test_changed_cover_url_is_refetched(tmp_path):
|
|
"""BGG replaces box art under the same extension: the manifest records
|
|
each cover's source URL so a change re-downloads."""
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
|
|
games = json.loads(cfg.games_path.read_text())
|
|
games["240:24621"]["image"] = "https://cf.geekdo-images.com/orig/pic9.jpg"
|
|
cfg.games_path.write_text(json.dumps(games))
|
|
log: list[str] = []
|
|
summary = run_export(cfg, out, fetch=_cdn(log), sleep=lambda s: None)
|
|
assert summary["downloaded"] == 1
|
|
assert any("pic9" in u for u in log)
|
|
|
|
|
|
def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path):
|
|
"""A CDN interstitial served with 200 must not be cached as a cover:
|
|
exists() would then skip it forever."""
|
|
cfg = _cfg(tmp_path)
|
|
|
|
def interstitial(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, content=b"<html>challenge page</html>" * 10)
|
|
|
|
summary = run_export(
|
|
cfg,
|
|
tmp_path / "site",
|
|
fetch=httpx.Client(transport=httpx.MockTransport(interstitial)),
|
|
sleep=lambda s: None,
|
|
)
|
|
assert summary["failures"] == 3
|
|
assert summary["downloaded"] == 0
|
|
# only the local-art copy survives; no CDN garbage was cached
|
|
names = {f.name for f in (tmp_path / "site" / "art").iterdir()}
|
|
assert names == {"abc123.jpg", "bggpipe-piper.jpg"}
|
|
|
|
|
|
def test_missing_local_art_is_a_counted_failure(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.local_art_dir / "abc123.jpg").unlink()
|
|
summary = run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
|
|
assert summary["failures"] == 1 # local_art is the ONLY source: say so
|
|
|
|
|
|
def test_interrupted_download_leaves_no_cover_and_recovers(tmp_path, monkeypatch):
|
|
"""The non-vacuous atomicity test: die BETWEEN response and write,
|
|
assert nothing half-made survives and the re-run completes."""
|
|
import bggpipe.export as export_mod
|
|
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
real_write = export_mod.atomic_write_bytes
|
|
calls = {"n": 0}
|
|
|
|
def die_on_second(path, data):
|
|
calls["n"] += 1
|
|
if calls["n"] == 2:
|
|
raise KeyboardInterrupt
|
|
real_write(path, data)
|
|
|
|
monkeypatch.setattr(export_mod, "atomic_write_bytes", die_on_second)
|
|
with pytest.raises(KeyboardInterrupt):
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert len(list((out / "art").glob("*.part"))) == 0
|
|
|
|
monkeypatch.setattr(export_mod, "atomic_write_bytes", real_write)
|
|
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert summary["failures"] == 0
|
|
assert (out / "index.html").exists()
|
|
|
|
|
|
def test_minimal_and_numeric_entries_render(tmp_path):
|
|
"""Entries vary wildly: a name-only localgame and enrich-shaped
|
|
numerics (int best counts, int year) must render, not crash."""
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
cfg.games_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"local:mystery:x.jpg": {"type": "localgame", "name": "Mystery"},
|
|
"7": {
|
|
"bgg_id": 7,
|
|
"type": "boardgame",
|
|
"name": "Numeric",
|
|
"year": 1980,
|
|
"min_players": 1,
|
|
"max_players": 1,
|
|
"best_player_counts": [1],
|
|
"weight": 2,
|
|
"rating": 7,
|
|
},
|
|
}
|
|
)
|
|
)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
numeric = (out / "numeric" / "index.html").read_text()
|
|
assert "1 player" in numeric and "best at 1" in numeric
|
|
assert (out / "mystery" / "index.html").exists()
|
|
|
|
|
|
def test_cli_export_wiring(tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
from bggpipe.cli import app
|
|
|
|
received = {}
|
|
|
|
def fake_run_export(cfg, out, *, title, include_shelves=True):
|
|
received.update(out=out, title=title)
|
|
return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}
|
|
|
|
monkeypatch.setattr("bggpipe.export.run_export", fake_run_export)
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app, ["export", "--out", str(tmp_path / "s"), "--title", "T"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert received["title"] == "T" and received["out"] == tmp_path / "s"
|
|
assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required
|
|
|
|
def failing_run_export(cfg, out, *, title, include_shelves=True):
|
|
return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}
|
|
|
|
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
|
|
result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")])
|
|
assert result.exit_code == 1 # scripted publishes must see failures
|
|
|
|
|
|
def test_colophon_card_closes_the_shelf(tmp_path):
|
|
"""The last card: piper, badge, fine print — a div the search filter
|
|
(which selects a.game) can never hide."""
|
|
cfg = _cfg(tmp_path)
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None)
|
|
index = (out / "index.html").read_text()
|
|
assert 'class="game colophon"' in index
|
|
assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index
|
|
assert "trademarks of BoardGameGeek" in index
|
|
assert (out / "art" / "bggpipe-piper.jpg").exists()
|
|
# the filter only ever hides <a class="game"> — the colophon is a div
|
|
assert '<div class="game colophon"' in index
|
|
|
|
|
|
def test_shelves_export_and_optout(tmp_path):
|
|
"""The wall ships with the site — locations on game pages, a shelves
|
|
page drawn as the physical arrangement — unless the owner opts out
|
|
(publishing a shelf layout is a choice)."""
|
|
from bggpipe.shelves import save_furniture, save_locations
|
|
|
|
cfg = _cfg(tmp_path)
|
|
save_furniture(
|
|
cfg,
|
|
[
|
|
{
|
|
"name": "Den Kallax",
|
|
"description": "the wall behind the couch",
|
|
"openings": [
|
|
{
|
|
"id": "den-a1",
|
|
"label": "A1",
|
|
"zone": "oversize",
|
|
"width_in": 26.5,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
{
|
|
"id": "den-b1",
|
|
"label": "B1",
|
|
"zone": "cubes",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
save_locations(cfg, {"240:24621": {"opening_id": "den-b1", "note": ""}})
|
|
out = tmp_path / "site"
|
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
|
|
|
shelves = (out / "shelves" / "index.html").read_text()
|
|
assert "Den Kallax" in shelves and "the wall behind the couch" in shelves
|
|
assert "Britannia" in shelves # the resident links from its opening
|
|
assert 'href="../britannia/"' in shelves
|
|
detail = (out / "britannia" / "index.html").read_text()
|
|
assert "where it lives" in detail and "Den Kallax · B1" in detail
|
|
# containment renders on both pages
|
|
local = (out / "britannia-2" / "index.html").read_text()
|
|
assert "in this box" not in local # the localgame contains nothing
|
|
index = (out / "index.html").read_text()
|
|
assert "see the shelves" in index
|
|
|
|
# opt-out: no shelves page, no location lines, no link
|
|
out2 = tmp_path / "site2"
|
|
run_export(cfg, out2, include_shelves=False, fetch=_cdn([]), sleep=lambda s: None)
|
|
assert not (out2 / "shelves").exists()
|
|
assert "where it lives" not in (out2 / "britannia" / "index.html").read_text()
|
|
assert "see the shelves" not in (out2 / "index.html").read_text()
|