@@ -705,3 +705,177 @@ async def test_serial_at_filename_start_resolves_title(self):
705705 mock_hget .assert_awaited_once_with (PS1_SERIAL_INDEX_KEY , "SCUS-94163" )
706706 assert result .get ("name" ) == "Gran Turismo"
707707 assert result ["igdb_id" ] is None
708+
709+
710+ class TestIsPrefixSupersetMatch :
711+ """Unit tests for the prefix/superset title heuristic (issue #3805)."""
712+
713+ @pytest .mark .parametrize (
714+ ("search_term" , "candidate" , "expected" ),
715+ [
716+ # A more specific variant's search term extends the base title.
717+ (
718+ "metal gear solid portable ops plus" ,
719+ "Metal Gear Solid: Portable Ops" ,
720+ True ,
721+ ),
722+ # Reversed: the base term is a prefix of the variant candidate.
723+ (
724+ "Metal Gear Solid: Portable Ops" ,
725+ "Metal Gear Solid: Portable Ops Plus" ,
726+ True ,
727+ ),
728+ ("pokemon ranger shadows of almia" , "Pokemon Ranger" , True ),
729+ # Extra word is not a trailing suffix, so not a prefix relationship.
730+ ("sonic hedgehog" , "Sonic the Hedgehog" , False ),
731+ # Identical titles are an exact match, not a prefix ambiguity.
732+ ("Metal Gear Solid" , "metal gear solid" , False ),
733+ # Unrelated titles.
734+ ("contra" , "Probotector" , False ),
735+ ],
736+ )
737+ def test_prefix_superset_detection (self , search_term , candidate , expected ):
738+ handler = IGDBHandler ()
739+ assert handler ._is_prefix_superset_match (search_term , candidate ) is expected
740+
741+
742+ class TestSearchRomPrefixSupersetVariant :
743+ """A base title that is a prefix of the searched variant must not be
744+ accepted when the more specific variant exists (issue #3805).
745+
746+ 'Metal Gear Solid - Portable Ops Plus' and 'Portable Ops' (and the Pokemon
747+ Ranger series) scored the same IGDB id because the first search pass only
748+ returned the base game and its ~0.99 Jaro-Winkler score cleared the match
749+ threshold.
750+ """
751+
752+ BASE_ID = 1001
753+ VARIANT_ID = 1002
754+ BASE_NAME = "Metal Gear Solid: Portable Ops"
755+ VARIANT_NAME = "Metal Gear Solid: Portable Ops Plus"
756+
757+ @pytest .mark .asyncio
758+ async def test_variant_excluded_by_game_type_is_recovered (self ):
759+ """When the game_type filter hides the variant (IGDB classifies it as an
760+ expansion), dropping the filter must surface it so the exact match wins
761+ over the base near-miss."""
762+ handler = IGDBHandler ()
763+
764+ base = _make_game (self .BASE_ID , self .BASE_NAME )
765+ variant = _make_game (self .VARIANT_ID , self .VARIANT_NAME )
766+
767+ async def mock_list_games (
768+ search_term = None , fields = None , where = None , limit = None
769+ ):
770+ # game_type-filtered pass excludes the variant (an expansion type).
771+ if where and "game_type" in where :
772+ return [base ]
773+ # Re-query without the game_type filter surfaces both.
774+ return [base , variant ]
775+
776+ with (
777+ patch (
778+ "handler.metadata.igdb_handler.IGDBHandler.is_enabled" ,
779+ return_value = True ,
780+ ),
781+ patch .object (
782+ handler .igdb_service ,
783+ "list_games" ,
784+ side_effect = mock_list_games ,
785+ ),
786+ patch .object (
787+ handler .igdb_service ,
788+ "search" ,
789+ new_callable = AsyncMock ,
790+ return_value = [],
791+ ),
792+ ):
793+ result = await handler ._search_rom (
794+ "metal gear solid portable ops plus" ,
795+ GENESIS_IGDB_ID ,
796+ with_game_type = True ,
797+ )
798+
799+ assert result is not None
800+ assert result ["id" ] == self .VARIANT_ID , (
801+ f"Expected the '{ self .VARIANT_NAME } ' variant (id={ self .VARIANT_ID } ), "
802+ f"got { result .get ('name' )} (id={ result .get ('id' )} ). The base title is "
803+ "only a prefix near-miss and must not win over the exact variant."
804+ )
805+
806+ @pytest .mark .asyncio
807+ async def test_base_title_still_matches_when_it_is_the_target (self ):
808+ """Scanning the base game itself must return the base on the first pass
809+ without any widening (exact match short-circuits)."""
810+ handler = IGDBHandler ()
811+
812+ base = _make_game (self .BASE_ID , self .BASE_NAME )
813+ variant = _make_game (self .VARIANT_ID , self .VARIANT_NAME )
814+
815+ search_mock = AsyncMock (return_value = [])
816+
817+ async def mock_list_games (
818+ search_term = None , fields = None , where = None , limit = None
819+ ):
820+ # First pass includes both; the base is an exact match.
821+ if where and "game_type" in where :
822+ return [base , variant ]
823+ raise AssertionError ("widening should not run for an exact match" )
824+
825+ with (
826+ patch (
827+ "handler.metadata.igdb_handler.IGDBHandler.is_enabled" ,
828+ return_value = True ,
829+ ),
830+ patch .object (
831+ handler .igdb_service ,
832+ "list_games" ,
833+ side_effect = mock_list_games ,
834+ ),
835+ patch .object (handler .igdb_service , "search" , search_mock ),
836+ ):
837+ result = await handler ._search_rom (
838+ "metal gear solid portable ops" ,
839+ GENESIS_IGDB_ID ,
840+ with_game_type = True ,
841+ )
842+
843+ assert result is not None
844+ assert result ["id" ] == self .BASE_ID
845+ search_mock .assert_not_awaited ()
846+
847+ @pytest .mark .asyncio
848+ async def test_non_prefix_fuzzy_match_does_not_widen (self ):
849+ """A benign non-exact match that is not a prefix/superset must be
850+ returned from the first pass without extra queries."""
851+ handler = IGDBHandler ()
852+
853+ game = _make_game (42 , "Sonic the Hedgehog" )
854+ search_mock = AsyncMock (return_value = [])
855+
856+ async def mock_list_games (
857+ search_term = None , fields = None , where = None , limit = None
858+ ):
859+ if where and "game_type" in where :
860+ return [game ]
861+ raise AssertionError ("widening should not run for a non-prefix match" )
862+
863+ with (
864+ patch (
865+ "handler.metadata.igdb_handler.IGDBHandler.is_enabled" ,
866+ return_value = True ,
867+ ),
868+ patch .object (
869+ handler .igdb_service ,
870+ "list_games" ,
871+ side_effect = mock_list_games ,
872+ ),
873+ patch .object (handler .igdb_service , "search" , search_mock ),
874+ ):
875+ result = await handler ._search_rom (
876+ "sonic hedgehog" , GENESIS_IGDB_ID , with_game_type = True
877+ )
878+
879+ assert result is not None
880+ assert result ["id" ] == 42
881+ search_mock .assert_not_awaited ()
0 commit comments