mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
* feat(converto): read per-file title ids with rom-converto during scans The title-id half of #5002: rom-converto's info runs fill rom_files.title_id and title_version, sigil fills what rom-converto can't read and keeps owning save targets, and roms in a scan batch share info runs of up to 64 files. A converto_read_at marker lets quick scans backfill files rom-converto has not read yet. converto.scan_metadata (config.yml only) turns it off. The other per-file fields and embedded images stay out until a UI reads them. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): name the same-content check that keeps unread title ids Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): queue unread files in one pass over the built rows Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(converto): address Greptile review on #5031 - A file refresh now persists converto_read_at when rom-converto's read confirms a reused row's stored title id, so the file isn't re-read on every refresh. - RomConvertoService.read_infos splits its paths into info runs of at most INFO_BATCH_SIZE, so a rom with many files can't send them all through one run. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(converto): apply code-review fixes - A stored file title id counts as rom-converto's only once rom-converto has read the row, so a stale sigil id can't override a fresh extraction. - Fresh hashes decide whether unread title ids still describe a file, ahead of a size and mtime a copy can preserve. - One rom's failed candidate listing no longer aborts its scan batch. - The info paths file's cleanup can't raise out of read_infos. - _rom_file_order sorts by Path like _TitleIdSource.order. - Restored the in-place reconcile test for _identify_rom. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): apply simplify pass - sync_rom_files reads 'rom-converto read this pass' off converto_read_at instead of an inspected set threaded through ParsedRomFiles, FSRom, the scan socket and the refresh. - One _converto_may_read check serves the scan and the batch candidates. - converto_candidates reuses _list_rom_dir, validates paths off the event loop and is best-effort itself, so the scan socket gathers it plainly. - get_rom_files builds rom-converto paths from the validated rom dir instead of validating each file again. - _rom_level_identity returns early on sigil's extraction. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(converto): apply review-polish pass Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): drop the shared info batch Sharing rom-converto info runs across a scan batch saved about 2 ms per rom (one process start) in exchange for a second directory walk per rebuilt rom, the RomConvertoInfoBatch bookkeeping and the scan socket wiring. Each rom now reads its own files; read_infos still caps a run at INFO_RUN_MAX_FILES. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(converto): name the rom-converto columns inline Drops ROM_FILE_CONVERTO_COLUMNS and ROM_FILE_CONVERTO_KEPT_COLUMNS, so _copy_scanned_columns keeps master's frozenset keep_when_unset. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
925 lines
33 KiB
Python
925 lines
33 KiB
Python
import logging
|
|
import os
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from config.config_manager import (
|
|
DEFAULT_EXCLUDED_EXTENSIONS,
|
|
DEFAULT_EXCLUDED_FILES,
|
|
DEFAULT_EXCLUDED_MULTI_FILE_DIRS,
|
|
DEFAULT_EXCLUDED_PLATFORM_DIRS,
|
|
ConfigManager,
|
|
ConvertoConfig,
|
|
parse_firmware_template,
|
|
parse_platform_templates,
|
|
parse_structure_template,
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def capture_romm_logs(caplog, level=logging.WARNING):
|
|
"""Capture the `romm` logger, whose records never reach caplog on their own.
|
|
|
|
It sets `propagate = False`, so caplog's handler has to be attached to it
|
|
directly rather than to the root logger.
|
|
"""
|
|
romm_logger = logging.getLogger("romm")
|
|
romm_logger.addHandler(caplog.handler)
|
|
try:
|
|
with caplog.at_level(level, logger="romm"):
|
|
yield caplog
|
|
finally:
|
|
romm_logger.removeHandler(caplog.handler)
|
|
|
|
|
|
def test_config_loader():
|
|
loader = ConfigManager(
|
|
os.path.join(Path(__file__).resolve().parent, "fixtures", "config/config.yml")
|
|
)
|
|
|
|
assert loader.config.EXCLUDED_PLATFORMS == sorted(
|
|
{*DEFAULT_EXCLUDED_PLATFORM_DIRS, "romm"}
|
|
)
|
|
assert loader.config.EXCLUDED_SINGLE_EXT == sorted(
|
|
{
|
|
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
|
|
"xml",
|
|
}
|
|
)
|
|
assert loader.config.EXCLUDED_SINGLE_FILES == sorted(
|
|
{*DEFAULT_EXCLUDED_FILES, "info.txt"}
|
|
)
|
|
assert loader.config.EXCLUDED_MULTI_FILES == sorted(
|
|
{
|
|
*DEFAULT_EXCLUDED_MULTI_FILE_DIRS,
|
|
"my_multi_file_game",
|
|
"DLC",
|
|
}
|
|
)
|
|
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == sorted(
|
|
{
|
|
*(e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS),
|
|
"txt",
|
|
}
|
|
)
|
|
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == sorted(
|
|
{
|
|
*DEFAULT_EXCLUDED_FILES,
|
|
"data.xml",
|
|
}
|
|
)
|
|
assert loader.config.PLATFORMS_BINDING == {"gc": "ngc"}
|
|
assert loader.config.PLATFORMS_VERSIONS == {"naomi": "arcade"}
|
|
assert loader.config.SKIP_HASH_CALCULATION
|
|
assert loader.config.EJS_DEBUG
|
|
assert loader.config.EJS_DISABLE_AUTO_UNLOAD
|
|
assert loader.config.EJS_DISABLE_BATCH_BOOTUP
|
|
assert not loader.config.EJS_ENABLE_AUTO_SAVE_SYNC
|
|
assert loader.config.EJS_CACHE_LIMIT == 1000
|
|
assert loader.config.EJS_NETPLAY_ENABLED
|
|
assert loader.config.EJS_NETPLAY_ICE_SERVERS == [
|
|
{"urls": "stun:stun.relay.metered.ca:80"},
|
|
{
|
|
"urls": "turn:global.relay.metered.ca:80",
|
|
"username": "user",
|
|
"credential": "password",
|
|
},
|
|
]
|
|
assert loader.config.EJS_DEFAULT_CORES == {
|
|
"nds": "desmume",
|
|
"nintendo-dsi": "melonds",
|
|
}
|
|
assert loader.config.EJS_SETTINGS == {
|
|
"parallel_n64": {"vsync": "disabled"},
|
|
"snes9x": {"snes9x_region": "ntsc"},
|
|
}
|
|
assert loader.config.EJS_CONTROLS == {
|
|
"snes9x": {
|
|
"_0": {0: {"value": "x", "value2": "BUTTON_2"}},
|
|
"_1": {},
|
|
"_2": {},
|
|
"_3": {},
|
|
},
|
|
}
|
|
assert loader.config.SCAN_METADATA_PRIORITY == ["ss", "launchbox"]
|
|
assert loader.config.SCAN_ARTWORK_PRIORITY == ["igdb", "ss"]
|
|
assert loader.config.SCAN_ARTWORK_PRIORITY_OVERRIDES == {
|
|
"url_cover": ["ss", "tgdb"],
|
|
"url_screenshots": ["igdb"],
|
|
}
|
|
assert loader.config.SCAN_REGION_PRIORITY == ["jp", "eu", "wor"]
|
|
assert loader.config.SCAN_REGION_MODE == "prefer_config"
|
|
assert loader.config.SCAN_LANGUAGE_PRIORITY == ["jp", "es"]
|
|
assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box3d"
|
|
assert loader.config.GAMELIST_MEDIA_IMAGE == "title_screen"
|
|
assert loader.config.STRUCTURE_TEMPLATES == {
|
|
"default": "ROMS/{platform}/{game}",
|
|
"firmware": "BIOS/{platform}",
|
|
"psx": "ROMS/{platform}/{category}/{game}",
|
|
"nes": ["ROMS/{platform}/{game}", "ROMS/{platform}/{category}/{game}"],
|
|
}
|
|
assert loader.config.default_structure.platform_dir == ("ROMS",)
|
|
assert loader.config.platforms_dir == "ROMS"
|
|
assert loader.config.firmware_structure.firmware_dir("psx") == "BIOS/psx"
|
|
|
|
# The accessor parses templates on demand.
|
|
psx = loader.config.platform_structure("psx")
|
|
assert len(psx) == 1
|
|
assert psx[0].platform_path("psx") == "ROMS/psx"
|
|
assert len(psx[0].levels) == 1 and psx[0].levels[0].literal is None
|
|
# The list form yields one structure per template (union on discovery).
|
|
nes = loader.config.platform_structure("nes")
|
|
assert len(nes) == 2
|
|
assert nes[0].levels == ()
|
|
assert len(nes[1].levels) == 1 and nes[1].levels[0].literal is None
|
|
# A platform without an override falls back to the library-wide layout.
|
|
assert loader.config.platform_structure("snes") == (
|
|
loader.config.default_structure,
|
|
)
|
|
|
|
|
|
def test_scan_priority_sources_match_metadata_source_enum():
|
|
"""VALID_SCAN_PRIORITY_SOURCES duplicates MetadataSource to avoid a circular
|
|
import; guard against the two drifting apart."""
|
|
from config.config_manager import VALID_SCAN_PRIORITY_SOURCES
|
|
from handler.scan_handler import MetadataSource
|
|
|
|
assert VALID_SCAN_PRIORITY_SOURCES == {source.value for source in MetadataSource}
|
|
|
|
|
|
def test_empty_config_loader():
|
|
loader = ConfigManager(
|
|
os.path.join(
|
|
Path(__file__).resolve().parent, "fixtures", "config/empty_config.yml"
|
|
)
|
|
)
|
|
|
|
assert loader.config.EXCLUDED_PLATFORMS == sorted(DEFAULT_EXCLUDED_PLATFORM_DIRS)
|
|
assert loader.config.EXCLUDED_SINGLE_EXT == sorted(
|
|
{e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS}
|
|
)
|
|
assert "ini" not in loader.config.EXCLUDED_SINGLE_EXT
|
|
assert loader.config.EXCLUDED_SINGLE_FILES == sorted(DEFAULT_EXCLUDED_FILES)
|
|
assert loader.config.EXCLUDED_MULTI_FILES == sorted(
|
|
DEFAULT_EXCLUDED_MULTI_FILE_DIRS
|
|
)
|
|
assert loader.config.EXCLUDED_MULTI_PARTS_EXT == sorted(
|
|
{e.lower() for e in DEFAULT_EXCLUDED_EXTENSIONS}
|
|
)
|
|
assert "ini" not in loader.config.EXCLUDED_MULTI_PARTS_EXT
|
|
assert loader.config.EXCLUDED_MULTI_PARTS_FILES == sorted(DEFAULT_EXCLUDED_FILES)
|
|
assert loader.config.PLATFORMS_BINDING == {}
|
|
assert loader.config.PLATFORMS_VERSIONS == {}
|
|
assert loader.config.platforms_dir == "roms"
|
|
assert loader.config.firmware_structure.firmware_dir("nes") == "bios/nes"
|
|
assert not loader.config.SKIP_HASH_CALCULATION
|
|
assert not loader.config.EJS_DEBUG
|
|
assert loader.config.EJS_CACHE_LIMIT is None
|
|
assert not loader.config.EJS_DISABLE_AUTO_UNLOAD
|
|
assert not loader.config.EJS_DISABLE_BATCH_BOOTUP
|
|
assert loader.config.EJS_ENABLE_AUTO_SAVE_SYNC
|
|
assert not loader.config.EJS_NETPLAY_ENABLED
|
|
assert loader.config.EJS_NETPLAY_ICE_SERVERS == []
|
|
assert loader.config.EJS_DEFAULT_CORES == {}
|
|
assert loader.config.EJS_SETTINGS == {}
|
|
assert loader.config.EJS_CONTROLS == {}
|
|
assert loader.config.SCAN_ARTWORK_PRIORITY_OVERRIDES == {}
|
|
assert loader.config.SCAN_REGION_MODE == "prefer_rom_tags"
|
|
assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box2d"
|
|
assert loader.config.GAMELIST_MEDIA_IMAGE == "screenshot"
|
|
assert not loader.config.CONVERTO.download_conversion_enabled
|
|
assert loader.config.CONVERTO.scan_metadata
|
|
assert loader.config.CONVERTO.cache_ttl_hours == 24
|
|
assert loader.config.CONVERTO.platform_formats == {}
|
|
assert loader.config.STRUCTURE_TEMPLATES == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "platform_dir", "levels"),
|
|
[
|
|
("{platform}/{game}", (), ()),
|
|
("roms/{platform}/{game}", ("roms",), ()),
|
|
("roms/{platform}/{category}/{game}", ("roms",), (None,)),
|
|
("roms/{platform}/Hacks/{game}", ("roms",), ("Hacks",)),
|
|
(
|
|
"games/all/{platform}/{region}/{system}/{game}",
|
|
("games", "all"),
|
|
(None, None),
|
|
),
|
|
],
|
|
)
|
|
def test_parse_structure_template_valid(template, platform_dir, levels):
|
|
structure = parse_structure_template(template)
|
|
assert structure.platform_dir == platform_dir
|
|
assert tuple(level.literal for level in structure.levels) == levels
|
|
|
|
|
|
def test_parse_structure_template_accepts_the_platform_folder_by_name():
|
|
"""A per-platform override may name the folder outright instead of using the
|
|
macro, which is how the key already reads."""
|
|
structure = parse_structure_template("roms/ps3/{category}/{game}", fs_slug="ps3")
|
|
assert structure.platform_dir == ("roms",)
|
|
assert structure.platform_path("ps3") == "roms/ps3"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"template",
|
|
[
|
|
"",
|
|
"justliteral",
|
|
"roms/{platform}", # no terminal
|
|
"roms/{platform}/{category}", # no terminal
|
|
"roms/{game}", # no platform folder
|
|
"{game}/roms/{platform}", # terminal not last
|
|
"roms/{platform}/{game}/extra", # terminal not last
|
|
"roms/{platform}/{platform}/{game}", # platform folder twice
|
|
"{category}/{platform}/{game}", # wildcard above the platform folder
|
|
"{library}/roms/{platform}/{game}", # macro RomM resolves itself
|
|
"roms/{platform}/{}/{game}", # empty macro
|
|
"roms/{platform}/{gameFile}", # the terminal is spelled {game}
|
|
"roms/{platform}/./{game}", # no listing ever yields '.'
|
|
"roms/{platform}/../{game}",
|
|
"../roms/{platform}/{game}",
|
|
],
|
|
)
|
|
def test_parse_structure_template_invalid(template):
|
|
with pytest.raises(ValueError):
|
|
parse_structure_template(template)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "platform_dir", "subdir"),
|
|
[
|
|
("bios/{platform}", ("bios",), ()),
|
|
("{platform}/bios", (), ("bios",)),
|
|
("{platform}/firmware/bios", (), ("firmware", "bios")),
|
|
],
|
|
)
|
|
def test_parse_firmware_template_valid(template, platform_dir, subdir):
|
|
template_parsed = parse_firmware_template(template)
|
|
assert template_parsed.platform_dir == platform_dir
|
|
assert template_parsed.subdir == subdir
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"template",
|
|
[
|
|
"",
|
|
"bios", # no platform folder
|
|
"bios/{platform}/{game}", # firmware is a folder, not a set of games
|
|
"bios/{platform}/{region}", # no wildcard levels
|
|
"bios/{platform}/..",
|
|
],
|
|
)
|
|
def test_parse_firmware_template_invalid(template):
|
|
with pytest.raises(ValueError):
|
|
parse_firmware_template(template)
|
|
|
|
|
|
def test_structure_templates_are_keyed_case_insensitively(monkeypatch, tmp_path):
|
|
"""`system.platforms` lowercases its folder names, so `filesystem.structure`
|
|
has to as well or the same key works in one block and not the other."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"filesystem:\n structure:\n"
|
|
" 'Atari - 2600': 'roms/{platform}/Hacks/{game}'\n"
|
|
)
|
|
config = ConfigManager(str(config_file)).get_config()
|
|
|
|
for key in ("Atari - 2600", "atari - 2600"):
|
|
structure = config.platform_structure(key)
|
|
assert len(structure) == 1
|
|
assert structure[0].levels[0].literal == "Hacks"
|
|
|
|
|
|
def test_parse_platform_templates_string_and_list():
|
|
# A bare string yields a single structure.
|
|
single = parse_platform_templates("roms/{platform}/{game}")
|
|
assert len(single) == 1 and single[0].levels == ()
|
|
|
|
# A list yields one structure per template, preserving order.
|
|
multi = parse_platform_templates(
|
|
["roms/{platform}/{game}", "roms/{platform}/{category}/{game}"]
|
|
)
|
|
assert len(multi) == 2
|
|
assert multi[0].levels == ()
|
|
assert len(multi[1].levels) == 1 and multi[1].levels[0].literal is None
|
|
|
|
|
|
def test_parse_platform_templates_propagates_invalid():
|
|
with pytest.raises(ValueError):
|
|
parse_platform_templates(["roms/{platform}/{game}", "nope"])
|
|
|
|
|
|
def test_missing_config_file_is_created(tmp_path):
|
|
config_file = tmp_path / "config" / "config.yml"
|
|
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
assert config_file.parent.exists()
|
|
assert config_file.exists()
|
|
assert config_file.read_text() == ""
|
|
assert loader.config.CONFIG_FILE_MOUNTED
|
|
assert loader.config.CONFIG_FILE_WRITABLE
|
|
|
|
|
|
def test_forward_compat_unknown_values_are_tolerated():
|
|
"""A newer release may ship sample configs that reference media types
|
|
this version doesn't yet recognize. The loader should drop unknowns and
|
|
fall back to defaults rather than exiting."""
|
|
loader = ConfigManager(
|
|
os.path.join(
|
|
Path(__file__).resolve().parent,
|
|
"fixtures",
|
|
"config/forward_compat_config.yml",
|
|
)
|
|
)
|
|
|
|
# Unknown entries in scan.media are filtered out; known ones survive.
|
|
assert loader.config.SCAN_MEDIA == ["box2d", "screenshot"]
|
|
# Unknown thumbnail/image values fall back to their defaults.
|
|
assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box2d"
|
|
assert loader.config.GAMELIST_MEDIA_IMAGE == "screenshot"
|
|
# Unknown region_mode values fall back to the default.
|
|
assert loader.config.SCAN_REGION_MODE == "prefer_rom_tags"
|
|
|
|
|
|
def test_non_string_region_mode_falls_back_to_default(tmp_path):
|
|
"""A list/mapping region_mode is unhashable and must not crash the
|
|
membership check against the valid-modes set."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"scan:\n priority:\n region_mode:\n - prefer_config\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
assert loader.config.SCAN_REGION_MODE == "prefer_rom_tags"
|
|
|
|
|
|
def test_malformed_yaml_falls_back_to_defaults():
|
|
"""A YAML parse error should log critically and leave the app on
|
|
defaults, not crash."""
|
|
loader = ConfigManager(
|
|
os.path.join(
|
|
Path(__file__).resolve().parent,
|
|
"fixtures",
|
|
"config/malformed_config.yml",
|
|
)
|
|
)
|
|
|
|
assert loader.config.platforms_dir == "roms"
|
|
assert loader.config.firmware_structure.firmware_dir("nes") == "bios/nes"
|
|
assert loader.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]
|
|
# The parse error is surfaced so the UI can warn the user their whole
|
|
# config (not just the broken part) was discarded.
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
|
|
|
|
|
|
def test_valid_config_has_no_parse_error():
|
|
"""A syntactically valid config should not report a parse error."""
|
|
loader = ConfigManager(
|
|
os.path.join(Path(__file__).resolve().parent, "fixtures", "config/config.yml")
|
|
)
|
|
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
|
|
|
|
|
|
def test_mixed_gamelist_syntax_surfaces_parse_error(tmp_path):
|
|
"""Regression for #3708: mixing the old (`scan.export`) and new
|
|
(`scan.gamelist.export`) syntax produces invalid YAML. The whole config is
|
|
discarded and defaults are used, so the parse error must be surfaced rather
|
|
than silently swallowed."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"scan:\n"
|
|
" gamelist:\n"
|
|
" export: true\n"
|
|
" - gamelist_xml: true\n"
|
|
" media:\n"
|
|
" - box2d\n"
|
|
" - video\n"
|
|
" - manual\n"
|
|
)
|
|
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
|
|
# The user's scan.media list is lost, falling back to defaults.
|
|
assert loader.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]
|
|
|
|
|
|
def test_parse_error_is_cleared_when_config_is_missing(tmp_path):
|
|
"""A stale parse error from a malformed config must not persist once the
|
|
file is gone. The loader is a singleton, so a later reload that skips YAML
|
|
parsing (e.g. the file was deleted) has to clear the flag."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text("scan:\n - broken: true\n media: [box2d]\n")
|
|
|
|
loader = ConfigManager(str(config_file))
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is not None
|
|
|
|
# get_config's FileNotFoundError branch must clear the stale error.
|
|
config_file.unlink()
|
|
loader.get_config()
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
|
|
|
|
|
|
def test_parse_error_is_cleared_when_config_is_recreated(tmp_path):
|
|
"""Reloading via a missing file goes through _create_missing_config_file,
|
|
which must also clear a stale parse error from a prior malformed load."""
|
|
broken_file = tmp_path / "config.yml"
|
|
broken_file.write_text("scan:\n - broken: true\n media: [box2d]\n")
|
|
|
|
ConfigManager(str(broken_file))
|
|
|
|
# Reusing the singleton with a missing path exercises the __init__
|
|
# FileNotFoundError -> _create_missing_config_file recreate path.
|
|
broken_file.unlink()
|
|
loader = ConfigManager(str(broken_file))
|
|
|
|
assert loader.config.CONFIG_FILE_PARSE_ERROR is None
|
|
|
|
|
|
def test_config_updates_serialize_gamelist_media_as_plain_strings(tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"scan:\n"
|
|
" gamelist:\n"
|
|
" media:\n"
|
|
" thumbnail: box2d\n"
|
|
" image: screenshot\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
loader.add_platform_binding("atarist", "atari-st")
|
|
|
|
config_text = config_file.read_text()
|
|
assert "!!python/object" not in config_text
|
|
assert "thumbnail: box2d" in config_text
|
|
assert "image: screenshot" in config_text
|
|
|
|
reloaded = ConfigManager(str(config_file))
|
|
assert reloaded.config.PLATFORMS_BINDING == {"atarist": "atari-st"}
|
|
|
|
|
|
def test_update_scan_settings_round_trip(tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"scan:\n priority:\n region_mode: prefer_config\n media:\n - box2d\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
loader.update_scan_settings(
|
|
metadata_priority=["ss", "igdb"],
|
|
artwork_priority=["igdb", "ss"],
|
|
artwork_overrides={"cover": ["ss"], "screenshot": None, "manual": None},
|
|
region_priority=["jp", "us"],
|
|
language_priority=["ja", "en"],
|
|
media=["box2d", "screenshot", "manual"],
|
|
gamelist_export=True,
|
|
gamelist_thumbnail="box3d",
|
|
gamelist_image="title_screen",
|
|
pegasus_export=True,
|
|
)
|
|
|
|
config_text = config_file.read_text()
|
|
assert "!!python/object" not in config_text
|
|
|
|
reloaded = ConfigManager(str(config_file))
|
|
assert reloaded.config.SCAN_METADATA_PRIORITY == ["ss", "igdb"]
|
|
assert reloaded.config.SCAN_ARTWORK_PRIORITY == ["igdb", "ss"]
|
|
# Only the "cover" override was provided; the others fall back to artwork.
|
|
assert reloaded.config.SCAN_ARTWORK_PRIORITY_OVERRIDES == {"url_cover": ["ss"]}
|
|
assert reloaded.config.SCAN_REGION_PRIORITY == ["jp", "us"]
|
|
# region_mode is not runtime-editable but must survive the rewrite.
|
|
assert reloaded.config.SCAN_REGION_MODE == "prefer_config"
|
|
assert reloaded.config.SCAN_LANGUAGE_PRIORITY == ["ja", "en"]
|
|
assert reloaded.config.SCAN_MEDIA == ["box2d", "screenshot", "manual"]
|
|
assert reloaded.config.GAMELIST_AUTO_EXPORT_ON_SCAN is True
|
|
assert reloaded.config.GAMELIST_MEDIA_THUMBNAIL == "box3d"
|
|
assert reloaded.config.GAMELIST_MEDIA_IMAGE == "title_screen"
|
|
assert reloaded.config.PEGASUS_AUTO_EXPORT_ON_SCAN is True
|
|
|
|
|
|
def test_config_update_preserves_streaming_section(tmp_path):
|
|
"""A runtime write (e.g. saving scan settings) must not drop the
|
|
streaming section, which isn't otherwise re-serialized."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"streaming:\n"
|
|
" enabled: true\n"
|
|
" containers:\n"
|
|
" - platform: ps2\n"
|
|
" host: https://192.168.1.51:3001\n"
|
|
" broker_host: http://192.168.1.51:8000\n"
|
|
" label: PCSX2\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
assert loader.config.STREAMING_ENABLED
|
|
|
|
loader.add_platform_binding("gc", "ngc")
|
|
|
|
reloaded = ConfigManager(str(config_file))
|
|
assert reloaded.config.STREAMING_ENABLED
|
|
assert reloaded.config.STREAMING_CONTAINERS == [
|
|
{
|
|
"platform": "ps2",
|
|
"host": "https://192.168.1.51:3001",
|
|
"broker_host": "http://192.168.1.51:8000",
|
|
"label": "PCSX2",
|
|
}
|
|
]
|
|
|
|
|
|
def test_legacy_streaming_container_logs_deprecation_warning(caplog, tmp_path):
|
|
"""A container without `protocol: webstation` is the deprecated
|
|
per-emulator broker shape and must warn, not fail, so it keeps working
|
|
for one more release while pointing operators at the migration guide."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"streaming:\n"
|
|
" enabled: true\n"
|
|
" containers:\n"
|
|
" - platform: ps2\n"
|
|
" host: https://192.168.1.51:3001\n"
|
|
" broker_host: http://192.168.1.51:8000\n"
|
|
" label: PCSX2\n"
|
|
)
|
|
with capture_romm_logs(caplog):
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
assert loader.config.STREAMING_CONTAINERS
|
|
assert "deprecated" in caplog.text
|
|
assert "emulator-streaming-migration" in caplog.text
|
|
|
|
|
|
def test_webstation_streaming_container_does_not_warn(caplog, tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"streaming:\n"
|
|
" enabled: true\n"
|
|
" containers:\n"
|
|
" - host: https://192.168.1.56:3010\n"
|
|
" protocol: webstation\n"
|
|
" subfolder: /streaming\n"
|
|
" label: Emulation station\n"
|
|
" platforms:\n"
|
|
" ps2: pcsx2\n"
|
|
)
|
|
with capture_romm_logs(caplog):
|
|
ConfigManager(str(config_file))
|
|
|
|
assert "deprecated" not in caplog.text
|
|
|
|
|
|
def test_config_update_preserves_nested_container_platforms(tmp_path):
|
|
"""A container's `platforms` map is the only nested mapping inside the
|
|
containers list, so it is the shape a runtime rewrite could flatten."""
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"streaming:\n"
|
|
" enabled: true\n"
|
|
" containers:\n"
|
|
" - host: https://192.168.1.51:3001\n"
|
|
" broker_host: http://192.168.1.51:8000\n"
|
|
" label: WEBSTATION\n"
|
|
" platforms:\n"
|
|
" ps2: pcsx2\n"
|
|
" ngc: dolphin\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
loader.add_platform_binding("gc", "ngc")
|
|
|
|
reloaded = ConfigManager(str(config_file))
|
|
assert reloaded.config.STREAMING_CONTAINERS == [
|
|
{
|
|
"host": "https://192.168.1.51:3001",
|
|
"broker_host": "http://192.168.1.51:8000",
|
|
"label": "WEBSTATION",
|
|
"platforms": {"ps2": "pcsx2", "ngc": "dolphin"},
|
|
}
|
|
]
|
|
|
|
|
|
def _write_config(tmp_path: Path, system_block: str) -> ConfigManager:
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(f"system:\n{system_block}")
|
|
return ConfigManager(str(config_file))
|
|
|
|
|
|
def test_platform_folder_names_are_lowercased(tmp_path):
|
|
loader = _write_config(
|
|
tmp_path,
|
|
' platforms:\n GameCube: "ngc"\n versions:\n NAOMI: "arcade"\n',
|
|
)
|
|
|
|
assert loader.config.PLATFORMS_BINDING == {"gamecube": "ngc"}
|
|
assert loader.config.PLATFORMS_VERSIONS == {"naomi": "arcade"}
|
|
|
|
|
|
def test_null_platforms_block_means_empty(tmp_path):
|
|
loader = _write_config(tmp_path, " platforms:\n versions:\n")
|
|
|
|
assert loader.config.PLATFORMS_BINDING == {}
|
|
assert loader.config.PLATFORMS_VERSIONS == {}
|
|
|
|
|
|
@pytest.mark.parametrize("value", ['""', "5", "~"])
|
|
def test_platform_binding_must_be_a_non_empty_string(tmp_path, value):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_config(tmp_path, f" platforms:\n gamecube: {value}\n")
|
|
|
|
assert excinfo.value.code == 3
|
|
|
|
|
|
def test_platform_binding_lookup_ignores_case(tmp_path):
|
|
loader = _write_config(tmp_path, ' platforms:\n GameCube: "ngc"\n')
|
|
|
|
loader.remove_platform_binding("GAMECUBE")
|
|
assert loader.config.PLATFORMS_BINDING == {}
|
|
|
|
|
|
def test_case_variant_folder_keys_collapse_with_a_warning(caplog, tmp_path):
|
|
"""One key covers both, so the dropped mapping must not vanish silently."""
|
|
with capture_romm_logs(caplog):
|
|
loader = _write_config(
|
|
tmp_path, ' platforms:\n PSX: "ps2"\n psx: "psx"\n'
|
|
)
|
|
|
|
assert loader.config.PLATFORMS_BINDING == {"psx": "psx"}
|
|
assert "case variant" in caplog.text
|
|
assert "ps2" in caplog.text
|
|
|
|
|
|
def test_identical_case_variant_mappings_do_not_warn(caplog, tmp_path):
|
|
with capture_romm_logs(caplog):
|
|
loader = _write_config(
|
|
tmp_path, ' platforms:\n PSX: "psx"\n psx: "psx"\n'
|
|
)
|
|
|
|
assert loader.config.PLATFORMS_BINDING == {"psx": "psx"}
|
|
assert "case variant" not in caplog.text
|
|
|
|
|
|
def test_rebinding_a_folder_replaces_the_existing_platform(tmp_path):
|
|
loader = _write_config(tmp_path, ' platforms:\n GameCube: "ngc"\n')
|
|
|
|
loader.add_platform_binding("GameCube", "wii")
|
|
|
|
assert loader.config.PLATFORMS_BINDING == {"gamecube": "wii"}
|
|
reloaded = ConfigManager(loader.config_file)
|
|
assert reloaded.config.PLATFORMS_BINDING == {"gamecube": "wii"}
|
|
|
|
|
|
def test_reparenting_a_folder_replaces_the_existing_version(tmp_path):
|
|
loader = _write_config(tmp_path, ' versions:\n NAOMI: "arcade"\n')
|
|
|
|
loader.add_platform_version("naomi", "dc")
|
|
|
|
assert loader.config.PLATFORMS_VERSIONS == {"naomi": "dc"}
|
|
reloaded = ConfigManager(loader.config_file)
|
|
assert reloaded.config.PLATFORMS_VERSIONS == {"naomi": "dc"}
|
|
|
|
|
|
def test_converto_config_from_yaml(tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(
|
|
"converto:\n"
|
|
" download_conversion_enabled: true\n"
|
|
" scan_metadata: false\n"
|
|
" cache_ttl_hours: 48\n"
|
|
" cache_max_size_gb: 0\n"
|
|
" platform_formats:\n"
|
|
" PSP: iso\n"
|
|
" ngc: rvz\n"
|
|
)
|
|
loader = ConfigManager(str(config_file))
|
|
|
|
# Slugs and targets are normalized to lowercase.
|
|
assert loader.config.CONVERTO == ConvertoConfig(
|
|
download_conversion_enabled=True,
|
|
scan_metadata=False,
|
|
cache_ttl_hours=48,
|
|
cache_max_size_gb=0,
|
|
platform_formats={"psp": "iso", "ngc": "rvz"},
|
|
)
|
|
|
|
|
|
def test_null_converto_platform_formats_means_empty(tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text("converto:\n platform_formats:\n")
|
|
|
|
assert ConfigManager(str(config_file)).config.CONVERTO.platform_formats == {}
|
|
|
|
|
|
def test_update_converto_settings_round_trip(tmp_path):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text("converto:\n cache_ttl_hours: 12\n")
|
|
loader = ConfigManager(str(config_file))
|
|
converto = ConvertoConfig(
|
|
download_conversion_enabled=True,
|
|
scan_metadata=True,
|
|
cache_ttl_hours=72,
|
|
cache_max_size_gb=50,
|
|
platform_formats={"psp": "iso", "ngc": "rvz"},
|
|
)
|
|
|
|
loader.update_converto_settings(converto)
|
|
|
|
assert ConfigManager(str(config_file)).config.CONVERTO == converto
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"block",
|
|
[
|
|
" platform_formats:\n psvita: iso\n",
|
|
" platform_formats:\n psp: rvz\n",
|
|
" platform_formats:\n xbox: xiso\n",
|
|
" cache_ttl_hours: 0\n",
|
|
" cache_max_size_gb: -1\n",
|
|
" cache_max_size_gb: true\n",
|
|
],
|
|
)
|
|
def test_invalid_converto_setting_exits(tmp_path, block):
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(f"converto:\n{block}")
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
ConfigManager(str(config_file))
|
|
|
|
assert excinfo.value.code == 3
|
|
|
|
|
|
def _write_emulatorjs_config(tmp_path: Path, emulatorjs_block: str) -> ConfigManager:
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(f"emulatorjs:\n{emulatorjs_block}")
|
|
return ConfigManager(str(config_file))
|
|
|
|
|
|
def test_null_default_cores_block_means_empty(tmp_path):
|
|
loader = _write_emulatorjs_config(tmp_path, " default_cores:\n")
|
|
|
|
assert loader.config.EJS_DEFAULT_CORES == {}
|
|
|
|
|
|
@pytest.mark.parametrize("value", ['""', "5", "~"])
|
|
def test_default_core_must_be_a_non_empty_string(tmp_path, value):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_emulatorjs_config(tmp_path, f" default_cores:\n nds: {value}\n")
|
|
|
|
assert excinfo.value.code == 3
|
|
|
|
|
|
@pytest.fixture
|
|
def critical(mocker):
|
|
"""The last message the config manager logged before exiting."""
|
|
spy = mocker.patch("config.config_manager.log.critical")
|
|
return lambda: str(spy.call_args[0][0])
|
|
|
|
|
|
EXCLUDE_LIST_PATHS = (
|
|
"exclude.platforms",
|
|
"exclude.roms.single_file.extensions",
|
|
"exclude.roms.single_file.names",
|
|
"exclude.roms.multi_file.names",
|
|
"exclude.roms.multi_file.parts.extensions",
|
|
"exclude.roms.multi_file.parts.names",
|
|
)
|
|
|
|
|
|
def _write_exclude_config(tmp_path: Path, path: str, value: str) -> ConfigManager:
|
|
"""Write `value` at the dotted `path`, nesting one YAML level per segment."""
|
|
lines = [f"{' ' * depth}{key}:" for depth, key in enumerate(path.split("."))]
|
|
lines[-1] += f" {value}"
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text("\n".join(lines) + "\n")
|
|
return ConfigManager(str(config_file))
|
|
|
|
|
|
@pytest.mark.parametrize("path", EXCLUDE_LIST_PATHS)
|
|
@pytest.mark.parametrize("value", ["ps2", "5", "{ps2: true}"])
|
|
def test_exclude_list_rejects_a_non_list(tmp_path, critical, path, value):
|
|
"""A bare string would otherwise be split into one exclusion per character."""
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_exclude_config(tmp_path, path, value)
|
|
|
|
assert excinfo.value.code == 3
|
|
assert critical() == f"Invalid config.yml: {path} must be a list"
|
|
|
|
|
|
@pytest.mark.parametrize("path", EXCLUDE_LIST_PATHS)
|
|
@pytest.mark.parametrize("value", ["[7z, 001]", "[ps2, ~]"])
|
|
def test_exclude_list_rejects_a_non_string_entry(tmp_path, critical, path, value):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_exclude_config(tmp_path, path, value)
|
|
|
|
assert excinfo.value.code == 3
|
|
assert critical().startswith(f"Invalid config.yml: {path} must only hold strings")
|
|
|
|
|
|
def test_empty_exclude_list_keeps_the_defaults(tmp_path):
|
|
loader = _write_exclude_config(tmp_path, "exclude.platforms", "")
|
|
|
|
assert loader.config.EXCLUDED_PLATFORMS == sorted(DEFAULT_EXCLUDED_PLATFORM_DIRS)
|
|
|
|
|
|
def _write_filesystem_config(tmp_path: Path, block: str) -> ConfigManager:
|
|
config_file = tmp_path / "config.yml"
|
|
config_file.write_text(f"filesystem:\n{block}")
|
|
return ConfigManager(str(config_file))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "folder", "expected"),
|
|
[
|
|
("roms_folder", "retro_games", 'default: "retro_games/{platform}/{game}"'),
|
|
("firmware_folder", "fw", 'firmware: "fw/{platform}"'),
|
|
],
|
|
)
|
|
def test_retired_folder_keys_exit_with_the_replacement(
|
|
tmp_path, key, folder, expected, critical
|
|
):
|
|
"""Ignoring them would silently relocate the library, so refuse to start and
|
|
name the template that reproduces the layout."""
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_filesystem_config(tmp_path, f" {key}: {folder}\n")
|
|
|
|
assert excinfo.value.code == 3
|
|
assert expected in critical()
|
|
|
|
|
|
def test_an_override_may_not_move_the_platform_folder(tmp_path, critical):
|
|
"""A platform outside the folder platforms are enumerated in would never be
|
|
discovered."""
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_filesystem_config(
|
|
tmp_path,
|
|
" structure:\n"
|
|
' default: "roms/{platform}/{game}"\n'
|
|
' ps3: "disc-games/{platform}/{game}"\n',
|
|
)
|
|
|
|
assert excinfo.value.code == 3
|
|
assert "disc-games" in critical()
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["default", "firmware"])
|
|
def test_a_reserved_structure_key_takes_a_single_template(tmp_path, key):
|
|
"""Only a platform can union several layouts; the library has just one."""
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_filesystem_config(
|
|
tmp_path,
|
|
f' structure:\n {key}:\n - "roms/{{platform}}/{{game}}"\n',
|
|
)
|
|
|
|
assert excinfo.value.code == 3
|
|
|
|
|
|
def test_an_invalid_firmware_template_is_rejected(tmp_path):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_write_filesystem_config(
|
|
tmp_path, ' structure:\n firmware: "bios/{platform}/{game}"\n'
|
|
)
|
|
|
|
assert excinfo.value.code == 3
|
|
|
|
|
|
def test_the_retired_library_layout_check_names_the_template(
|
|
tmp_path, mocker, critical
|
|
):
|
|
"""A `{platform}/roms` library used to be auto-detected; now it has to say so
|
|
rather than scan as empty and mark every rom missing."""
|
|
library = tmp_path / "library"
|
|
(library / "n64" / "roms").mkdir(parents=True)
|
|
mocker.patch("config.config_manager.LIBRARY_BASE_PATH", str(library))
|
|
|
|
loader = _write_filesystem_config(tmp_path, " skip_hash_calculation: false\n")
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
loader.check_library_layout()
|
|
|
|
assert excinfo.value.code == 3
|
|
assert 'default: "{platform}/roms/{game}"' in critical()
|
|
|
|
|
|
def test_the_retired_library_layout_check_passes_once_declared(tmp_path, mocker):
|
|
library = tmp_path / "library"
|
|
(library / "n64" / "roms").mkdir(parents=True)
|
|
mocker.patch("config.config_manager.LIBRARY_BASE_PATH", str(library))
|
|
|
|
loader = _write_filesystem_config(
|
|
tmp_path, ' structure:\n default: "{platform}/roms/{game}"\n'
|
|
)
|
|
loader.check_library_layout()
|
|
|
|
|
|
def test_a_platform_named_like_a_layout_key_uses_the_default(tmp_path):
|
|
"""`structure.firmware` is a firmware folder, not a template for a platform
|
|
whose folder happens to be called `firmware`."""
|
|
loader = _write_filesystem_config(
|
|
tmp_path, ' structure:\n firmware: "bios/{platform}"\n'
|
|
)
|
|
|
|
config = loader.get_config()
|
|
assert config.platform_structure("firmware") == (config.default_structure,)
|