diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a3ea643 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Test configuration - add project root to sys.path.""" +import sys +from pathlib import Path + +# Add project root so `danding_bot` can be imported +project_root = Path(__file__).parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..63c5308 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,158 @@ +"""Test models.py dataclasses - direct import bypassing __init__.py. + +Uses importlib.util to load models.py directly, avoiding nonebot dependency. +""" +import pytest +import importlib.util +import sys +from pathlib import Path +from datetime import datetime + +# Load models.py directly without triggering __init__.py +project_root = Path(__file__).parent.parent +models_path = project_root / "danding_bot" / "plugins" / "group_horse_racing" / "models.py" + +spec = importlib.util.spec_from_file_location("models", models_path) +models = importlib.util.module_from_spec(spec) +spec.loader.exec_module(models) + +RoomState = models.RoomState +HorseState = models.HorseState +Horse = models.Horse +Bet = models.Bet +Room = models.Room +RaceResult = models.RaceResult + + +class TestEnums: + def test_room_states(self): + assert RoomState.WAITING.value == "waiting" + assert RoomState.RUNNING.value == "running" + assert RoomState.FINISHED.value == "finished" + assert RoomState.INTERRUPTED.value == "interrupted" + + def test_horse_states(self): + assert HorseState.READY.value == "ready" + assert HorseState.RACING.value == "racing" + assert HorseState.FINISHED.value == "finished" + + def test_room_state_string_enum(self): + """RoomState is str enum, should work in string comparisons""" + assert RoomState.WAITING == "waiting" + + def test_horse_state_string_enum(self): + assert HorseState.READY == "ready" + + +class TestHorseDataclass: + def test_default_construction(self): + h = Horse(owner_id="u1", name="闪电") + assert h.owner_id == "u1" + assert h.name == "闪电" + assert h.position == 0.0 + assert h.state == HorseState.READY + assert h.index == 0 + + def test_custom_values(self): + h = Horse(owner_id="u2", name="旋风", index=3, position=5.5, state=HorseState.RACING) + assert h.index == 3 + assert h.position == 5.5 + assert h.state == HorseState.RACING + + def test_finished_state(self): + h = Horse(owner_id="u3", name="飞龙", state=HorseState.FINISHED, position=100.0) + assert h.state == HorseState.FINISHED + + +class TestBetDataclass: + def test_construction(self): + b = Bet(user_id="u1", horse_name="闪电", amount=100) + assert b.user_id == "u1" + assert b.horse_name == "闪电" + assert b.amount == 100 + + def test_zero_bet(self): + b = Bet(user_id="u2", horse_name="旋风", amount=0) + assert b.amount == 0 + + def test_negative_bet_boundary(self): + """Negative bet shouldn't happen but dataclass allows it""" + b = Bet(user_id="u3", horse_name="x", amount=-50) + assert b.amount == -50 + + +class TestRoomDataclass: + def test_default_room(self): + r = Room(scope="test_group") + assert r.scope == "test_group" + assert r.state == RoomState.WAITING + assert r.horses == {} + assert r.bets == [] + assert r.champion_name is None + assert r.tick_count == 0 + assert r.next_horse_index == 1 + assert isinstance(r.created_at, datetime) + + def test_room_with_horses(self): + r = Room( + scope="g1", + horses={"闪电": Horse(owner_id="u1", name="闪电")} + ) + assert "闪电" in r.horses + assert len(r.horses) == 1 + + def test_room_state_transitions(self): + r = Room(scope="g1") + assert r.state == RoomState.WAITING + r.state = RoomState.RUNNING + assert r.state == RoomState.RUNNING + r.state = RoomState.FINISHED + assert r.state == RoomState.FINISHED + + def test_independent_rooms_have_independent_horses(self): + """Verify dict default_factory creates independent instances""" + r1 = Room(scope="g1") + r2 = Room(scope="g2") + r1.horses["test"] = Horse(owner_id="u1", name="test") + assert "test" not in r2.horses + + def test_independent_rooms_have_independent_bets(self): + r1 = Room(scope="g1") + r2 = Room(scope="g2") + r1.bets.append(Bet(user_id="u1", horse_name="x", amount=10)) + assert len(r2.bets) == 0 + + +class TestRaceResultDataclass: + def test_basic_construction(self): + rr = RaceResult( + race_id="race1", + scope="g1", + champion_name="闪电", + champion_owner="u1", + participants=["u1", "u2"], + bet_distribution={"u1": 100, "u2": 50}, + duration_ticks=30, + completed_at=datetime.now(), + ) + assert rr.race_id == "race1" + assert rr.champion_name == "闪电" + assert len(rr.participants) == 2 + assert rr.point_changes == {} + assert rr.point_change_summaries == {} + + def test_with_point_changes(self): + rr = RaceResult( + race_id="race2", + scope="g1", + champion_name="旋风", + champion_owner="u2", + participants=["u1", "u2"], + bet_distribution={"u1": 100}, + duration_ticks=25, + completed_at=datetime.now(), + point_changes={"u1": -100, "u2": 200}, + point_change_summaries={"u1": "-100", "u2": "+200"}, + ) + assert rr.point_changes["u2"] == 200 + assert rr.point_change_summaries["u1"] == "-100" diff --git a/tests/test_payout_logic.py b/tests/test_payout_logic.py new file mode 100644 index 0000000..c159309 --- /dev/null +++ b/tests/test_payout_logic.py @@ -0,0 +1,58 @@ +"""Test payout calculation logic - pure function tests per verify_sop. + +Tests the formula: payout = max(1, round(amount * odds)) +These are pure logic tests with no mocking needed. +""" +import pytest + + +def payout(amount: int, odds: float) -> int: + """Extracted payout formula from points_service.py:56""" + return max(1, round(amount * odds)) + + +class TestPayoutCalculation: + """Core payout formula: max(1, round(amount * odds))""" + + def test_basic_payout(self): + assert payout(100, 2.0) == 200 + + def test_fractional_odds(self): + assert payout(100, 1.5) == 150 + + def test_small_amount_rounds_up(self): + # 10 * 0.06 = 0.6 → round = 1 + assert payout(10, 0.06) == 1 + + def test_small_amount_rounds_down_still_1(self): + # 1 * 0.4 = 0.4 → round = 0 → max(1, 0) = 1 + assert payout(1, 0.4) == 1 + + def test_zero_odds_gives_minimum_1(self): + """Even with 0 odds, payout is at least 1""" + assert payout(1000, 0.0) == 1 + + def test_zero_amount_gives_minimum_1(self): + """Even with 0 amount, payout is at least 1""" + assert payout(0, 5.0) == 1 + + def test_both_zero_gives_1(self): + assert payout(0, 0.0) == 1 + + def test_high_odds(self): + assert payout(10, 100.0) == 1000 + + def test_large_amount(self): + assert payout(100000, 1.5) == 150000 + + def test_negative_odds_boundary(self): + """If negative odds somehow pass through, result is max(1, negative)""" + assert payout(100, -1.0) == 1 + + def test_round_half_even(self): + # Python bankers rounding: round(0.5) = 0, so max(1, 0) = 1 + assert payout(1, 0.5) == 1 + + def test_round_1_5(self): + # round(1*1.5) = round(1.5) = 2 + assert payout(1, 1.5) == 2 diff --git a/tests/test_room_store_lock.py b/tests/test_room_store_lock.py new file mode 100644 index 0000000..17bc093 --- /dev/null +++ b/tests/test_room_store_lock.py @@ -0,0 +1,79 @@ +"""Test RoomStore._get_lock behavior - the setdefault fix. + +Per verify_sop: tests must run, with adversarial probing. +This tests the get_lock concurrency fix (setdefault vs if-check). +""" +import asyncio +import pytest + + +class FakeRoomStore: + """Minimal reproduction of RoomStore._get_lock for testing the lock pattern.""" + def __init__(self): + self._locks: dict[str, asyncio.Lock] = {} + + def get_lock(self, scope: str) -> asyncio.Lock: + """The fixed pattern: setdefault (atomic)""" + return self._locks.setdefault(scope, asyncio.Lock()) + + def get_lock_old_buggy(self, scope: str) -> asyncio.Lock: + """The old buggy pattern: check-then-set (race condition)""" + if scope not in self._locks: + self._locks[scope] = asyncio.Lock() # NOT thread-safe + return self._locks[scope] + + +class TestGetLock: + def test_same_scope_returns_same_lock(self): + store = FakeRoomStore() + lock1 = store.get_lock("scope1") + lock2 = store.get_lock("scope1") + assert lock1 is lock2 + + def test_different_scopes_different_locks(self): + store = FakeRoomStore() + lock1 = store.get_lock("scope1") + lock2 = store.get_lock("scope2") + assert lock1 is not lock2 + + def test_lock_initially_not_locked(self): + store = FakeRoomStore() + lock = store.get_lock("scope1") + assert not lock.locked() + + @pytest.mark.asyncio + async def test_concurrent_get_lock_same_scope(self): + """Adversarial: concurrent calls to get_lock for same scope must return same lock.""" + store = FakeRoomStore() + results = [] + + async def grab_lock(scope): + lock = store.get_lock(scope) + results.append(id(lock)) + + tasks = [grab_lock("shared") for _ in range(100)] + await asyncio.gather(*tasks) + + # ALL must be the same lock object + assert len(set(results)) == 1, f"Got {len(set(results))} different lock objects!" + + @pytest.mark.asyncio + async def test_lock_prevents_concurrent_execution(self): + """Verify the lock actually serializes access.""" + store = FakeRoomStore() + execution_log = [] + + async def protected_operation(scope, op_id): + lock = store.get_lock(scope) + async with lock: + execution_log.append(f"start_{op_id}") + await asyncio.sleep(0.01) + execution_log.append(f"end_{op_id}") + + await asyncio.gather( + protected_operation("scope", "A"), + protected_operation("scope", "B"), + ) + + # Operations must be serialized: start_A, end_A, start_B, end_B + assert execution_log == ["start_A", "end_A", "start_B", "end_B"]