diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 7b6d8e6ea..9179831cf 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -682,6 +682,14 @@ async def fetch_hltb_rom() -> HLTBRom: ) ) ): + # A refresh keeps the ID already on the ROM, often a manual match, + # rather than trading it for a filename guess. COMPLETE rematches. + if rom.hltb_id and ( + scan_type == ScanType.UPDATE + or (scan_type == ScanType.UNMATCHED and not rom.hltb_metadata) + ): + return await meta_hltb_handler.get_rom_by_id(rom.hltb_id) + return await meta_hltb_handler.get_rom(rom_attrs["fs_name"], platform.slug) return HLTBRom(hltb_id=None) diff --git a/backend/tests/handler/test_scan_hltb_match.py b/backend/tests/handler/test_scan_hltb_match.py new file mode 100644 index 000000000..dfbbcc276 --- /dev/null +++ b/backend/tests/handler/test_scan_hltb_match.py @@ -0,0 +1,143 @@ +"""How a scan picks a ROM's HowLongToBeat match. + +The filename lookup matches fuzzily, so re-running it on a ROM that already +carries an ID lets a different game outscore a match the user pinned by hand. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from handler.database import db_platform_handler, db_rom_handler +from handler.metadata.hltb_handler import HLTBMetadata, HLTBRom +from handler.scan_handler import MetadataSource, ScanType, scan_rom +from models.platform import Platform +from models.rom import Rom +from utils.context import initialize_context + +FS_NAME = "Mario Kart 64 (USA).z64" +PINNED = HLTBRom(hltb_id=2255, name="Mario Kart 64") +GUESSED = HLTBRom(hltb_id=9999, name="Mario Kart 8") +NO_MATCH = HLTBRom(hltb_id=None) + + +@pytest.fixture +def lookups(): + """Patch both HowLongToBeat lookups, defaulting each to a miss.""" + with ( + patch( + "handler.scan_handler.meta_hltb_handler.get_rom_by_id", + new=AsyncMock(return_value=NO_MATCH), + ) as by_id, + patch( + "handler.scan_handler.meta_hltb_handler.get_rom", + new=AsyncMock(return_value=NO_MATCH), + ) as by_name, + ): + yield SimpleNamespace(by_id=by_id, by_name=by_name) + + +async def _scan( + scan_type: ScanType, + hltb_id: int | None = None, + hltb_metadata: HLTBMetadata | None = None, +) -> Rom: + platform = db_platform_handler.add_platform( + Platform(id=1, slug="n64", fs_slug="n64", name="Nintendo 64") + ) + rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + fs_name=FS_NAME, + fs_name_no_tags="Mario Kart 64", + fs_name_no_ext="Mario Kart 64 (USA)", + fs_extension="z64", + fs_path="n64", + name="Mario Kart 64", + hltb_id=hltb_id, + hltb_metadata=dict(hltb_metadata or {}), + fs_size_bytes=1024, + tags=[], + ) + ) + + async with initialize_context(): + return await scan_rom( + platform=platform, + scan_type=scan_type, + rom=rom, + fs_rom={ + "fs_name": FS_NAME, + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + }, + metadata_sources=[MetadataSource.HLTB], + newly_added=False, + ) + + +async def test_no_stored_id_uses_the_file_name_lookup(lookups): + lookups.by_name.return_value = GUESSED + + result = await _scan(ScanType.COMPLETE) + + lookups.by_id.assert_not_awaited() + lookups.by_name.assert_awaited_once_with(FS_NAME, "n64") + assert result.hltb_id == 9999 + + +async def test_complete_rescan_rematches_a_stored_id(lookups): + """A complete rescan wipes external IDs by design, so it re-runs the search.""" + lookups.by_name.return_value = GUESSED + + result = await _scan(ScanType.COMPLETE, hltb_id=2255) + + lookups.by_id.assert_not_awaited() + lookups.by_name.assert_awaited_once_with(FS_NAME, "n64") + assert result.hltb_id == 9999 + + +async def test_update_scan_refetches_a_stored_id(lookups): + """A pinned match is refreshed by ID, never re-guessed from the filename.""" + lookups.by_id.return_value = PINNED + + result = await _scan( + ScanType.UPDATE, hltb_id=2255, hltb_metadata=HLTBMetadata(release_year=1997) + ) + + lookups.by_id.assert_awaited_once_with(2255) + lookups.by_name.assert_not_awaited() + assert result.hltb_id == 2255 + + +async def test_stored_id_is_not_replaced_by_a_file_name_guess(lookups): + """An ID the provider can't resolve stays put rather than being re-guessed.""" + await _scan(ScanType.UPDATE, hltb_id=2255) + + lookups.by_id.assert_awaited_once_with(2255) + lookups.by_name.assert_not_awaited() + + +async def test_unmatched_scan_refetches_metadata_for_a_stored_id(lookups): + lookups.by_id.return_value = PINNED + + result = await _scan(ScanType.UNMATCHED, hltb_id=2255) + + lookups.by_id.assert_awaited_once_with(2255) + lookups.by_name.assert_not_awaited() + assert result.hltb_id == 2255 + + +async def test_unmatched_scan_without_a_stored_id_uses_the_file_name_lookup(lookups): + lookups.by_name.return_value = GUESSED + + await _scan(ScanType.UNMATCHED) + + lookups.by_id.assert_not_awaited() + lookups.by_name.assert_awaited_once_with(FS_NAME, "n64")