mirror of
https://github.com/rommapp/romm.git
synced 2026-07-01 08:16:21 +00:00
A ROM's name defaults to its raw filename (extension included) when it is first discovered. On UNMATCHED and UPDATE rescans, the block that preserves existing base fields kept that placeholder name even after a metadata provider finally matched the ROM, leaving the filename (with its ".zip" extension and region tags) as the title. Treat a name equal to the ROM's filename as "no name" so a freshly matched provider name replaces it, while still preserving user-edited names. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Shf4omKSP6dHC3dC2Jjg7b
506 lines
17 KiB
Python
506 lines
17 KiB
Python
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from handler.database import db_platform_handler, db_rom_handler
|
|
from handler.metadata import (
|
|
meta_hasheous_handler,
|
|
meta_playmatch_handler,
|
|
meta_ra_handler,
|
|
)
|
|
from handler.metadata.hasheous_handler import HasheousRom
|
|
from handler.metadata.ra_handler import RAGameRom
|
|
from handler.scan_handler import MetadataSource, ScanType, scan_platform, scan_rom
|
|
from models.platform import Platform
|
|
from models.rom import Rom, RomFile
|
|
from utils.context import initialize_context
|
|
|
|
|
|
@pytest.mark.vcr
|
|
async def test_scan_platform():
|
|
async with initialize_context():
|
|
platform = await scan_platform("n64", ["n64"])
|
|
|
|
assert type(platform) is Platform
|
|
assert platform.fs_slug == "n64"
|
|
assert platform.slug == "n64"
|
|
assert platform.name == "Nintendo 64"
|
|
assert platform.igdb_id == 4
|
|
assert platform.hasheous_id == 64
|
|
# Hasheous returns tgdb_id=None and Moby has no tgdb_id for n64, so
|
|
# this value must come from the TGDB handler fallback.
|
|
assert platform.tgdb_id == 3
|
|
|
|
async with initialize_context():
|
|
platform = await scan_platform("", [])
|
|
|
|
assert platform.fs_slug == ""
|
|
assert platform.slug == ""
|
|
assert platform.name == ""
|
|
assert platform.igdb_id is None
|
|
assert platform.hasheous_id is None
|
|
assert platform.tgdb_id is None
|
|
|
|
|
|
@pytest.mark.vcr
|
|
async def test_scan_rom():
|
|
platform = Platform(
|
|
id=1, slug="n64", fs_slug="n64", name="Nintendo 64", igdb_id=4, hasheous_id=64
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Paper Mario (USA).z64",
|
|
fs_name_no_tags="Paper Mario",
|
|
fs_name_no_ext="Paper Mario",
|
|
fs_extension="z64",
|
|
fs_path="n64/Paper Mario (USA)",
|
|
name="Paper Mario",
|
|
igdb_id=3340,
|
|
hasheous_id=4872,
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
|
|
async with initialize_context():
|
|
rom = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.QUICK,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Paper Mario (USA).z64",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [
|
|
RomFile(
|
|
rom=rom,
|
|
file_name="Paper Mario (USA).z64",
|
|
file_path="n64/Paper Mario (USA)",
|
|
file_size_bytes=23175094,
|
|
last_modified=1620000000,
|
|
crc_hash="d56d1c89",
|
|
md5_hash="7de64234ee20788b9d74d2fdb3462aed",
|
|
sha1_hash="77693a00418a9d8971b7a005f2001d997e359bff",
|
|
)
|
|
],
|
|
"crc_hash": "d56d1c89",
|
|
"md5_hash": "7de64234ee20788b9d74d2fdb3462aed",
|
|
"sha1_hash": "77693a00418a9d8971b7a005f2001d997e359bff",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.HASHEOUS],
|
|
newly_added=True,
|
|
)
|
|
|
|
assert type(rom) is Rom
|
|
assert rom.fs_name == "Paper Mario (USA).z64"
|
|
assert rom.fs_path == "n64/Paper Mario (USA)"
|
|
# Disabled until we can fix the tests
|
|
# assert rom.name == "Paper Mario"
|
|
# assert rom.igdb_id == 3340
|
|
# assert rom.hasheous_id == 4872
|
|
# assert rom.fs_size_bytes == 23175094
|
|
# assert rom.tags == []
|
|
|
|
|
|
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
|
|
@patch.object(meta_hasheous_handler, "get_ra_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "get_igdb_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "lookup_rom", new_callable=AsyncMock)
|
|
async def test_scan_rom_complete_clears_unselected_metadata(
|
|
mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled
|
|
):
|
|
"""COMPLETE rescan with newly_added=False must clear id and *_metadata
|
|
fields for sources that are no longer in metadata_sources."""
|
|
hasheous_result = HasheousRom(
|
|
hasheous_id=999,
|
|
igdb_id=None,
|
|
tgdb_id=None,
|
|
ra_id=None,
|
|
name="Mock Hasheous Game",
|
|
)
|
|
mock_lookup.return_value = hasheous_result
|
|
mock_get_igdb.return_value = hasheous_result
|
|
mock_get_ra.return_value = hasheous_result
|
|
|
|
platform = Platform(
|
|
id=1,
|
|
slug="n64",
|
|
fs_slug="n64",
|
|
name="Nintendo 64",
|
|
igdb_id=4,
|
|
ra_id=2,
|
|
hasheous_id=64,
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Paper Mario (USA).z64",
|
|
fs_name_no_tags="Paper Mario",
|
|
fs_name_no_ext="Paper Mario",
|
|
fs_extension="z64",
|
|
fs_path="n64/Paper Mario (USA)",
|
|
name="Paper Mario",
|
|
igdb_id=3340,
|
|
igdb_metadata={"summary": "stale IGDB metadata"},
|
|
ra_id=1234,
|
|
ra_metadata={"name": "stale RA metadata"},
|
|
hasheous_id=4872,
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
rom = db_rom_handler.add_rom(rom)
|
|
|
|
async with initialize_context():
|
|
result = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.COMPLETE,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Paper Mario (USA).z64",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [],
|
|
"crc_hash": "",
|
|
"md5_hash": "",
|
|
"sha1_hash": "",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.HASHEOUS],
|
|
newly_added=False,
|
|
)
|
|
|
|
# IGDB and RA were unselected — their id and metadata must be cleared.
|
|
assert result.igdb_id is None
|
|
assert result.igdb_metadata == {}
|
|
assert result.ra_id is None
|
|
assert result.ra_metadata == {}
|
|
# Hasheous is still selected and should remain populated.
|
|
assert result.hasheous_id == 999
|
|
|
|
|
|
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
|
|
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
|
|
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
|
|
async def test_scan_rom_unmatched_fetches_ra_when_id_set_but_no_metadata(
|
|
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
|
|
):
|
|
"""UNMATCHED scan must fetch RA metadata when ra_id is set manually but
|
|
ra_metadata is empty (the user manually set the ID)."""
|
|
ra_result = RAGameRom(
|
|
ra_id=2774,
|
|
name="Jak and Daxter: The Precursor's Legacy",
|
|
url_cover="https://media.retroachievements.org/Images/jpg",
|
|
)
|
|
mock_get_rom_by_id.return_value = ra_result
|
|
mock_get_rom.return_value = RAGameRom(ra_id=None)
|
|
|
|
platform = Platform(
|
|
id=1,
|
|
slug="ps2",
|
|
fs_slug="ps2",
|
|
name="PlayStation 2",
|
|
igdb_id=8,
|
|
ra_id=21,
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
# ROM has ra_id set manually but no ra_metadata (never fetched before)
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Jak and Daxter.chd",
|
|
fs_name_no_tags="Jak and Daxter",
|
|
fs_name_no_ext="Jak and Daxter",
|
|
fs_extension="chd",
|
|
fs_path="ps2",
|
|
name="Jak and Daxter",
|
|
ra_id=2774,
|
|
ra_metadata={}, # empty - never fetched
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
rom = db_rom_handler.add_rom(rom)
|
|
|
|
async with initialize_context():
|
|
result = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.UNMATCHED,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Jak and Daxter.chd",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [],
|
|
"crc_hash": "",
|
|
"md5_hash": "",
|
|
"sha1_hash": "",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.RA],
|
|
newly_added=False,
|
|
)
|
|
|
|
# ra_id was set manually - get_rom_by_id should be called, not get_rom
|
|
mock_get_rom_by_id.assert_called_once()
|
|
mock_get_rom.assert_not_called()
|
|
assert result.ra_id == 2774
|
|
|
|
|
|
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
|
|
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
|
|
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
|
|
async def test_scan_rom_unmatched_skips_ra_when_id_and_metadata_exist(
|
|
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
|
|
):
|
|
"""UNMATCHED scan must NOT re-fetch RA metadata when both ra_id and
|
|
ra_metadata are already populated."""
|
|
mock_get_rom_by_id.return_value = RAGameRom(ra_id=None)
|
|
mock_get_rom.return_value = RAGameRom(ra_id=None)
|
|
|
|
platform = Platform(
|
|
id=1,
|
|
slug="ps2",
|
|
fs_slug="ps2",
|
|
name="PlayStation 2",
|
|
igdb_id=8,
|
|
ra_id=21,
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
# ROM has both ra_id and ra_metadata populated
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Jak and Daxter.chd",
|
|
fs_name_no_tags="Jak and Daxter",
|
|
fs_name_no_ext="Jak and Daxter",
|
|
fs_extension="chd",
|
|
fs_path="ps2",
|
|
name="Jak and Daxter",
|
|
ra_id=2774,
|
|
ra_metadata={"achievements_count": 60}, # already populated
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
rom = db_rom_handler.add_rom(rom)
|
|
|
|
async with initialize_context():
|
|
result = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.UNMATCHED,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Jak and Daxter.chd",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [],
|
|
"crc_hash": "",
|
|
"md5_hash": "",
|
|
"sha1_hash": "",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.RA],
|
|
newly_added=False,
|
|
)
|
|
|
|
# Both ID and metadata exist - should not re-fetch
|
|
mock_get_rom_by_id.assert_not_called()
|
|
mock_get_rom.assert_not_called()
|
|
# Existing ra_id should be preserved
|
|
assert result.ra_id == 2774
|
|
|
|
|
|
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
|
|
@patch.object(meta_hasheous_handler, "get_ra_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "get_igdb_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "lookup_rom", new_callable=AsyncMock)
|
|
async def test_scan_rom_unmatched_replaces_placeholder_name(
|
|
mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled
|
|
):
|
|
"""UNMATCHED scan must replace the placeholder name (the raw filename set
|
|
when the ROM is first created) with a freshly matched provider name,
|
|
instead of keeping the filename (extension included) as the title."""
|
|
hasheous_result = HasheousRom(
|
|
hasheous_id=999,
|
|
igdb_id=None,
|
|
tgdb_id=None,
|
|
ra_id=None,
|
|
name="Snow Bros.",
|
|
)
|
|
mock_lookup.return_value = hasheous_result
|
|
mock_get_igdb.return_value = hasheous_result
|
|
mock_get_ra.return_value = hasheous_result
|
|
|
|
platform = Platform(
|
|
id=1, slug="n64", fs_slug="n64", name="Nintendo 64", igdb_id=4, hasheous_id=64
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
# Never-matched ROM: name defaults to the raw filename and no provider ids set.
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Snow Brothers (USA).zip",
|
|
fs_name_no_tags="Snow Brothers",
|
|
fs_name_no_ext="Snow Brothers (USA)",
|
|
fs_extension="zip",
|
|
fs_path="n64/Snow Brothers (USA)",
|
|
name="Snow Brothers (USA).zip",
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
rom = db_rom_handler.add_rom(rom)
|
|
|
|
async with initialize_context():
|
|
result = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.UNMATCHED,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Snow Brothers (USA).zip",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [],
|
|
"crc_hash": "",
|
|
"md5_hash": "",
|
|
"sha1_hash": "",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.HASHEOUS],
|
|
newly_added=False,
|
|
)
|
|
|
|
assert result.hasheous_id == 999
|
|
# The placeholder filename must be replaced by the provider name.
|
|
assert result.name == "Snow Bros."
|
|
|
|
|
|
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
|
|
@patch.object(meta_hasheous_handler, "get_ra_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "get_igdb_game", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "lookup_rom", new_callable=AsyncMock)
|
|
async def test_scan_rom_unmatched_preserves_custom_name(
|
|
mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled
|
|
):
|
|
"""UNMATCHED scan must keep a user-set name (one that differs from the raw
|
|
filename) rather than overwriting it with a provider name."""
|
|
hasheous_result = HasheousRom(
|
|
hasheous_id=999,
|
|
igdb_id=None,
|
|
tgdb_id=None,
|
|
ra_id=None,
|
|
name="Snow Bros.",
|
|
)
|
|
mock_lookup.return_value = hasheous_result
|
|
mock_get_igdb.return_value = hasheous_result
|
|
mock_get_ra.return_value = hasheous_result
|
|
|
|
platform = Platform(
|
|
id=1, slug="n64", fs_slug="n64", name="Nintendo 64", igdb_id=4, hasheous_id=64
|
|
)
|
|
platform = db_platform_handler.add_platform(platform)
|
|
|
|
# ROM with a custom name that differs from its filename.
|
|
rom = Rom(
|
|
platform_id=platform.id,
|
|
fs_name="Snow Brothers (USA).zip",
|
|
fs_name_no_tags="Snow Brothers",
|
|
fs_name_no_ext="Snow Brothers (USA)",
|
|
fs_extension="zip",
|
|
fs_path="n64/Snow Brothers (USA)",
|
|
name="My Custom Title",
|
|
fs_size_bytes=1024,
|
|
tags=[],
|
|
)
|
|
rom = db_rom_handler.add_rom(rom)
|
|
|
|
async with initialize_context():
|
|
result = await scan_rom(
|
|
platform=platform,
|
|
scan_type=ScanType.UNMATCHED,
|
|
rom=rom,
|
|
fs_rom={
|
|
"fs_name": "Snow Brothers (USA).zip",
|
|
"flat": True,
|
|
"nested": False,
|
|
"files": [],
|
|
"crc_hash": "",
|
|
"md5_hash": "",
|
|
"sha1_hash": "",
|
|
"ra_hash": "",
|
|
},
|
|
metadata_sources=[MetadataSource.HASHEOUS],
|
|
newly_added=False,
|
|
)
|
|
|
|
assert result.hasheous_id == 999
|
|
# The custom name must be preserved.
|
|
assert result.name == "My Custom Title"
|
|
|
|
|
|
def _top_level_rom_file(**kwargs) -> RomFile:
|
|
"""Build a RomFile whose `is_top_level` cached_property is pre-seeded to
|
|
True, so it passes lookup_rom's filtering without a persisted rom."""
|
|
file = RomFile(file_path="n64/Game", **kwargs)
|
|
file.__dict__["is_top_level"] = True
|
|
return file
|
|
|
|
|
|
@patch.object(meta_hasheous_handler, "_request", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "is_enabled", return_value=True)
|
|
async def test_lookup_rom_sends_all_top_level_file_hashes(
|
|
mock_is_enabled, mock_request
|
|
):
|
|
"""lookup_rom must send the hashes of every top-level file as a list,
|
|
using chd_sha1_hash (and only it) for files that have one, and skipping
|
|
files with no hashes or zero size."""
|
|
mock_request.return_value = {}
|
|
|
|
files = [
|
|
_top_level_rom_file(
|
|
file_name="disc1.bin",
|
|
file_size_bytes=100,
|
|
md5_hash="md5one",
|
|
sha1_hash="sha1one",
|
|
crc_hash="crcone",
|
|
),
|
|
# CHD file: only chd_sha1_hash should be sent, raw md5/crc ignored.
|
|
_top_level_rom_file(
|
|
file_name="disc2.chd",
|
|
file_size_bytes=200,
|
|
md5_hash="ignoredmd5",
|
|
crc_hash="ignoredcrc",
|
|
chd_sha1_hash="chdsha1",
|
|
),
|
|
# Zero-size file: must be filtered out entirely.
|
|
_top_level_rom_file(
|
|
file_name="empty.bin",
|
|
file_size_bytes=0,
|
|
md5_hash="zeromd5",
|
|
),
|
|
# No hashes at all: must be skipped.
|
|
_top_level_rom_file(file_name="nohash.bin", file_size_bytes=50),
|
|
]
|
|
|
|
result = await meta_hasheous_handler.lookup_rom("n64", files)
|
|
|
|
assert result["hasheous_id"] is None
|
|
mock_request.assert_called_once()
|
|
sent_data = mock_request.call_args.kwargs["data"]
|
|
assert sent_data == [
|
|
{"mD5": "md5one", "shA1": "sha1one", "crc": "crcone"},
|
|
{"shA1": "chdsha1"},
|
|
]
|
|
|
|
|
|
@patch.object(meta_hasheous_handler, "_request", new_callable=AsyncMock)
|
|
@patch.object(meta_hasheous_handler, "is_enabled", return_value=True)
|
|
async def test_lookup_rom_skips_request_when_no_hashes(mock_is_enabled, mock_request):
|
|
"""lookup_rom must not hit the API when no file has any usable hash."""
|
|
files = [_top_level_rom_file(file_name="nohash.bin", file_size_bytes=50)]
|
|
|
|
result = await meta_hasheous_handler.lookup_rom("n64", files)
|
|
|
|
assert result["hasheous_id"] is None
|
|
mock_request.assert_not_called()
|