mirror of
https://github.com/rommapp/romm.git
synced 2026-05-04 00:01:30 +08:00
967 lines
35 KiB
Python
967 lines
35 KiB
Python
"""
|
|
Tests for the LaunchBox metadata handler.
|
|
|
|
Covers:
|
|
- utils.py — pure utility functions
|
|
- platforms.py — platform slug resolution
|
|
- local_source.py — LocalSource (local XML parsing + Redis index cache)
|
|
- remote_source.py — RemoteSource (Redis metadata lookups)
|
|
- handler.py — LaunchboxHandler orchestration
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from anyio import Path as AnyioPath
|
|
from defusedxml import ElementTree as ET
|
|
|
|
from handler.metadata.launchbox_handler.handler import LaunchboxHandler
|
|
from handler.metadata.launchbox_handler.local_source import LocalSource
|
|
from handler.metadata.launchbox_handler.media import build_launchbox_metadata, build_rom
|
|
from handler.metadata.launchbox_handler.platforms import get_platform
|
|
from handler.metadata.launchbox_handler.remote_source import RemoteSource
|
|
from handler.metadata.launchbox_handler.types import (
|
|
LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY,
|
|
LAUNCHBOX_METADATA_DATABASE_ID_KEY,
|
|
LAUNCHBOX_METADATA_IMAGE_KEY,
|
|
LAUNCHBOX_METADATA_NAME_KEY,
|
|
LaunchboxImage,
|
|
)
|
|
from handler.metadata.launchbox_handler.utils import (
|
|
coalesce,
|
|
parse_playmode,
|
|
parse_release_date,
|
|
parse_videourl,
|
|
sanitize_filename,
|
|
)
|
|
from handler.redis_handler import async_cache
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample XML that mirrors a real LaunchBox platform file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SAMPLE_NES_XML = """\
|
|
<?xml version="1.0"?>
|
|
<LaunchBox>
|
|
<Game>
|
|
<Title>Super Mario Bros.</Title>
|
|
<ApplicationPath>Games\\NES\\super mario bros..nes</ApplicationPath>
|
|
<DatabaseID>1234</DatabaseID>
|
|
<Developer>Nintendo</Developer>
|
|
<Publisher>Nintendo</Publisher>
|
|
<Genre>Platformer</Genre>
|
|
<ReleaseDate>1985-09-13T00:00:00</ReleaseDate>
|
|
<MaxPlayers>2</MaxPlayers>
|
|
<Region>North America</Region>
|
|
<VideoUrl>https://www.youtube.com/watch?v=dQw4w9WgXcQ</VideoUrl>
|
|
<CommunityStarRating>4.5</CommunityStarRating>
|
|
<CommunityStarRatingTotalVotes>1000</CommunityStarRatingTotalVotes>
|
|
</Game>
|
|
<Game>
|
|
<Title>Mega Man 2</Title>
|
|
<ApplicationPath>Games\\NES\\Mega Man 2.nes</ApplicationPath>
|
|
<DatabaseID>5678</DatabaseID>
|
|
<Developer>Capcom</Developer>
|
|
<Publisher>Capcom</Publisher>
|
|
<Genre>Platformer;Action</Genre>
|
|
</Game>
|
|
</LaunchBox>
|
|
"""
|
|
|
|
REMOTE_ENTRY = {
|
|
"DatabaseID": "1234",
|
|
"Name": "Super Mario Bros.",
|
|
"Overview": "Jump and run platformer by Nintendo.",
|
|
"MaxPlayers": "2",
|
|
"ReleaseDate": "1985-09-13T00:00:00",
|
|
"Developer": "Nintendo",
|
|
"Publisher": "Nintendo",
|
|
"Genres": "Platformer",
|
|
"ESRB": "E - Everyone",
|
|
"CommunityRating": "4.5",
|
|
"CommunityRatingCount": "1000",
|
|
}
|
|
|
|
REMOTE_IMAGES = [
|
|
{
|
|
"FileName": "covers/super-mario-bros-front.png",
|
|
"Type": "Box - Front",
|
|
"Region": "North America",
|
|
},
|
|
{
|
|
"FileName": "screens/super-mario-bros-1.png",
|
|
"Type": "Screenshot - Gameplay",
|
|
"Region": "",
|
|
},
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def platforms_dir(tmp_path: Path) -> Path:
|
|
"""Return a temporary Platforms directory (mirrors LaunchBox layout)."""
|
|
d = tmp_path / "Data" / "Platforms"
|
|
d.mkdir(parents=True)
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def nes_xml(platforms_dir: Path) -> Path:
|
|
"""Write a sample NES XML to the temporary platforms dir."""
|
|
xml_file = platforms_dir / "Nintendo Entertainment System.xml"
|
|
xml_file.write_text(SAMPLE_NES_XML)
|
|
return xml_file
|
|
|
|
|
|
# ===========================================================================
|
|
# TestUtils
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSanitizeFilename:
|
|
def test_basic_string(self):
|
|
assert sanitize_filename("Super Mario Bros") == "Super Mario Bros"
|
|
|
|
def test_strips_whitespace(self):
|
|
assert sanitize_filename(" Game ") == "Game"
|
|
|
|
def test_replaces_curly_apostrophe(self):
|
|
assert sanitize_filename("Mario\u2019s Game") == "Mario_s Game"
|
|
|
|
def test_replaces_colon(self):
|
|
assert sanitize_filename("Game: The Sequel") == "Game_ The Sequel"
|
|
|
|
def test_replaces_backslash_and_pipe(self):
|
|
assert sanitize_filename("A|B\\C") == "A_B_C"
|
|
|
|
def test_collapses_multiple_spaces(self):
|
|
assert sanitize_filename("A B") == "A B"
|
|
|
|
def test_collapses_multiple_underscores(self):
|
|
assert sanitize_filename("A__B") == "A_B"
|
|
|
|
def test_strips_trailing_dot(self):
|
|
assert sanitize_filename("Game.") == "Game"
|
|
|
|
def test_empty_string(self):
|
|
assert sanitize_filename("") == ""
|
|
|
|
|
|
class TestCoalesce:
|
|
def test_returns_first_non_empty(self):
|
|
assert coalesce("hello", "world") == "hello"
|
|
|
|
def test_skips_none(self):
|
|
assert coalesce(None, "second") == "second"
|
|
|
|
def test_skips_blank_string(self):
|
|
assert coalesce(" ", "fallback") == "fallback"
|
|
|
|
def test_all_none_returns_none(self):
|
|
assert coalesce(None, None) is None
|
|
|
|
def test_strips_result(self):
|
|
assert coalesce(" hello ") == "hello"
|
|
|
|
|
|
class TestParseReleaseDate:
|
|
def test_iso_with_timezone_suffix(self):
|
|
ts = parse_release_date("1985-09-13T00:00:00Z")
|
|
assert isinstance(ts, int)
|
|
assert ts > 0
|
|
|
|
def test_iso_without_timezone(self):
|
|
ts = parse_release_date("1985-09-13T00:00:00")
|
|
assert isinstance(ts, int)
|
|
|
|
def test_plain_date(self):
|
|
ts = parse_release_date("1985-09-13")
|
|
assert isinstance(ts, int)
|
|
|
|
def test_none_input(self):
|
|
assert parse_release_date(None) is None
|
|
|
|
def test_empty_string(self):
|
|
assert parse_release_date("") is None
|
|
|
|
def test_invalid_format(self):
|
|
assert parse_release_date("not-a-date") is None
|
|
|
|
|
|
class TestParsePlaymode:
|
|
def test_cooperative(self):
|
|
assert parse_playmode("Cooperative") is True
|
|
|
|
def test_coop(self):
|
|
assert parse_playmode("Coop") is True
|
|
|
|
def test_co_op(self):
|
|
assert parse_playmode("Co-op") is True
|
|
|
|
def test_single_player(self):
|
|
assert parse_playmode("Single Player") is False
|
|
|
|
def test_none(self):
|
|
assert parse_playmode(None) is False
|
|
|
|
def test_empty(self):
|
|
assert parse_playmode("") is False
|
|
|
|
|
|
class TestParseVideourl:
|
|
def test_youtube_watch(self):
|
|
assert (
|
|
parse_videourl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
== "dQw4w9WgXcQ"
|
|
)
|
|
|
|
def test_youtube_watch_with_extra_params(self):
|
|
assert parse_videourl("https://www.youtube.com/watch?v=abc123&t=30") == "abc123"
|
|
|
|
def test_youtu_be(self):
|
|
assert parse_videourl("https://youtu.be/dQw4w9WgXcQ") == "dQw4w9WgXcQ"
|
|
|
|
def test_youtu_be_with_query(self):
|
|
assert parse_videourl("https://youtu.be/abc123?t=30") == "abc123"
|
|
|
|
def test_non_youtube(self):
|
|
assert parse_videourl("https://vimeo.com/123456") == ""
|
|
|
|
def test_none(self):
|
|
assert parse_videourl(None) == ""
|
|
|
|
def test_empty(self):
|
|
assert parse_videourl("") == ""
|
|
|
|
|
|
# ===========================================================================
|
|
# TestPlatforms
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetPlatform:
|
|
def test_known_slug(self):
|
|
p = get_platform("nes")
|
|
assert p.get("launchbox_id", None) == 27
|
|
assert p.get("name", None) == "Nintendo Entertainment System"
|
|
assert p.get("slug", None) == "nes"
|
|
|
|
def test_case_insensitive(self):
|
|
p = get_platform("NES")
|
|
assert p.get("launchbox_id", None) == 27
|
|
|
|
def test_unknown_slug_returns_none_id(self):
|
|
p = get_platform("unknown-platform-xyz")
|
|
assert p["launchbox_id"] is None
|
|
assert p.get("slug", None) == "unknown-platform-xyz"
|
|
|
|
def test_slug_with_dashes_normalized(self):
|
|
# n64 is registered as UPS.N64 = "n64"
|
|
p = get_platform("n64")
|
|
assert p.get("launchbox_id", None) == 25
|
|
|
|
def test_slug_strips_whitespace(self):
|
|
p = get_platform(" nes ")
|
|
assert p.get("launchbox_id", None) == 27
|
|
|
|
|
|
# ===========================================================================
|
|
# TestLocalSource
|
|
# ===========================================================================
|
|
|
|
|
|
class TestLocalSource:
|
|
@pytest.fixture
|
|
def source(self) -> LocalSource:
|
|
return LocalSource()
|
|
|
|
async def test_platforms_dir_missing_returns_none(self, source: LocalSource):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
Path("/nonexistent/path/that/does/not/exist"),
|
|
):
|
|
result = await source.get_rom("game.nes", "nes")
|
|
assert result is None
|
|
|
|
async def test_unknown_platform_returns_none(
|
|
self, source: LocalSource, platforms_dir: Path
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
# "unknown-xyz" has no XML file in platforms_dir
|
|
result = await source.get_rom("game.nes", "unknown-xyz")
|
|
assert result is None
|
|
|
|
async def test_xml_file_missing_returns_none(
|
|
self, source: LocalSource, platforms_dir: Path
|
|
):
|
|
# platforms_dir exists but "Nintendo Entertainment System.xml" is not created
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
result = await source.get_rom("game.nes", "nes")
|
|
assert result is None
|
|
|
|
async def test_match_by_application_path(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
result = await source.get_rom("super mario bros..nes", "nes")
|
|
|
|
assert result is not None
|
|
assert result.get("Title", None) == "Super Mario Bros."
|
|
assert result.get("DatabaseID", None) == "1234"
|
|
|
|
async def test_match_by_title_stem(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
"""Filename stem should match the Title key in the index."""
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
# "Mega Man 2.nes" → stem "Mega Man 2" → title key "mega man 2"
|
|
result = await source.get_rom("Mega Man 2.nes", "nes")
|
|
|
|
assert result is not None
|
|
assert result.get("Title", None) == "Mega Man 2"
|
|
|
|
async def test_cache_hit_uses_cached_index(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
cached_index = {
|
|
"super mario bros..nes": {"Title": "Cached Entry", "DatabaseID": "9999"}
|
|
}
|
|
source._cache["nes"] = cached_index
|
|
source._mtime["nes"] = nes_xml.stat().st_mtime_ns # trunk-ignore(ruff/ASYNC240)
|
|
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.ET.parse"
|
|
) as mock_parse:
|
|
result = await source.get_rom("super mario bros..nes", "nes")
|
|
mock_parse.assert_not_called()
|
|
|
|
assert result is not None
|
|
assert result.get("Title", None) == "Cached Entry"
|
|
|
|
async def test_xml_parsed_once_across_calls(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
"""XML should only be parsed once per platform per LocalSource lifetime."""
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.ET.parse",
|
|
wraps=ET.parse,
|
|
) as mock_parse:
|
|
await source.get_rom("super mario bros..nes", "nes")
|
|
await source.get_rom("Mega Man 2.nes", "nes")
|
|
mock_parse.assert_called_once()
|
|
|
|
async def test_parse_error_returns_none(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
await AnyioPath(str(nes_xml)).write_text("<<<not valid xml>>>")
|
|
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
result = await source.get_rom("super mario bros..nes", "nes")
|
|
|
|
assert result is None
|
|
|
|
async def test_empty_fs_name_returns_none(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
result = await source.get_rom(" ", "nes")
|
|
|
|
assert result is None
|
|
|
|
async def test_no_match_returns_none(
|
|
self, source: LocalSource, nes_xml: Path, platforms_dir: Path
|
|
):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.local_source.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms_dir,
|
|
):
|
|
result = await source.get_rom("game_that_does_not_exist.nes", "nes")
|
|
|
|
assert result is None
|
|
|
|
|
|
# ===========================================================================
|
|
# TestRemoteSource
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRemoteSourceGetById:
|
|
@pytest.fixture
|
|
def source(self) -> RemoteSource:
|
|
return RemoteSource()
|
|
|
|
async def test_cache_miss_returns_none(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache, "hget", new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await source.get_by_id(1234)
|
|
assert result is None
|
|
|
|
async def test_cache_hit_returns_dict(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache,
|
|
"hget",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(REMOTE_ENTRY),
|
|
):
|
|
result = await source.get_by_id(1234)
|
|
assert result is not None
|
|
assert result.get("Name", None) == "Super Mario Bros."
|
|
|
|
async def test_accepts_string_id(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache,
|
|
"hget",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(REMOTE_ENTRY),
|
|
) as mock_hget:
|
|
await source.get_by_id("1234")
|
|
mock_hget.assert_called_once_with(LAUNCHBOX_METADATA_DATABASE_ID_KEY, "1234")
|
|
|
|
|
|
class TestRemoteSourceGetRom:
|
|
@pytest.fixture
|
|
def source(self) -> RemoteSource:
|
|
return RemoteSource()
|
|
|
|
async def test_no_cache_returns_none(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache, "exists", new_callable=AsyncMock, return_value=False
|
|
):
|
|
result = await source.get_rom("super mario bros.", "nes")
|
|
assert result is None
|
|
|
|
async def test_unknown_platform_returns_none(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache, "hget", new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await source.get_rom(
|
|
"game", "unknown-xyz", assume_cache_present=True
|
|
)
|
|
assert result is None
|
|
|
|
async def test_name_match_returns_entry(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache,
|
|
"hget",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(REMOTE_ENTRY),
|
|
):
|
|
result = await source.get_rom(
|
|
"super mario bros.", "nes", assume_cache_present=True
|
|
)
|
|
assert result is not None
|
|
assert result.get("DatabaseID", None) == "1234"
|
|
|
|
async def test_alternate_name_match(self, source: RemoteSource):
|
|
alt_entry = {"DatabaseID": "1234"}
|
|
|
|
async def side_effect(key, _field):
|
|
if key == LAUNCHBOX_METADATA_NAME_KEY:
|
|
return None
|
|
if key == LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY:
|
|
return json.dumps(alt_entry)
|
|
if key == LAUNCHBOX_METADATA_DATABASE_ID_KEY:
|
|
return json.dumps(REMOTE_ENTRY)
|
|
return None
|
|
|
|
with patch.object(
|
|
async_cache, "hget", new_callable=AsyncMock, side_effect=side_effect
|
|
):
|
|
result = await source.get_rom(
|
|
"super mario bros.", "nes", assume_cache_present=True
|
|
)
|
|
assert result is not None
|
|
assert result.get("Name", None) == "Super Mario Bros."
|
|
|
|
async def test_no_match_returns_none(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache, "hget", new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await source.get_rom(
|
|
"nonexistent game", "nes", assume_cache_present=True
|
|
)
|
|
assert result is None
|
|
|
|
async def test_empty_filename_returns_none(self, source: RemoteSource):
|
|
result = await source.get_rom("", "nes", assume_cache_present=True)
|
|
assert result is None
|
|
|
|
|
|
class TestRemoteSourceFetchImages:
|
|
@pytest.fixture
|
|
def source(self) -> RemoteSource:
|
|
return RemoteSource()
|
|
|
|
async def test_remote_disabled_returns_none(self, source: RemoteSource):
|
|
result = await source.fetch_images(remote_enabled=False)
|
|
assert result is None
|
|
|
|
async def test_no_id_returns_none(self, source: RemoteSource):
|
|
result = await source.fetch_images(remote=None, database_id=None)
|
|
assert result is None
|
|
|
|
async def test_id_from_database_id_arg(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache,
|
|
"hget",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(REMOTE_IMAGES),
|
|
) as mock_hget:
|
|
result = await source.fetch_images(database_id=1234)
|
|
mock_hget.assert_called_once_with(LAUNCHBOX_METADATA_IMAGE_KEY, "1234")
|
|
assert result == REMOTE_IMAGES
|
|
|
|
async def test_id_from_remote_dict(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache,
|
|
"hget",
|
|
new_callable=AsyncMock,
|
|
return_value=json.dumps(REMOTE_IMAGES),
|
|
) as mock_hget:
|
|
result = await source.fetch_images(remote={"DatabaseID": "1234"})
|
|
mock_hget.assert_called_once_with(LAUNCHBOX_METADATA_IMAGE_KEY, "1234")
|
|
assert result == REMOTE_IMAGES
|
|
|
|
async def test_cache_miss_returns_none(self, source: RemoteSource):
|
|
with patch.object(
|
|
async_cache, "hget", new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await source.fetch_images(database_id=9999)
|
|
assert result is None
|
|
|
|
|
|
# ===========================================================================
|
|
# TestBuildLaunchboxMetadata
|
|
# ===========================================================================
|
|
|
|
|
|
class TestBuildLaunchboxMetadata:
|
|
def test_local_only(self):
|
|
local = {
|
|
"ReleaseDate": "1985-09-13",
|
|
"MaxPlayers": "2",
|
|
"Genre": "Platformer",
|
|
"Publisher": "Nintendo",
|
|
"Developer": "Nintendo",
|
|
"PlayMode": "Single Player",
|
|
"CommunityStarRating": "4.0",
|
|
"CommunityStarRatingTotalVotes": "500",
|
|
}
|
|
meta = build_launchbox_metadata(local=local, remote=None, images=[])
|
|
assert meta.get("max_players", None) == 2
|
|
assert meta.get("genres", []) == ["Platformer"]
|
|
assert "Nintendo" in meta.get("companies", [])
|
|
assert meta.get("cooperative", None) is False
|
|
|
|
def test_remote_only(self):
|
|
meta = build_launchbox_metadata(local=None, remote=REMOTE_ENTRY, images=[])
|
|
assert meta.get("max_players", None) == 2
|
|
assert meta.get("genres", []) == ["Platformer"]
|
|
assert meta.get("community_rating", None) == 4.5
|
|
assert meta.get("community_rating_count", None) == 1000
|
|
|
|
def test_local_takes_precedence_over_remote(self):
|
|
local = {"MaxPlayers": "4", "Genre": "RPG"}
|
|
remote = {"MaxPlayers": "1", "Genres": "Platformer"}
|
|
meta = build_launchbox_metadata(local=local, remote=remote, images=[])
|
|
assert meta.get("max_players", None) == 4
|
|
assert meta.get("genres", []) == ["RPG"]
|
|
|
|
def test_release_date_parsed(self):
|
|
local = {"ReleaseDate": "1985-09-13T00:00:00"}
|
|
meta = build_launchbox_metadata(local=local, remote=None, images=[])
|
|
assert isinstance(meta["first_release_date"], int)
|
|
assert meta["first_release_date"] > 0
|
|
|
|
def test_esrb_stripped(self):
|
|
remote = {**REMOTE_ENTRY, "ESRB": "E - Everyone"}
|
|
meta = build_launchbox_metadata(local=None, remote=remote, images=[])
|
|
assert meta.get("esrb", None) == "E"
|
|
|
|
def test_cooperative_from_playmode(self):
|
|
local = {"PlayMode": "Cooperative"}
|
|
meta = build_launchbox_metadata(local=local, remote=None, images=[])
|
|
assert meta.get("cooperative", None) is True
|
|
|
|
def test_youtube_video_id_extracted(self):
|
|
local = {"VideoUrl": "https://www.youtube.com/watch?v=abc123"}
|
|
meta = build_launchbox_metadata(local=local, remote=None, images=[])
|
|
assert meta.get("youtube_video_id", None) == "abc123"
|
|
|
|
def test_companies_deduplicated(self):
|
|
local = {"Developer": "Nintendo", "Publisher": "Nintendo"}
|
|
meta = build_launchbox_metadata(local=local, remote=None, images=[])
|
|
assert meta.get("companies", []) == ["Nintendo"]
|
|
|
|
def test_images_passed_through(self):
|
|
images = [
|
|
LaunchboxImage(
|
|
url="https://images.launchbox-app.com/cover.png", type="Box - Front"
|
|
)
|
|
]
|
|
meta = build_launchbox_metadata(local=None, remote=None, images=images)
|
|
assert meta.get("images", []) == images
|
|
|
|
|
|
class TestBuildRom:
|
|
def test_name_from_local_title(self):
|
|
local = {"Title": "Super Mario Bros.", "Notes": "Classic platformer"}
|
|
rom = build_rom(local=local, remote=None, launchbox_id=1234)
|
|
assert rom.get("name", None) == "Super Mario Bros."
|
|
assert rom.get("summary", None) == "Classic platformer"
|
|
assert rom.get("launchbox_id", None) == 1234
|
|
|
|
def test_name_falls_back_to_remote(self):
|
|
rom = build_rom(local=None, remote=REMOTE_ENTRY, launchbox_id=1234)
|
|
assert rom.get("name", None) == "Super Mario Bros."
|
|
assert rom.get("summary", None) == "Jump and run platformer by Nintendo."
|
|
|
|
def test_local_name_overrides_remote(self):
|
|
local = {"Title": "Local Title", "Notes": "Local Notes"}
|
|
rom = build_rom(local=local, remote=REMOTE_ENTRY, launchbox_id=1234)
|
|
assert rom.get("name", None) == "Local Title"
|
|
assert rom.get("summary", None) == "Local Notes"
|
|
|
|
def test_no_media_req_yields_empty_media(self):
|
|
rom = build_rom(
|
|
local=None, remote=REMOTE_ENTRY, launchbox_id=1234, media_req=None
|
|
)
|
|
assert rom.get("url_cover", None) == ""
|
|
assert rom.get("url_screenshots", None) == []
|
|
assert rom.get("url_manual", None) == ""
|
|
|
|
def test_launchbox_id_set(self):
|
|
rom = build_rom(local=None, remote=REMOTE_ENTRY, launchbox_id=42)
|
|
assert rom.get("launchbox_id", None) == 42
|
|
|
|
|
|
# ===========================================================================
|
|
# TestLaunchboxHandler
|
|
# ===========================================================================
|
|
|
|
|
|
class TestLaunchboxHandlerEnabled:
|
|
def test_is_cloud_enabled_true(self):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.LAUNCHBOX_API_ENABLED", True
|
|
):
|
|
assert LaunchboxHandler.is_cloud_enabled() is True
|
|
|
|
def test_is_cloud_enabled_false(self):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.LAUNCHBOX_API_ENABLED", False
|
|
):
|
|
assert LaunchboxHandler.is_cloud_enabled() is False
|
|
|
|
def test_is_local_enabled_true(self, tmp_path: Path):
|
|
platforms = tmp_path / "Data" / "Platforms"
|
|
platforms.mkdir(parents=True)
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.LAUNCHBOX_PLATFORMS_DIR",
|
|
platforms,
|
|
):
|
|
assert LaunchboxHandler.is_local_enabled() is True
|
|
|
|
def test_is_local_enabled_false(self):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.LAUNCHBOX_PLATFORMS_DIR",
|
|
Path("/does/not/exist"),
|
|
):
|
|
assert LaunchboxHandler.is_local_enabled() is False
|
|
|
|
def test_is_enabled_true_when_cloud(self):
|
|
with patch.object(LaunchboxHandler, "is_cloud_enabled", return_value=True):
|
|
with patch.object(LaunchboxHandler, "is_local_enabled", return_value=False):
|
|
assert LaunchboxHandler.is_enabled() is True
|
|
|
|
def test_is_enabled_true_when_local(self):
|
|
with patch.object(LaunchboxHandler, "is_cloud_enabled", return_value=False):
|
|
with patch.object(LaunchboxHandler, "is_local_enabled", return_value=True):
|
|
assert LaunchboxHandler.is_enabled() is True
|
|
|
|
def test_is_enabled_false_when_both_off(self):
|
|
with patch.object(LaunchboxHandler, "is_cloud_enabled", return_value=False):
|
|
with patch.object(LaunchboxHandler, "is_local_enabled", return_value=False):
|
|
assert LaunchboxHandler.is_enabled() is False
|
|
|
|
|
|
class TestLaunchboxHandlerGetPlatform:
|
|
def test_delegates_to_get_platform(self):
|
|
handler = LaunchboxHandler()
|
|
p = handler.get_platform("nes")
|
|
assert p.get("launchbox_id", None) == 27
|
|
|
|
def test_unknown_platform(self):
|
|
handler = LaunchboxHandler()
|
|
p = handler.get_platform("totally-unknown")
|
|
assert p["launchbox_id"] is None
|
|
|
|
|
|
class TestLaunchboxHandlerGetRom:
|
|
@pytest.fixture
|
|
def handler(self, monkeypatch) -> LaunchboxHandler:
|
|
h = LaunchboxHandler()
|
|
h._local = MagicMock(spec=LocalSource)
|
|
h._remote = MagicMock(spec=RemoteSource)
|
|
h._local.get_rom = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.get_rom = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.get_by_id = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.fetch_images = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: True)
|
|
monkeypatch.setattr(async_cache, "exists", AsyncMock(return_value=True))
|
|
return h
|
|
|
|
async def test_disabled_returns_fallback(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: False)
|
|
result = await handler.get_rom("game.nes", "nes")
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_local_found_remote_unavailable(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
local_data = {"Title": "Mario", "DatabaseID": "1234"}
|
|
monkeypatch.setattr(async_cache, "exists", AsyncMock(return_value=False))
|
|
with patch.object(
|
|
handler._local, "get_rom", new=AsyncMock(return_value=local_data)
|
|
):
|
|
result = await handler.get_rom("game.nes", "nes")
|
|
|
|
assert result.get("launchbox_id", None) == 1234
|
|
assert result.get("name", None) == "Mario"
|
|
|
|
async def test_local_found_supplements_remote_by_id(
|
|
self, handler: LaunchboxHandler
|
|
):
|
|
local_data = {"Title": "Mario", "DatabaseID": "1234"}
|
|
mock_get_by_id = AsyncMock(return_value=REMOTE_ENTRY)
|
|
with (
|
|
patch.object(
|
|
handler._local, "get_rom", new=AsyncMock(return_value=local_data)
|
|
),
|
|
patch.object(handler._remote, "get_by_id", new=mock_get_by_id),
|
|
):
|
|
result = await handler.get_rom("game.nes", "nes")
|
|
|
|
mock_get_by_id.assert_called_once_with(1234)
|
|
assert result.get("launchbox_id", None) == 1234
|
|
|
|
async def test_local_found_supplements_remote_by_title_fallback(
|
|
self, handler: LaunchboxHandler
|
|
):
|
|
local_data = {"Title": "Mario"} # no DatabaseID
|
|
mock_get_rom = AsyncMock(return_value=REMOTE_ENTRY)
|
|
with (
|
|
patch.object(
|
|
handler._local, "get_rom", new=AsyncMock(return_value=local_data)
|
|
),
|
|
patch.object(handler._remote, "get_rom", new=mock_get_rom),
|
|
):
|
|
result = await handler.get_rom("game.nes", "nes")
|
|
|
|
mock_get_rom.assert_called_once_with("Mario", "nes", assume_cache_present=True)
|
|
assert result.get("name", None) == "Mario"
|
|
|
|
async def test_no_local_no_remote_returns_fallback(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
monkeypatch.setattr(async_cache, "exists", AsyncMock(return_value=False))
|
|
result = await handler.get_rom("game.nes", "nes")
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_tag_in_filename_matches_by_id(self, handler: LaunchboxHandler):
|
|
with patch.object(
|
|
handler._remote, "get_by_id", new=AsyncMock(return_value=REMOTE_ENTRY)
|
|
):
|
|
result = await handler.get_rom(
|
|
"Super Mario Bros (launchbox-1234).nes", "nes"
|
|
)
|
|
|
|
assert result.get("launchbox_id", None) == 1234
|
|
|
|
async def test_tag_in_filename_not_found_falls_through_to_name_search(
|
|
self, handler: LaunchboxHandler
|
|
):
|
|
with (
|
|
patch.object(
|
|
handler._remote,
|
|
"get_rom",
|
|
new=AsyncMock(return_value=REMOTE_ENTRY),
|
|
),
|
|
patch(
|
|
"handler.metadata.launchbox_handler.handler.fs_rom_handler"
|
|
) as mock_fs,
|
|
):
|
|
# fs_rom_handler.get_file_name_with_no_tags strips the tag
|
|
mock_fs.get_file_name_with_no_tags.return_value = "Super Mario Bros"
|
|
result = await handler.get_rom(
|
|
"Super Mario Bros (launchbox-9999).nes", "nes"
|
|
)
|
|
|
|
# Falls through to name search, which succeeds
|
|
assert result.get("launchbox_id", None) == 1234
|
|
|
|
async def test_name_search_succeeds(self, handler: LaunchboxHandler):
|
|
with (
|
|
patch.object(
|
|
handler._remote,
|
|
"get_rom",
|
|
new=AsyncMock(return_value=REMOTE_ENTRY),
|
|
),
|
|
patch(
|
|
"handler.metadata.launchbox_handler.handler.fs_rom_handler"
|
|
) as mock_fs,
|
|
):
|
|
mock_fs.get_file_name_with_no_tags.return_value = "Super Mario Bros."
|
|
result = await handler.get_rom("Super Mario Bros.nes", "nes")
|
|
|
|
assert result.get("launchbox_id", None) == 1234
|
|
assert result.get("name", None) == "Super Mario Bros."
|
|
|
|
async def test_name_search_fails_returns_fallback(self, handler: LaunchboxHandler):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.fs_rom_handler"
|
|
) as mock_fs:
|
|
mock_fs.get_file_name_with_no_tags.return_value = "Unknown Game"
|
|
result = await handler.get_rom("Unknown Game.nes", "nes")
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_keep_tags_true_skips_tag_stripping(self, handler: LaunchboxHandler):
|
|
with patch(
|
|
"handler.metadata.launchbox_handler.handler.fs_rom_handler"
|
|
) as mock_fs:
|
|
await handler.get_rom("Game (USA).nes", "nes", keep_tags=True)
|
|
# fs_rom_handler.get_file_name_with_no_tags should NOT be called
|
|
mock_fs.get_file_name_with_no_tags.assert_not_called()
|
|
|
|
|
|
class TestLaunchboxHandlerGetRomById:
|
|
@pytest.fixture
|
|
def handler(self, monkeypatch) -> LaunchboxHandler:
|
|
h = LaunchboxHandler()
|
|
h._remote = MagicMock(spec=RemoteSource)
|
|
h._remote.get_by_id = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.fetch_images = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: True)
|
|
return h
|
|
|
|
async def test_disabled_returns_fallback(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: False)
|
|
result = await handler.get_rom_by_id(1234)
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_remote_disabled_returns_fallback(self, handler: LaunchboxHandler):
|
|
result = await handler.get_rom_by_id(1234, remote_enabled=False)
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_not_in_cache_returns_fallback(self, handler: LaunchboxHandler):
|
|
result = await handler.get_rom_by_id(9999)
|
|
assert result["launchbox_id"] is None
|
|
|
|
async def test_found_returns_launchbox_rom(self, handler: LaunchboxHandler):
|
|
with (
|
|
patch.object(
|
|
handler._remote,
|
|
"get_by_id",
|
|
new=AsyncMock(return_value=REMOTE_ENTRY),
|
|
),
|
|
patch.object(
|
|
handler._remote,
|
|
"fetch_images",
|
|
new=AsyncMock(return_value=REMOTE_IMAGES),
|
|
),
|
|
):
|
|
result = await handler.get_rom_by_id(1234)
|
|
|
|
assert result.get("launchbox_id", None) == 1234
|
|
assert result.get("name", None) == "Super Mario Bros."
|
|
assert result.get("launchbox_metadata") is not None
|
|
|
|
|
|
class TestLaunchboxHandlerSearch:
|
|
@pytest.fixture
|
|
def handler(self, monkeypatch) -> LaunchboxHandler:
|
|
h = LaunchboxHandler()
|
|
h._local = MagicMock(spec=LocalSource)
|
|
h._remote = MagicMock(spec=RemoteSource)
|
|
h._local.get_rom = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.get_rom = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.get_by_id = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
h._remote.fetch_images = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: True)
|
|
monkeypatch.setattr(async_cache, "exists", AsyncMock(return_value=True))
|
|
return h
|
|
|
|
async def test_get_matched_roms_by_name_disabled_returns_empty(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: False)
|
|
result = await handler.get_matched_roms_by_name("Mario", "nes")
|
|
assert result == []
|
|
|
|
async def test_get_matched_roms_by_name_found(self, handler: LaunchboxHandler):
|
|
with patch.object(
|
|
handler._remote, "get_rom", new=AsyncMock(return_value=REMOTE_ENTRY)
|
|
):
|
|
result = await handler.get_matched_roms_by_name("Super Mario Bros.", "nes")
|
|
|
|
assert len(result) == 1
|
|
assert result[0].get("launchbox_id", 0) == 1234
|
|
|
|
async def test_get_matched_rom_by_id_disabled_returns_none(
|
|
self, handler: LaunchboxHandler, monkeypatch
|
|
):
|
|
monkeypatch.setattr(LaunchboxHandler, "is_enabled", lambda *_: False)
|
|
result = await handler.get_matched_rom_by_id(1234)
|
|
assert result is None
|
|
|
|
async def test_get_matched_rom_by_id_found(self, handler: LaunchboxHandler):
|
|
with patch.object(
|
|
handler._remote, "get_by_id", new=AsyncMock(return_value=REMOTE_ENTRY)
|
|
):
|
|
result = await handler.get_matched_rom_by_id(1234)
|
|
assert result is not None
|
|
assert result.get("launchbox_id", None) == 1234
|
|
|
|
async def test_get_matched_rom_by_id_not_found_returns_none(
|
|
self, handler: LaunchboxHandler
|
|
):
|
|
result = await handler.get_matched_rom_by_id(9999)
|
|
assert result is None
|