from pathlib import Path import json import io import pytest from PIL import Image from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset def test_extracts_and_classifies_unique_https_media() -> None: html = '''
Щука
Карта Куори
''' items = extract_media_candidates(html, source_page="https://example.test/guide") assert [(item.entity_type, item.asset_url) for item in items] == [ ("fish", "https://example.test/media/fish/pike.png"), ("waterbody", "https://cdn.example/maps/kuori.webp"), ] assert items[0].external_id == "pike" def test_does_not_mistake_seafishing_screenshot_for_fish_entity() -> None: items = extract_media_candidates('', source_page="https://rf4game.de/userguide/") assert items[0].entity_type == "reference" def test_gallery_page_provenance_classifies_generic_asset_urls() -> None: fish = extract_media_candidates('Hecht', source_page="https://rf4game.de/media/fische/") water = extract_media_candidates('Kuori', source_page="https://rf4game.de/media/levels/") assert fish[0].entity_type == "fish" assert water[0].entity_type == "waterbody" chrome = extract_media_candidates('', source_page="https://rf4game.de/media/fische/") assert chrome[0].entity_type == "reference" def test_rf4map_gateway_fish_filename_is_classified_as_fish() -> None: items = extract_media_candidates( 'Ерш', source_page="https://rf4map.ru/fishes", ) assert items[0].entity_type == "fish" def test_rf4db_asset_filename_is_stable_external_id() -> None: item = extract_media_candidates( 'Ротан', source_page="https://download.rf4db.com/ru/fishes", )[0] assert (item.entity_type, item.external_id) == ("fish", "a.sleeper") def test_tackle_path_wins_over_fish_word_in_product_name() -> None: items = extract_media_candidates( 'Краб и рыба 14', source_page="https://example.test/fishes", ) assert items[0].entity_type == "tackle" def test_generic_userguide_map_is_reference_not_waterbody_entity() -> None: items = extract_media_candidates( 'Bild: Gewässerkarte', source_page="https://rf4game.de/userguide/", ) assert items[0].entity_type == "reference" def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None: item = extract_media_candidates('Bait worm', source_page="https://example.test")[0] first = merge_manifest(tmp_path / "manifest.json", [item]) second = merge_manifest(tmp_path / "manifest.json", [item]) assert len(first["assets"]) == len(second["assets"]) == 1 assert second["assets"][0]["source_pages"] == ["https://example.test"] image = io.BytesIO() Image.new("RGB", (3, 2), "green").save(image, format="PNG") digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url) assert len(digest) == 64 assert (tmp_path / relative).read_bytes() == image.getvalue() assert (width, height, mime) == (3, 2, "image/png") def test_image_inspection_rejects_invalid_body_and_mime_mismatch(tmp_path: Path) -> None: with pytest.raises(ValueError, match="valid raster"): inspect_image(b"not an image") image = io.BytesIO() Image.new("RGB", (1, 1)).save(image, format="PNG") with pytest.raises(ValueError, match="MIME mismatch"): store_asset(tmp_path, image.getvalue(), content_type="image/jpeg", source_url="https://example.test/a.jpg") def test_review_requires_stored_asset_and_canonical_mapping(tmp_path: Path) -> None: item = extract_media_candidates('Щука', source_page="https://example.test")[0] path = tmp_path / "manifest.json" manifest = merge_manifest(path, [item]) with pytest.raises(ValueError, match="stored"): review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike") manifest["assets"][0]["status"] = "stored" path.write_text(__import__("json").dumps(manifest), encoding="utf-8") with pytest.raises(ValueError, match="canonical key"): review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish") reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name") assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike") def test_catalog_audit_detects_tampering_and_orphans(tmp_path: Path) -> None: item = extract_media_candidates('', source_page="https://example.test")[0] path = tmp_path / "manifest.json" manifest = merge_manifest(path, [item]) image = io.BytesIO() Image.new("RGB", (2, 2)).save(image, format="PNG") digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url) manifest["assets"][0].update({"status": "stored", "sha256": digest, "local_path": relative, "width": width, "height": height, "content_type": mime}) path.write_text(__import__("json").dumps(manifest), encoding="utf-8") assert audit_media_catalog(tmp_path)["issues"] == [] (tmp_path / relative).write_bytes(b"changed") (tmp_path / "files" / "orphan.png").write_bytes(b"orphan") report = audit_media_catalog(tmp_path) assert any("SHA-256 mismatch" in issue for issue in report["issues"]) assert "files/orphan.png" in report["orphaned_files"] def test_media_coverage_keeps_unknown_tackle_total_honest(tmp_path: Path) -> None: (tmp_path / "manifest.json").write_text( '{"assets":[{"entity_type":"fish","status":"approved","entity_key":"ruffe"},' '{"entity_type":"fish","status":"queued"},{"entity_type":"tackle","status":"queued"}]}', encoding="utf-8", ) (tmp_path / "catalog-baseline.json").write_text( '{"verified_at":"2026-09-12","entities":{"fish":{"count":2},"tackle":{"count":null}}}', encoding="utf-8", ) report = media_coverage(tmp_path)["entities"] assert report["fish"] == { "expected": 2, "candidate_assets": 2, "unique_candidates": 0, "unidentified_assets": 2, "approved": 1, "candidate_gap": 2, "approved_gap": 1, } assert report["tackle"]["candidate_gap"] is None def test_media_coverage_deduplicates_candidate_labels(tmp_path: Path) -> None: (tmp_path / "manifest.json").write_text( '{"assets":[{"entity_type":"fish","status":"queued","label":" Ерш "},' '{"entity_type":"fish","status":"queued","label":"ерш"}]}', encoding="utf-8", ) (tmp_path / "catalog-baseline.json").write_text( '{"verified_at":"2026-09-12","entities":{"fish":{"count":2}}}', encoding="utf-8", ) fish = media_coverage(tmp_path)["entities"]["fish"] assert (fish["candidate_assets"], fish["unique_candidates"], fish["candidate_gap"]) == (2, 1, 1) def test_reconcile_queue_preserves_source_but_skips_normalized_duplicate(tmp_path: Path) -> None: path = tmp_path / "manifest.json" path.write_text(json.dumps({"assets": [ {"entity_type": "fish", "label": "Ерш", "status": "stored", "asset_url": "https://a/fish.png"}, {"entity_type": "fish", "label": " ЁРШ ", "status": "queued", "asset_url": "https://b/fish.webp"}, {"entity_type": "fish", "label": "Щука", "status": "queued", "asset_url": "https://b/pike.webp"}, ]}), encoding="utf-8") report = reconcile_queued_duplicates(path) assets = json.loads(path.read_text(encoding="utf-8"))["assets"] assert report == {"duplicates": 1, "queued": 1} assert assets[1]["status"] == "duplicate" assert assets[1]["duplicate_of"] == "https://a/fish.png" assert assets[2]["status"] == "queued" def test_bulk_approval_only_publishes_stored_assets(tmp_path: Path) -> None: path = tmp_path / "manifest.json" path.write_text(json.dumps({"assets": [ {"entity_type": "fish", "external_id": "pike", "status": "stored", "asset_url": "https://a/pike.webp", "sha256": "a" * 64}, {"entity_type": "fish", "status": "queued", "asset_url": "https://a/perch.webp"}, ]}), encoding="utf-8") assert approve_stored_assets(path, note="owner approved") == {"approved": 1} assets = json.loads(path.read_text(encoding="utf-8"))["assets"] assert assets[0]["entity_key"] == "fish:pike" assert assets[0]["review_note"] == "owner approved" assert assets[1]["status"] == "queued"