rommapp_romm/backend/tests/handler/metadata/test_libretro_handler.py
Georges-Antoine Assi 1b5943bd93
feat: fetch libretro Named_Snaps/Titles/Logos gated by SCAN_MEDIA
get_rom now also fetches Named_Snaps, Named_Titles, and Named_Logos
when the matching MetadataMediaType (SCREENSHOT, TITLE_SCREEN, LOGO)
is in SCAN_MEDIA. Box art is still fetched unconditionally — it drives
url_cover and libretro_id. Matching extras are appended to
url_screenshots so the scan_handler artwork priority loop picks them
up without further changes. All enabled listings are fetched
concurrently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:01:37 -04:00

294 lines
10 KiB
Python

"""Tests for the libretro thumbnails metadata handler."""
import hashlib
from unittest.mock import AsyncMock, patch
import pytest
from adapters.services.libretro_thumbnails import LibretroThumbnailsService
from adapters.services.libretro_thumbnails_types import LibretroArtType
from config.config_manager import MetadataMediaType
from handler.metadata.libretro_handler import (
LIBRETRO_PLATFORM_LIST,
LibretroHandler,
_strip_paren_tags,
libretro_id_for,
)
# Sample directory listing for Sony - PlayStation
PSX_LISTING = [
"Castlevania - Symphony of the Night (USA).png",
"Castlevania - Symphony of the Night (Europe).png",
"Castlevania - Symphony of the Night (Japan).png",
"Final Fantasy VII (USA).png",
"Final Fantasy VII (Europe).png",
"Metal Gear Solid (USA).png",
"Sonic _ Knuckles Collection (USA).png",
]
@pytest.fixture
def handler() -> LibretroHandler:
return LibretroHandler()
# ---------------------------------------------------------------------------
# Pure utilities
# ---------------------------------------------------------------------------
def test_strip_paren_tags_removes_single_tag():
assert _strip_paren_tags("Foo (USA)") == "Foo"
def test_strip_paren_tags_removes_multiple_tags():
assert _strip_paren_tags("Foo (USA) (Rev 1)") == "Foo"
def test_strip_paren_tags_preserves_when_no_tags():
assert _strip_paren_tags("Foo") == "Foo"
# ---------------------------------------------------------------------------
# Platform resolution
# ---------------------------------------------------------------------------
def test_get_platform_supported_platform(handler: LibretroHandler):
# PSX is explicitly mapped to "Sony - PlayStation"
assert handler.get_platform("psx")["libretro_slug"] == "Sony - PlayStation"
def test_get_platform_unsupported_platform(handler: LibretroHandler):
assert handler.get_platform("not-a-real-platform")["libretro_slug"] is None
def test_platform_list_uses_ups_keys():
"""Every entry in LIBRETRO_PLATFORM_LIST should be a UniversalPlatformSlug."""
from handler.metadata.base_handler import UniversalPlatformSlug
for key in LIBRETRO_PLATFORM_LIST.keys():
assert isinstance(key, UniversalPlatformSlug)
# ---------------------------------------------------------------------------
# Matching logic
# ---------------------------------------------------------------------------
def test_find_matching_art_exact_case_insensitive(handler: LibretroHandler):
# The match should prefer the exact case-insensitive filename — region tag
# included — so a PAL ROM lands on the (Europe) artwork.
result = handler._find_matching_art(
"Castlevania - Symphony of the Night (Europe).iso", PSX_LISTING
)
assert result == "Castlevania - Symphony of the Night (Europe).png"
def test_find_matching_art_different_case(handler: LibretroHandler):
result = handler._find_matching_art(
"CASTLEVANIA - SYMPHONY OF THE NIGHT (USA).bin", PSX_LISTING
)
assert result == "Castlevania - Symphony of the Night (USA).png"
def test_find_matching_art_ampersand_normalized(handler: LibretroHandler):
# Libretro filenames replace `&` with `_`; ROM filename uses `&`.
result = handler._find_matching_art(
"Sonic & Knuckles Collection (USA).iso", PSX_LISTING
)
assert result == "Sonic _ Knuckles Collection (USA).png"
def test_find_matching_art_fuzzy_fallback(handler: LibretroHandler):
# No exact match — ROM has an extra `(Rev 1)` tag that libretro doesn't
# index. Fuzzy fallback strips tags from both sides; the Europe variant
# is the first tag-stripped candidate and wins.
result = handler._find_matching_art(
"Castlevania - Symphony of the Night (Europe) (Rev 1).iso", PSX_LISTING
)
assert result is not None
assert result.startswith("Castlevania - Symphony of the Night")
def test_find_matching_art_no_match(handler: LibretroHandler):
result = handler._find_matching_art(
"Completely Made Up Game Title XYZ.iso", PSX_LISTING
)
assert result is None
# ---------------------------------------------------------------------------
# get_rom (scan path)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_rom_unsupported_platform_returns_empty(handler: LibretroHandler):
result = await handler.get_rom("whatever.iso", "not-a-real-platform")
assert result == {"libretro_id": None}
@pytest.mark.asyncio
async def test_get_rom_matched_returns_cover_url(handler: LibretroHandler):
# Isolate from SCAN_MEDIA defaults so this test only exercises box art.
with (
patch.object(
handler.service,
"fetch_listing",
AsyncMock(return_value=PSX_LISTING),
) as mock_fetch,
patch(
"handler.metadata.libretro_handler.get_preferred_media_types",
return_value=[],
),
):
result = await handler.get_rom(
"Castlevania - Symphony of the Night (Europe).iso", "psx"
)
mock_fetch.assert_awaited_once()
# libretro_id is the SHA1 hex of the matched libretro filename
expected_id = libretro_id_for("Castlevania - Symphony of the Night (Europe).png")
assert result["libretro_id"]
assert result["libretro_id"] == expected_id
assert len(result["libretro_id"]) == 40 # SHA1 hex
assert result.get("url_cover", "").startswith("https://thumbnails.libretro.com/")
assert "Sony%20-%20PlayStation" in result.get("url_cover", "")
assert "Named_Boxarts" in result.get("url_cover", "")
assert "Europe" in result.get("url_cover", "")
# Scan path intentionally does not populate `name` so it doesn't
# overwrite a real IGDB name.
assert "name" not in result
# No extra art types requested, so url_screenshots is absent.
assert "url_screenshots" not in result
@pytest.mark.asyncio
async def test_get_rom_fetches_extra_art_when_preferred(handler: LibretroHandler):
"""SCREENSHOT/TITLE_SCREEN/LOGO in SCAN_MEDIA trigger the corresponding
Named_Snaps/Named_Titles/Named_Logos fetches; matches land in
url_screenshots so the scan_handler artwork loop can pick them up."""
with (
patch.object(
handler.service,
"fetch_listing",
AsyncMock(return_value=PSX_LISTING),
) as mock_fetch,
patch(
"handler.metadata.libretro_handler.get_preferred_media_types",
return_value=[
MetadataMediaType.SCREENSHOT,
MetadataMediaType.TITLE_SCREEN,
MetadataMediaType.LOGO,
],
),
):
result = await handler.get_rom(
"Castlevania - Symphony of the Night (Europe).iso", "psx"
)
# Box art + three extras = four listings fetched in parallel.
assert mock_fetch.await_count == 4
screenshots = result.get("url_screenshots", [])
assert len(screenshots) == 3
assert any("Named_Snaps" in s for s in screenshots)
assert any("Named_Titles" in s for s in screenshots)
assert any("Named_Logos" in s for s in screenshots)
# url_cover still comes from Named_Boxarts.
assert "Named_Boxarts" in result.get("url_cover", "")
@pytest.mark.asyncio
async def test_get_rom_skips_extra_art_when_not_preferred(handler: LibretroHandler):
"""Only Named_Boxarts is fetched when no extra media types are in SCAN_MEDIA."""
with (
patch.object(
handler.service,
"fetch_listing",
AsyncMock(return_value=PSX_LISTING),
) as mock_fetch,
patch(
"handler.metadata.libretro_handler.get_preferred_media_types",
return_value=[MetadataMediaType.BOX2D, MetadataMediaType.MANUAL],
),
):
result = await handler.get_rom(
"Castlevania - Symphony of the Night (Europe).iso", "psx"
)
mock_fetch.assert_awaited_once()
assert "url_screenshots" not in result
def test_libretro_id_for_is_deterministic():
f = "Castlevania - Symphony of the Night (Europe).png"
assert libretro_id_for(f) == libretro_id_for(f)
# Sanity-check the algorithm so the ID is stable across releases.
assert libretro_id_for(f) == hashlib.sha1(f.encode("utf-8")).hexdigest()
def test_libretro_id_for_distinguishes_regions():
assert libretro_id_for(
"Castlevania - Symphony of the Night (USA).png"
) != libretro_id_for("Castlevania - Symphony of the Night (Europe).png")
@pytest.mark.asyncio
async def test_get_rom_no_match_returns_empty(handler: LibretroHandler):
with patch.object(
handler.service,
"fetch_listing",
AsyncMock(return_value=PSX_LISTING),
):
result = await handler.get_rom("Totally Unknown Title.iso", "psx")
assert result == {"libretro_id": None}
@pytest.mark.asyncio
async def test_get_rom_empty_listing_returns_empty(handler: LibretroHandler):
with patch.object(
handler.service,
"fetch_listing",
AsyncMock(return_value=[]),
):
result = await handler.get_rom("Whatever.iso", "psx")
assert result == {"libretro_id": None}
# ---------------------------------------------------------------------------
# LibretroThumbnailsService helpers
# ---------------------------------------------------------------------------
def test_build_art_url_encodes_spaces_and_special_chars():
url = LibretroThumbnailsService.build_art_url(
"Sony - PlayStation",
LibretroArtType.BOX_ART,
"Castlevania - Symphony of the Night (Europe).png",
)
assert url.startswith("https://thumbnails.libretro.com/")
assert "Sony%20-%20PlayStation" in url
assert "Named_Boxarts" in url
# Filename-level encoding is strict (space → %20, paren encoded).
assert "Castlevania%20-%20Symphony%20of%20the%20Night" in url
def test_art_type_values():
assert LibretroArtType.BOX_ART.value == "Named_Boxarts"
assert LibretroArtType.TITLE_SCREEN.value == "Named_Titles"
assert LibretroArtType.LOGO.value == "Named_Logos"
assert LibretroArtType.SCREENSHOT.value == "Named_Snaps"
# ---------------------------------------------------------------------------
# Handler basics
# ---------------------------------------------------------------------------
def test_is_enabled_always_true():
# No API key required — public server.
assert LibretroHandler.is_enabled() is True