""" Tests for WP-10: refractor_card_state initialization on pack opening. Covers `app/services/refractor_init.py` — the `initialize_card_refractor` function that creates an RefractorCardState row when a card is first acquired. Test strategy: - Unit tests for `_determine_card_type` cover all three branches (batter, SP, RP/CP) using plain objects so no database round-trip is needed. - Integration tests run against the in-memory SQLite database (conftest.py autouse fixture) and exercise the full get_or_create path. Why we test idempotency: Pack-opening can post duplicate cards (e.g. the same player ID appears in two separate pack insertions). The get_or_create guarantee means the second call must be a no-op — it must not reset current_tier/current_value of a card that has already started evolving. Why we test cross-player isolation: Two different players with the same team must each get their own RefractorCardState row. A bug that checked only team_id would share state across players, so we assert that state.player_id matches. """ import pytest from app.db_engine import ( Cardset, RefractorCardState, RefractorTrack, Player, ) from app.services.refractor_init import _determine_card_type, initialize_card_refractor # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _FakePlayer: """Minimal stand-in for a Player instance used in unit tests. We only need pos_1 for card-type determination; real FK fields are not required by the pure function under test. """ def __init__(self, pos_1: str): self.pos_1 = pos_1 def _make_player(rarity, pos_1: str) -> Player: """Create a minimal Player row with the given pos_1 value. A fresh Cardset is created per call so that players are independent of each other and can be iterated over in separate test cases without FK conflicts. """ cardset = Cardset.create( name=f"Set-{pos_1}-{id(pos_1)}", description="Test", total_cards=1, ) return Player.create( p_name=f"Player {pos_1}", rarity=rarity, cardset=cardset, set_num=1, pos_1=pos_1, image="https://example.com/img.png", mlbclub="TST", franchise="TST", description="test", ) def _make_track(card_type: str) -> RefractorTrack: """Create an RefractorTrack for the given card_type. Thresholds are kept small and arbitrary; the unit under test only cares about card_type when selecting the track. """ return RefractorTrack.create( name=f"Track-{card_type}", card_type=card_type, formula="pa", t1_threshold=10, t2_threshold=40, t3_threshold=120, t4_threshold=240, ) # --------------------------------------------------------------------------- # Unit tests — _determine_card_type (no DB required) # --------------------------------------------------------------------------- class TestDetermineCardType: """Unit tests for _determine_card_type, the pure position-to-type mapper. The function receives a Player (or any object with a pos_1 attribute) and returns one of the three strings 'batter', 'sp', or 'rp'. These unit tests use _FakePlayer so no database is touched and failures are fast. """ def test_starting_pitcher(self): """pos_1 == 'SP' maps to card_type 'sp'. SP is the canonical starting-pitcher position string stored in Player.pos_1 by the card-creation pipeline. """ assert _determine_card_type(_FakePlayer("SP")) == "sp" def test_relief_pitcher(self): """pos_1 == 'RP' maps to card_type 'rp'. Relief pitchers carry the 'RP' position flag and must follow a separate refractor track with lower thresholds. """ assert _determine_card_type(_FakePlayer("RP")) == "rp" def test_closer_pitcher(self): """pos_1 == 'CP' maps to card_type 'rp'. Closers share the RP refractor track; the spec explicitly lists 'CP' as an rp-track position. """ assert _determine_card_type(_FakePlayer("CP")) == "rp" def test_infielder_is_batter(self): """pos_1 == '1B' maps to card_type 'batter'. Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should fall through to the batter track. """ assert _determine_card_type(_FakePlayer("1B")) == "batter" def test_catcher_is_batter(self): """pos_1 == 'C' maps to card_type 'batter'.""" assert _determine_card_type(_FakePlayer("C")) == "batter" def test_dh_is_batter(self): """pos_1 == 'DH' maps to card_type 'batter'. Designated hitters have no defensive rating but accumulate batting stats, so they belong on the batter track. """ assert _determine_card_type(_FakePlayer("DH")) == "batter" def test_outfielder_is_batter(self): """pos_1 == 'CF' maps to card_type 'batter'.""" assert _determine_card_type(_FakePlayer("CF")) == "batter" # --------------------------------------------------------------------------- # Integration tests — initialize_card_refractor # --------------------------------------------------------------------------- class TestDetermineCardTypeEdgeCases: """T2-2: Parametrized edge cases for _determine_card_type. Covers all the boundary inputs identified in the PO review: DH, C, 2B (batters), empty string, None, and the compound 'SP/RP' which contains both 'SP' and 'RP' substrings. The function checks 'SP' before 'RP'/'CP', so 'SP/RP' resolves to 'sp'. """ @pytest.mark.parametrize( "pos_1, expected", [ # Plain batter positions ("DH", "batter"), ("C", "batter"), ("2B", "batter"), # Empty / None — fall through to batter default ("", "batter"), (None, "batter"), # Compound string containing 'SP' first — must resolve to 'sp' # because _determine_card_type checks "SP" in pos.upper() before RP/CP ("SP/RP", "sp"), ], ) def test_position_mapping(self, pos_1, expected): """_determine_card_type maps each pos_1 value to the expected card_type. What: Directly exercises _determine_card_type with the given pos_1 string. None is handled by the `(player.pos_1 or "").upper()` guard in the implementation, so it falls through to 'batter'. Why: The card_type string is the key used to look up a RefractorTrack. An incorrect mapping silently assigns the wrong thresholds to a player's entire refractor journey. Parametrized so each edge case is a distinct, independently reported test failure. """ player = _FakePlayer(pos_1) assert _determine_card_type(player) == expected, ( f"pos_1={pos_1!r}: expected {expected!r}, " f"got {_determine_card_type(player)!r}" ) class TestInitializeCardEvolution: """Integration tests for initialize_card_refractor against in-memory SQLite. Each test relies on the conftest autouse fixture to get a clean database. We create tracks for all three card types so the function can always find a matching track regardless of which player position is used. """ @pytest.fixture(autouse=True) def seed_tracks(self): """Create one RefractorTrack per card_type before each test. initialize_card_refractor does a DB lookup for a track matching the card_type. If no track exists the function must not crash (it should log and return None), but having tracks present lets us verify the happy path for all three types without repeating setup in every test. """ self.batter_track = _make_track("batter") self.sp_track = _make_track("sp") self.rp_track = _make_track("rp") def test_first_card_creates_state(self, rarity, team): """First acquisition creates an RefractorCardState with zeroed values. Acceptance criteria from WP-10: - current_tier == 0 - current_value == 0.0 - fully_evolved == False - track matches the player's card_type (batter here) """ player = _make_player(rarity, "2B") state = initialize_card_refractor(player.player_id, team.id, "batter") assert state is not None assert state.player_id == player.player_id assert state.team_id == team.id assert state.track_id == self.batter_track.id assert state.current_tier == 0 assert state.current_value == 0.0 assert state.fully_evolved is False def test_duplicate_card_skips_creation(self, rarity, team): """Second call for the same (player_id, team_id) is a no-op. The get_or_create guarantee: if a state row already exists it must not be overwritten. This protects cards that have already started evolving — their current_tier and current_value must be preserved. """ player = _make_player(rarity, "SS") # First call creates the state state1 = initialize_card_refractor(player.player_id, team.id, "batter") assert state1 is not None # Simulate partial evolution progress state1.current_tier = 2 state1.current_value = 250.0 state1.save() # Second call (duplicate card) must not reset progress state2 = initialize_card_refractor(player.player_id, team.id, "batter") assert state2 is not None # Exactly one row in the database count = ( RefractorCardState.select() .where( RefractorCardState.player == player, RefractorCardState.team == team, ) .count() ) assert count == 1 # Progress was NOT reset refreshed = RefractorCardState.get_by_id(state1.id) assert refreshed.current_tier == 2 assert refreshed.current_value == 250.0 def test_different_player_creates_new_state(self, rarity, team): """Two different players on the same team each get their own state row. Cross-player isolation: the (player_id, team_id) uniqueness means player A and player B must have separate rows even though team_id is the same. """ player_a = _make_player(rarity, "LF") player_b = _make_player(rarity, "RF") state_a = initialize_card_refractor(player_a.player_id, team.id, "batter") state_b = initialize_card_refractor(player_b.player_id, team.id, "batter") assert state_a is not None assert state_b is not None assert state_a.id != state_b.id assert state_a.player_id == player_a.player_id assert state_b.player_id == player_b.player_id def test_sp_card_gets_sp_track(self, rarity, team): """A starting pitcher is assigned the 'sp' RefractorTrack. Track selection is driven by card_type, which in turn comes from pos_1. This test passes card_type='sp' explicitly (mirroring the router hook that calls _determine_card_type first) and confirms the state links to the sp track, not the batter track. """ player = _make_player(rarity, "SP") state = initialize_card_refractor(player.player_id, team.id, "sp") assert state is not None assert state.track_id == self.sp_track.id def test_rp_card_gets_rp_track(self, rarity, team): """A relief pitcher (RP or CP) is assigned the 'rp' RefractorTrack.""" player = _make_player(rarity, "RP") state = initialize_card_refractor(player.player_id, team.id, "rp") assert state is not None assert state.track_id == self.rp_track.id def test_missing_track_returns_none(self, rarity, team): """If no track exists for the card_type, the function returns None. This is the safe-failure path: the function must not raise an exception if the evolution system is misconfigured (e.g. track seed data missing). It logs the problem and returns None so that the caller (the cards router) can proceed with pack opening unaffected. We use a fictional card_type that has no matching seed row. """ player = _make_player(rarity, "SP") # Delete the sp track to simulate missing seed data self.sp_track.delete_instance() result = initialize_card_refractor(player.player_id, team.id, "sp") assert result is None def test_card_type_from_pos1_batter(self, rarity, team): """_determine_card_type is wired correctly for a batter position. End-to-end: pass the player object directly and verify the state ends up on the batter track based solely on pos_1. """ player = _make_player(rarity, "3B") card_type = _determine_card_type(player) state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.batter_track.id def test_card_type_from_pos1_sp(self, rarity, team): """_determine_card_type is wired correctly for a starting pitcher.""" player = _make_player(rarity, "SP") card_type = _determine_card_type(player) state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.sp_track.id def test_card_type_from_pos1_rp(self, rarity, team): """_determine_card_type correctly routes CP to the rp track.""" player = _make_player(rarity, "CP") card_type = _determine_card_type(player) state = initialize_card_refractor(player.player_id, team.id, card_type) assert state is not None assert state.track_id == self.rp_track.id