"""Extract-stage tests. The vision callable is always a local fake — the Anthropic API is never touched here.""" from __future__ import annotations import base64 import io import pytest import typer from bggpipe.config import Config from bggpipe.extract import ( dedupe_entries, parse_vision_json, prepare_image, run_extract, ) def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)): from PIL import Image img = Image.new("RGB", size, color) if fmt == "HEIC": from pillow_heif import register_heif_opener register_heif_opener() img.save(path, format="HEIF") else: img.save(path, format=fmt) return path def _vision_stub(payload): """A VisionFn returning a canned response, recording call count.""" calls = [] def vision(image_b64, media_type): calls.append(media_type) return payload return vision, calls # -- image preparation -------------------------------------------------- def test_prepare_image_downscales_long_edge(tmp_path): from PIL import Image photo = _write_image(tmp_path / "big.jpg", size=(4000, 3000)) b64, media_type = prepare_image(photo) assert media_type == "image/jpeg" out = Image.open(io.BytesIO(base64.standard_b64decode(b64))) assert max(out.size) == 1568 assert out.size == (1568, 1176) # aspect ratio preserved def test_prepare_image_keeps_small_images(tmp_path): from PIL import Image photo = _write_image(tmp_path / "small.png", size=(640, 480), fmt="PNG") b64, _ = prepare_image(photo) out = Image.open(io.BytesIO(base64.standard_b64decode(b64))) assert out.size == (640, 480) assert out.format == "JPEG" # PNG converted to JPEG def test_prepare_image_converts_heic(tmp_path): photo = _write_image(tmp_path / "iphone.heic", size=(2000, 1500), fmt="HEIC") b64, media_type = prepare_image(photo) assert media_type == "image/jpeg" assert len(b64) > 0 # -- defensive JSON parsing --------------------------------------------- def test_parse_strips_code_fences(): text = '```json\n[{"title_raw": "Catan", "confidence": "high"}]\n```' assert parse_vision_json(text)[0]["title_raw"] == "Catan" def test_parse_tolerates_prose_around_array(): text = 'Here are the games:\n[{"title_raw": "Wingspan"}]\nLet me know!' assert parse_vision_json(text)[0]["title_raw"] == "Wingspan" def test_parse_drops_malformed_entries(): text = '[{"title_raw": "Catan"}, {"no_title": true}, "just a string"]' entries = parse_vision_json(text) assert len(entries) == 1 def test_parse_raises_on_garbage(): with pytest.raises(ValueError): parse_vision_json("I couldn't see any games clearly.") # -- dedupe with edition cues ------------------------------------------- def _entry(title, photo, **cues): return { "title_raw": title, "confidence": cues.pop("confidence", "high"), "publisher_hint": cues.pop("publisher_hint", ""), "edition_hint": cues.pop("edition_hint", ""), "year_hint": cues.pop("year_hint", None), "language_hint": cues.pop("language_hint", ""), "art_notes": cues.pop("art_notes", ""), "source_photos": [photo], } def test_dedupe_merges_same_title_compatible_cues(): deduped = dedupe_entries( [ _entry("Wingspan", "a.jpg", publisher_hint="Stonemaier Games"), _entry("WINGSPAN", "b.jpg"), # no cues -> compatible ] ) assert len(deduped) == 1 assert deduped[0]["source_photos"] == ["a.jpg", "b.jpg"] assert deduped[0]["publisher_hint"] == "Stonemaier Games" # provenance kept def test_dedupe_keeps_conflicting_editions_separate(): deduped = dedupe_entries( [ _entry( "Cosmic Encounter", "a.jpg", edition_hint="42nd Anniversary Edition" ), _entry("Cosmic Encounter", "b.jpg", edition_hint="Eon 1977 edition"), ] ) assert len(deduped) == 2 # different editions stay separate entries def test_dedupe_conflicting_years_stay_separate(): deduped = dedupe_entries( [ _entry("Catan", "a.jpg", year_hint=1995), _entry("Catan", "b.jpg", year_hint=2015), ] ) assert len(deduped) == 2 def test_dedupe_upgrades_confidence(): deduped = dedupe_entries( [ _entry("Azul", "a.jpg", confidence="low"), _entry("Azul", "b.jpg", confidence="high"), ] ) assert deduped[0]["confidence"] == "high" # -- run_extract orchestration ------------------------------------------ def _cfg(tmp_path): return Config(photos_dir=tmp_path / "photos", data_dir=tmp_path / "data") def test_run_extract_is_idempotent_and_resumable(tmp_path): cfg = _cfg(tmp_path) cfg.photos_dir.mkdir() _write_image(cfg.photos_dir / "shelf1.jpg") _write_image(cfg.photos_dir / "shelf2.jpg") vision, calls = _vision_stub('[{"title_raw": "Catan", "confidence": "high"}]') result = run_extract(cfg, vision=vision) assert len(calls) == 2 # one vision call per photo assert len(result) == 1 # deduped across photos assert result[0]["source_photos"] == ["shelf1.jpg", "shelf2.jpg"] assert cfg.titles_path.exists() run_extract(cfg, vision=vision) assert len(calls) == 2 # second run: everything cached, no vision calls def test_run_extract_only_reprocesses_one_photo(tmp_path): cfg = _cfg(tmp_path) cfg.photos_dir.mkdir() _write_image(cfg.photos_dir / "shelf1.jpg") _write_image(cfg.photos_dir / "blurry.jpg") vision, calls = _vision_stub('[{"title_raw": "Azul"}]') run_extract(cfg, vision=vision) retake_vision, retake_calls = _vision_stub( '[{"title_raw": "Azul: Summer Pavilion"}]' ) result = run_extract(cfg, only="blurry.jpg", vision=retake_vision) assert retake_calls == ["image/jpeg"] # exactly one re-extraction titles = {e["title_raw"] for e in result} assert titles == {"Azul", "Azul: Summer Pavilion"} def test_run_extract_empty_photos_dir_exits(tmp_path): cfg = _cfg(tmp_path) cfg.photos_dir.mkdir() vision, _ = _vision_stub("[]") with pytest.raises(typer.Exit): run_extract(cfg, vision=vision)