diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 391234f09..c51d3dd8a 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -346,7 +346,7 @@ class FSRomsHandler(FSHandler): rom_ra_h = "" # Check if rom is a multi-part rom - if os.path.isdir(f"{abs_fs_path}/{rom.full_path}"): + if os.path.isdir(f"{abs_fs_path}/{rom.fs_name}"): # Calculate the RA hash if the platform has a slug that matches a known RA slug if rom.platform_slug in RA_PLATFORM_LIST.keys(): rom_ra_h = await RAHasherService().calculate_hash( @@ -354,7 +354,9 @@ class FSRomsHandler(FSHandler): f"{abs_fs_path}/{rom.fs_name}/*", ) - for f_path, file_name in iter_files(f"{abs_fs_path}/{rom}", recursive=True): + for f_path, file_name in iter_files( + f"{abs_fs_path}/{rom.fs_name}", recursive=True + ): # Check if file is excluded ext = self.parse_file_extension(file_name) if not ext or ext in excluded_file_exts: @@ -541,10 +543,9 @@ class FSRomsHandler(FSHandler): rel_roms_path = self.get_roms_fs_structure( platform.fs_slug ) # Relative path to roms - abs_fs_path = f"{self.base_path}/{rel_roms_path}" # Absolute path to roms - fs_single_roms = self.list_files(path=abs_fs_path) - fs_multi_roms = self.list_directories(path=abs_fs_path) + fs_single_roms = self.list_files(path=rel_roms_path) + fs_multi_roms = self.list_directories(path=rel_roms_path) fs_roms: list[dict] = [ {"multi": False, "fs_name": rom} diff --git a/backend/handler/filesystem/tests/test_roms_handler.py b/backend/handler/filesystem/tests/test_roms_handler.py new file mode 100644 index 000000000..6397afef5 --- /dev/null +++ b/backend/handler/filesystem/tests/test_roms_handler.py @@ -0,0 +1,542 @@ +import os +from pathlib import Path +from unittest.mock import Mock + +import pytest +from config.config_manager import LIBRARY_BASE_PATH, Config +from handler.filesystem.roms_handler import FileHash, FSRomsHandler +from models.platform import Platform +from models.rom import Rom, RomFile, RomFileCategory + + +class TestFSRomsHandler: + """Test suite for FSRomsHandler class""" + + @pytest.fixture + def handler(self): + return FSRomsHandler() + + @pytest.fixture + def config(self): + return Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=["tmp"], + EXCLUDED_SINGLE_FILES=["excluded_test.tmp"], + EXCLUDED_MULTI_FILES=["excluded_multi"], + EXCLUDED_MULTI_PARTS_EXT=["tmp"], + EXCLUDED_MULTI_PARTS_FILES=["excluded_part.bin"], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ) + + @pytest.fixture + def platform(self): + return Platform(name="Nintendo 64", slug="n64", fs_slug="n64") + + @pytest.fixture + def rom_single(self, platform: Platform): + return Rom( + id=1, + fs_name="Paper Mario (USA).z64", + fs_path="n64/roms", + platform=platform, + full_path="n64/roms/Paper Mario (USA).z64", + ) + + @pytest.fixture + def rom_multi(self, platform: Platform): + rom = Rom( + id=2, + fs_name="Super Mario 64 (J) (Rev A)", + fs_path="n64/roms", + platform=platform, + ) + rom.multi = True + return rom + + def test_init_uses_library_base_path(self, handler: FSRomsHandler): + """Test that FSRomsHandler initializes with LIBRARY_BASE_PATH""" + assert handler.base_path == Path(LIBRARY_BASE_PATH).resolve() + + def test_get_roms_fs_structure_normal_structure(self, handler: FSRomsHandler): + """Test get_roms_fs_structure with normal structure""" + fs_slug = "n64" + + with pytest.MonkeyPatch.context() as m: + m.setattr( + "handler.filesystem.roms_handler.cm.get_config", + lambda: Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=[], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=[], + EXCLUDED_MULTI_PARTS_EXT=[], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ), + ) + m.setattr("os.path.exists", lambda x: False) # Simulate normal structure + + result = handler.get_roms_fs_structure(fs_slug) + assert result == f"{fs_slug}/roms" + + def test_get_roms_fs_structure_high_priority_structure( + self, handler: FSRomsHandler + ): + """Test get_roms_fs_structure with high priority structure""" + fs_slug = "n64" + + with pytest.MonkeyPatch.context() as m: + m.setattr( + "handler.filesystem.roms_handler.cm.get_config", + lambda: Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=[], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=[], + EXCLUDED_MULTI_PARTS_EXT=[], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ), + ) + m.setattr( + "os.path.exists", lambda x: True + ) # Simulate high priority structure + + result = handler.get_roms_fs_structure(fs_slug) + assert result == f"roms/{fs_slug}" + + def test_parse_tags_regions_and_languages(self, handler: FSRomsHandler): + """Test parse_tags method with regions and languages""" + fs_name = "Zelda (USA) (Rev 1) [En,Fr] [Test].n64" + + regions, revision, languages, other_tags = handler.parse_tags(fs_name) + + assert "USA" in regions + assert revision == "1" + assert "English" in languages + assert "French" in languages + assert "Test" in other_tags + + def test_parse_tags_complex_tags(self, handler: FSRomsHandler): + """Test parse_tags with complex tag structures""" + fs_name = "Game (Europe) (En,De,Fr,Es,It) (Rev A) [Reg-PAL] [Beta].rom" + + regions, revision, languages, other_tags = handler.parse_tags(fs_name) + + assert "Europe" in regions + assert "PAL" in regions + assert revision == "A" + assert "English" in languages + assert "German" in languages + assert "French" in languages + assert "Spanish" in languages + assert "Italian" in languages + assert "Beta" in other_tags + + def test_parse_tags_no_tags(self, handler: FSRomsHandler): + """Test parse_tags with no tags""" + fs_name = "Simple Game.rom" + + regions, revision, languages, other_tags = handler.parse_tags(fs_name) + + assert regions == [] + assert revision == "" + assert languages == [] + assert other_tags == [] + + def test_exclude_multi_roms_filters_excluded(self, handler: FSRomsHandler, config): + """Test _exclude_multi_roms filters out excluded multi-file ROMs""" + roms = ["Game1", "excluded_multi", "Game2", "Game3"] + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + + result = handler._exclude_multi_roms(roms) + expected = ["Game1", "Game2", "Game3"] + + assert result == expected + + def test_exclude_multi_roms_no_exclusions(self, handler: FSRomsHandler): + """Test _exclude_multi_roms with no exclusions""" + roms = ["Game1", "Game2", "Game3"] + config = Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=[], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=[], + EXCLUDED_MULTI_PARTS_EXT=[], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + + result = handler._exclude_multi_roms(roms) + assert result == roms + + def test_build_rom_file_single_file(self, handler: FSRomsHandler): + """Test _build_rom_file with actual single ROM file""" + rom_path = Path("n64/roms") + file_name = "Paper Mario (USA).z64" + file_hash = FileHash( + { + "crc_hash": "ABCD1234", + "md5_hash": "def456", + "sha1_hash": "789ghi", + } + ) + + rom_file = handler._build_rom_file(rom_path, file_name, file_hash) + + assert isinstance(rom_file, RomFile) + assert rom_file.file_name == file_name + assert rom_file.file_path == str(rom_path) + assert rom_file.crc_hash == "ABCD1234" + assert rom_file.md5_hash == "def456" + assert rom_file.sha1_hash == "789ghi" + assert rom_file.file_size_bytes > 0 # Should have actual file size + assert rom_file.last_modified is not None + assert rom_file.category is None # No category matching for this path + + def test_build_rom_file_with_category(self, handler: FSRomsHandler): + """Test _build_rom_file with category detection""" + # Test with DLC category + rom_path = Path("n64/roms/dlc") + file_name = "test_dlc.n64" + file_hash = FileHash( + { + "crc_hash": "12345678", + "md5_hash": "abcdef", + "sha1_hash": "123456", + } + ) + + # Create the test file + os.makedirs(handler.base_path / rom_path, exist_ok=True) + test_file = handler.base_path / rom_path / file_name + test_file.write_text("Test DLC content") + + try: + rom_file = handler._build_rom_file(rom_path, file_name, file_hash) + + assert rom_file.category == RomFileCategory.DLC + assert rom_file.file_name == file_name + assert rom_file.file_size_bytes > 0 + finally: + # Clean up + if test_file.exists(): + test_file.unlink() + + @pytest.mark.asyncio + async def test_get_roms(self, handler: FSRomsHandler, platform, config): + """Test get_roms with actual files in the filesystem""" + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + m.setattr("os.path.exists", lambda x: False) # Normal structure + + result = await handler.get_roms(platform) + + assert isinstance(result, list) + assert len(result) > 0 + + # Check that we have both single and multi ROMs + single_roms = [r for r in result if not r["multi"]] + multi_roms = [r for r in result if r["multi"]] + + assert len(single_roms) > 0 + assert len(multi_roms) > 0 + + # Check specific files exist + rom_names = [r["fs_name"] for r in result] + assert "Paper Mario (USA).z64" in rom_names + assert "Super Mario 64 (J) (Rev A)" in rom_names + assert "Zelda (USA) (Rev 1) [En,Fr] [Test].n64" in rom_names + + # Check excluded files are not present + assert "excluded_test.tmp" not in rom_names + + @pytest.mark.asyncio + async def test_get_rom_files_single_rom( + self, handler: FSRomsHandler, rom_single, config + ): + """Test get_rom_files with a single ROM file""" + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + m.setattr("os.path.exists", lambda x: False) # Normal structure + + rom_files, crc_hash, md5_hash, sha1_hash, ra_hash = ( + await handler.get_rom_files(rom_single) + ) + + assert len(rom_files) == 1 + assert isinstance(rom_files[0], RomFile) + assert rom_files[0].file_name == "Paper Mario (USA).z64" + assert rom_files[0].file_path == "n64/roms" + assert rom_files[0].file_size_bytes > 0 + + assert crc_hash == "efb5af2e" + assert md5_hash == "0f343b0931126a20f133d67c2b018a3b" + assert sha1_hash == "60cacbf3d72e1e7834203da608037b1bf83b40e8" + + @pytest.mark.asyncio + async def test_get_rom_files_multi_rom( + self, handler: FSRomsHandler, rom_multi, config + ): + """Test get_rom_files with a multi-part ROM""" + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + m.setattr("os.path.exists", lambda x: False) # Normal structure + + rom_files, crc_hash, md5_hash, sha1_hash, ra_hash = ( + await handler.get_rom_files(rom_multi) + ) + + assert len(rom_files) >= 2 # Should have multiple parts + + file_names = [rf.file_name for rf in rom_files] + assert "Super Mario 64 (J) (Rev A) [Part 1].z64" in file_names + assert "Super Mario 64 (J) (Rev A) [Part 2].z64" in file_names + + for rom_file in rom_files: + assert isinstance(rom_file, RomFile) + assert rom_file.file_size_bytes > 0 + assert rom_file.last_modified is not None + + def test_rename_fs_rom_same_name(self, handler: FSRomsHandler): + """Test rename_fs_rom when old and new names are the same""" + old_name = "test_rom.n64" + new_name = "test_rom.n64" + fs_path = "n64/roms" + + # Should not raise any exception + handler.rename_fs_rom(old_name, new_name, fs_path) + + def test_rename_fs_rom_different_name_target_exists(self, handler: FSRomsHandler): + """Test rename_fs_rom when target file already exists""" + old_name = "Paper Mario (USA).z64" + new_name = "test_game.n64" # This file exists + fs_path = "n64/roms" + + from exceptions.fs_exceptions import RomAlreadyExistsException + + with pytest.raises(RomAlreadyExistsException): + handler.rename_fs_rom(old_name, new_name, fs_path) + + def test_rename_fs_rom_successful_rename(self, handler: FSRomsHandler): + """Test successful ROM file rename""" + # Create a test file to rename + test_file = handler.base_path / "n64/roms/test_rename.n64" + test_file.write_text("Test ROM content") + + old_name = "test_rename.n64" + new_name = "renamed_rom.n64" + fs_path = "n64/roms" + + try: + handler.rename_fs_rom(old_name, new_name, fs_path) + + # Check that old file is gone and new file exists + old_path = handler.base_path / fs_path / old_name + new_path = handler.base_path / fs_path / new_name + + assert not old_path.exists() + assert new_path.exists() + assert new_path.read_text() == "Test ROM content" + finally: + # Clean up + new_path = handler.base_path / fs_path / new_name + if new_path.exists(): + new_path.unlink() + + def test_integration_with_base_handler_methods(self, handler: FSRomsHandler): + """Test that FSRomsHandler properly inherits from FSHandler""" + # Test that handler has base methods + assert hasattr(handler, "validate_path") + assert hasattr(handler, "list_files") + assert hasattr(handler, "list_directories") + assert hasattr(handler, "file_exists") + assert hasattr(handler, "move_file") + assert hasattr(handler, "stream_file") + assert hasattr(handler, "exclude_single_files") + + def test_exclude_single_files_integration(self, handler: FSRomsHandler, config): + """Test that exclude_single_files works with actual ROM files""" + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + + # Get all files in the ROM directory + all_files = handler.list_files(path="n64/roms") + + # Should include .tmp files before exclusion + assert "excluded_test.tmp" in all_files + assert "Paper Mario (USA).z64" in all_files + + # After exclusion, .tmp files should be removed + filtered_files = handler.exclude_single_files(all_files) + assert "excluded_test.tmp" not in filtered_files + assert "Paper Mario (USA).z64" in filtered_files + + def test_file_operations_with_actual_structure(self, handler: FSRomsHandler): + """Test that file operations work with the actual ROM directory structure""" + # Test that we can list files + n64_files = handler.list_files("n64/roms") + assert len(n64_files) > 0 + + n64_dirs = handler.list_directories("n64/roms") + assert len(n64_dirs) > 0 + + # Test that we can check file existence + assert handler.file_exists("n64/roms/Paper Mario (USA).z64") + assert handler.file_exists("n64/roms/test_game.n64") + assert not handler.file_exists("n64/roms/nonexistent.rom") + + def test_stream_file_with_actual_roms(self, handler: FSRomsHandler): + """Test streaming actual ROM files""" + with handler.stream_file("n64/roms/Paper Mario (USA).z64") as f: + content = f.read() + assert len(content) > 0 + + with handler.stream_file("n64/roms/test_game.n64") as f: + content = f.read() + assert len(content) > 0 + assert b"Test N64 ROM" in content + + def test_tag_parsing_edge_cases(self, handler: FSRomsHandler): + """Test tag parsing with edge cases""" + # Test with comma-separated tags + regions, revision, languages, other_tags = handler.parse_tags( + "Game (USA,Europe) [En,Fr,De].rom" + ) + assert "USA" in regions + assert "Europe" in regions + assert "English" in languages + assert "French" in languages + assert "German" in languages + + # Test with reg- prefix + regions, revision, languages, other_tags = handler.parse_tags( + "Game [Reg-NTSC].rom" + ) + assert "NTSC" in regions + + # Test with rev- prefix + regions, revision, languages, other_tags = handler.parse_tags( + "Game [Rev-B].rom" + ) + assert revision == "B" + + def test_platform_specific_behavior(self, handler: FSRomsHandler): + """Test platform-specific behavior differences""" + # Create mock platforms - one hashable, one non-hashable + hashable_platform = Mock(spec=Platform) + hashable_platform.fs_slug = "gba" + hashable_platform.slug = "gameboy-advance" + + non_hashable_platform = Mock(spec=Platform) + non_hashable_platform.fs_slug = "n64" + non_hashable_platform.slug = "nintendo-64" + + # Test ROM file structure paths + hashable_path = handler.get_roms_fs_structure(hashable_platform.fs_slug) + non_hashable_path = handler.get_roms_fs_structure(non_hashable_platform.fs_slug) + + with pytest.MonkeyPatch.context() as m: + m.setattr("os.path.exists", lambda x: False) # Normal structure + + assert hashable_path == f"{hashable_platform.fs_slug}/roms" + assert non_hashable_path == f"{non_hashable_platform.fs_slug}/roms" + + def test_multi_rom_directory_handling(self, handler: FSRomsHandler, config): + """Test handling of multi-ROM directories with actual structure""" + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + + # List directories in the ROM path + directories = handler.list_directories("n64/roms") + + # Should include our multi-ROM directories + assert "Super Mario 64 (J) (Rev A)" in directories + assert "Test Multi Rom [USA]" in directories + + # After exclusion, normal directories should remain + filtered_dirs = handler._exclude_multi_roms(directories) + assert "Super Mario 64 (J) (Rev A)" in filtered_dirs + assert "Test Multi Rom [USA]" in filtered_dirs + + def test_rom_fs_structure_consistency(self, handler: FSRomsHandler): + """Test that ROM filesystem structure is consistent across methods""" + fs_slug = "gba" + + with pytest.MonkeyPatch.context() as m: + # Test with normal structure + m.setattr("os.path.exists", lambda x: False) + + structure = handler.get_roms_fs_structure(fs_slug) + assert structure == f"{fs_slug}/roms" + + # Test with high priority structure + m.setattr("os.path.exists", lambda x: True) + + structure = handler.get_roms_fs_structure(fs_slug) + assert structure == f"roms/{fs_slug}" + + def test_actual_file_hash_calculation(self, handler: FSRomsHandler): + """Test hash calculation with actual files""" + # Create a test file with known content for hash verification + test_content = b"Test ROM content for hashing" + test_file = handler.base_path / "n64/roms/hash_test.n64" + test_file.write_bytes(test_content) + + try: + # Calculate expected hashes + import binascii + import hashlib + + expected_crc = binascii.crc32(test_content) + expected_md5 = hashlib.md5(test_content, usedforsecurity=False).hexdigest() + expected_sha1 = hashlib.sha1( + test_content, usedforsecurity=False + ).hexdigest() + + # Test the hash calculation method + crc_result, _, md5_result, _, sha1_result, _ = ( + handler._calculate_rom_hashes( + test_file, + 0, + hashlib.md5(usedforsecurity=False), + hashlib.sha1(usedforsecurity=False), + ) + ) + + assert crc_result == expected_crc + assert md5_result.hexdigest() == expected_md5 + assert sha1_result.hexdigest() == expected_sha1 + + finally: + # Clean up + if test_file.exists(): + test_file.unlink() + + def test_compressed_file_handling(self, handler: FSRomsHandler): + """Test handling of compressed ROM files""" + # Test with the ZIP file + psx_files = handler.list_files("psx/roms") + assert "PaRappa the Rapper.zip" in psx_files + + # Verify we can stream the compressed file + with handler.stream_file("psx/roms/PaRappa the Rapper.zip") as f: + content = f.read() + assert len(content) > 0 diff --git a/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part1.bin b/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part1.bin new file mode 100644 index 000000000..d5c8b1b21 --- /dev/null +++ b/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part1.bin @@ -0,0 +1 @@ +Multi part 1 diff --git a/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part2.bin b/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part2.bin new file mode 100644 index 000000000..9f12e497b --- /dev/null +++ b/backend/romm_test/library/n64/roms/Test Multi Rom [USA]/part2.bin @@ -0,0 +1 @@ +Multi part 2 diff --git a/backend/romm_test/library/n64/roms/Zelda (USA) (Rev 1) [En,Fr] [Test].n64 b/backend/romm_test/library/n64/roms/Zelda (USA) (Rev 1) [En,Fr] [Test].n64 new file mode 100644 index 000000000..1096f4b55 --- /dev/null +++ b/backend/romm_test/library/n64/roms/Zelda (USA) (Rev 1) [En,Fr] [Test].n64 @@ -0,0 +1 @@ +Test N64 ROM with tags diff --git a/backend/romm_test/library/n64/roms/excluded_test.tmp b/backend/romm_test/library/n64/roms/excluded_test.tmp new file mode 100644 index 000000000..ace510d0b --- /dev/null +++ b/backend/romm_test/library/n64/roms/excluded_test.tmp @@ -0,0 +1 @@ +Test excluded file diff --git a/backend/romm_test/library/n64/roms/test_game.n64 b/backend/romm_test/library/n64/roms/test_game.n64 new file mode 100644 index 000000000..79d76e0d4 --- /dev/null +++ b/backend/romm_test/library/n64/roms/test_game.n64 @@ -0,0 +1 @@ +Test N64 ROM