import pytest from unittest.mock import MagicMock, patch, AsyncMock from src.classes.gathering.auction import Auction from src.classes.item import Item from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary from src.classes.prices import prices from src.utils.config import CONFIG # Monkeypatch removed as Weapon/Auxiliary now have __hash__ implemented @pytest.mark.asyncio async def test_auction_is_start(base_world, mock_item_data): auction = Auction() weapon = mock_item_data["obj_weapon"] # 初始状态,sold_item_count 为 0 # 清空 circulation base_world.circulation.sold_weapons = [] base_world.circulation.sold_auxiliaries = [] base_world.circulation.sold_elixirs = [] # 设置阈值 CONFIG.game.gathering.auction_trigger_count = 5 assert auction.is_start(base_world) is False # 增加物品数量达到阈值 for _ in range(5): base_world.circulation.add_weapon(weapon) assert auction.is_start(base_world) is True def test_calculate_bid(dummy_avatar, mock_item_data): auction = Auction() item = mock_item_data["obj_weapon"] base_price = prices.get_price(item) # Case 1: 需求低 (<=1) -> 出价 0 assert auction._calculate_bid(item, 1, 1000) == 0 # Case 2: 需求 2 (捡漏 0.8) expected_price = int(base_price * 0.8) assert auction._calculate_bid(item, 2, 100000) == expected_price # Case 3: 余额不足 -> 出价 = 余额 avatar_money = 10 bid = auction._calculate_bid(item, 3, avatar_money) # need 3 is 1.5x, definitely > 10 assert bid == avatar_money # Case 4: 需求 5 (梭哈) -> 出价 = 余额 assert auction._calculate_bid(item, 5, 5000) == 5000 def test_resolve_auctions_basic(dummy_avatar, mock_item_data): """测试基本的竞价结算逻辑(单物品)""" auction = Auction() item = mock_item_data["obj_weapon"] avatar1 = dummy_avatar avatar1.magic_stone = 1000 avatar1.name = "A1" # 创建第二个角色 avatar2 = MagicMock() avatar2.magic_stone = 1000 avatar2.name = "A2" avatar2.__hash__ = MagicMock(return_value=123) # Make it hashable for dict keys # 模拟需求字典 needs = { item: { avatar1: 4, # High need avatar2: 2 # Low need } } # Mock prices with patch("src.classes.prices.prices.get_price", return_value=100): deal_results, unsold, willing = auction.resolve_auctions(needs) # 验证结果 assert item in deal_results winner, price = deal_results[item] # A1 出价: 100 * 3.0 = 300 # A2 出价: 100 * 0.8 = 80 # 成交价应为第二高价(80) + 1 = 81 assert winner == avatar1 assert price == 81 assert not unsold def test_resolve_auctions_asset_protection(dummy_avatar, mock_item_data): """测试资产穿透保护:同一个角色竞拍多个物品""" auction = Auction() item1 = mock_item_data["obj_weapon"] # 贵 item2 = mock_item_data["obj_material"] # 便宜 avatar = dummy_avatar avatar.magic_stone = 100 # 总共只有 100 needs = { item1: {avatar: 5}, # 梭哈 item1 item2: {avatar: 5} # 梭哈 item2 } # Mock prices: item1=80, item2=50 # item1 应该先结算(价值高),因为是梭哈(need=5),出价100。 # 如果只有一人竞拍,成交价 = max(1, 100 * 0.6) = 60。 # 剩余余额 = 100 - 60 = 40。 # item2 结算时,余额只有 40,虽然 need=5,但出价只能是 40。 # item2 成交价 = max(1, 40 * 0.6) = 24。 def get_price_side_effect(item): if item == item1: return 80 return 50 with patch("src.classes.prices.prices.get_price", side_effect=get_price_side_effect): deal_results, unsold, willing = auction.resolve_auctions(needs) # 验证 item1 assert item1 in deal_results winner1, price1 = deal_results[item1] assert winner1 == avatar assert price1 == 60 # 100 * 0.6 # 验证 item2 assert item2 in deal_results winner2, price2 = deal_results[item2] assert winner2 == avatar # 此时余额只剩 40,出价 40,成交价 40 * 0.6 = 24 assert price2 == 24 # 总花费 84 <= 100,保护成功 assert price1 + price2 <= 100 def test_resolve_auctions_unsold(mock_item_data): """测试流拍""" auction = Auction() item = mock_item_data["obj_weapon"] # 空需求或者需求都很低导致不出价 needs = { item: {} } with patch("src.classes.prices.prices.get_price", return_value=100): deal_results, unsold, willing = auction.resolve_auctions(needs) assert item not in deal_results assert item in unsold @pytest.mark.asyncio async def test_execute_flow(base_world, dummy_avatar, mock_item_data): """测试完整的 execute 流程,包括物品交易和销毁""" auction = Auction() item_sold = mock_item_data["obj_weapon"] item_unsold = mock_item_data["obj_auxiliary"] # 使用 Auxiliary 代替 Material # 设置环境 # 将物品加入 circulation 以便测试移除逻辑 base_world.circulation.sold_weapons = [item_sold] base_world.circulation.sold_auxiliaries = [item_unsold] # 设置 Avatar dummy_avatar.magic_stone = 1000 dummy_avatar.weapon = None # 确保没有武器 dummy_avatar.auxiliary = None # 确保 avatar 在 avatar_manager 中 base_world.avatar_manager.avatars[dummy_avatar.id] = dummy_avatar # Mock methods # 1. get_related_avatars auction.get_related_avatars = MagicMock(return_value=[dummy_avatar.id]) # 2. get_needs (Async) -> 让 item_sold 有人买,item_unsold 没人买 async def mock_get_needs(*args, **kwargs): return { item_sold: {dummy_avatar: 4}, # High need item_unsold: {dummy_avatar: 1} # No need } auction.get_needs = mock_get_needs # 3. Mock StoryTeller to avoid LLM with patch("src.classes.story_teller.StoryTeller.tell_gathering_story", new_callable=AsyncMock) as mock_story: mock_story.return_value = "拍卖会故事..." # 4. Mock prices with patch("src.classes.prices.prices.get_price", return_value=100): events = await auction.execute(base_world) # 验证结果 # 1. 物品去向 # item_sold 应该被 dummy_avatar 装备 assert dummy_avatar.weapon == item_sold # item_sold 应该不在 circulation 中 assert item_sold not in base_world.circulation.sold_weapons # item_unsold 应该被销毁 (不在 circulation 中,也不在 avatar 背包/装备 中) assert item_unsold not in base_world.circulation.sold_auxiliaries assert dummy_avatar.auxiliary != item_unsold # 2. 资金扣除 # Base price 100, Need 4 (3.0x) -> Bid 300 # Single bidder -> Deal 300 * 0.6 = 180 # Balance 1000 - 180 = 820 assert dummy_avatar.magic_stone == 820 # 3. 事件生成 assert len(events) > 0 # 应该包含 story event assert any(e.content == "拍卖会故事..." for e in events) def test_items_are_hashable(): """测试物品类是否可哈希(用作字典键)""" from src.classes.weapon import Weapon from src.classes.weapon_type import WeaponType from src.classes.auxiliary import Auxiliary from src.classes.elixir import Elixir, ElixirType from src.classes.cultivation import Realm # Weapon w = Weapon( id=1, name="TestSword", weapon_type=WeaponType.SWORD, realm=Realm.Qi_Refinement, desc="Test", special_data={"a": 1} # mutable field ) s = set() s.add(w) assert w in s d = {w: 1} assert d[w] == 1 # Auxiliary a = Auxiliary( id=2, name="TestAux", realm=Realm.Qi_Refinement, desc="Test", special_data={"b": 2} # mutable field ) s = set() s.add(a) assert a in s # Elixir e = Elixir( id=3, name="TestElixir", realm=Realm.Qi_Refinement, type=ElixirType.Heal, desc="Test", price=100 ) s = set() s.add(e) assert e in s def test_resolve_auctions_tie_breaking(dummy_avatar, mock_item_data): """测试出价相同时的判定(稳定性)""" auction = Auction() item = mock_item_data["obj_weapon"] # 两个角色,需求相同,资金充足 -> 理论上出价相同 avatar1 = dummy_avatar avatar1.magic_stone = 1000 avatar1.name = "A1" avatar2 = MagicMock() avatar2.magic_stone = 1000 avatar2.name = "A2" avatar2.__hash__ = MagicMock(return_value=12345) # 手动构建 needs 字典,控制 key 的顺序 # 情况1: A1 在前 needs1 = { item: {avatar1: 5, avatar2: 5} } with patch("src.classes.prices.prices.get_price", return_value=100): deal_results1, _, _ = auction.resolve_auctions(needs1) winner1, _ = deal_results1[item] # 如果是稳定排序,且 bid 相等,应该保持顺序,winner 是 A1 assert winner1 == avatar1 # 情况2: A2 在前 needs2 = { item: {avatar2: 5, avatar1: 5} } with patch("src.classes.prices.prices.get_price", return_value=100): deal_results2, _, _ = auction.resolve_auctions(needs2) winner2, _ = deal_results2[item] # winner 应该是 A2 assert winner2 == avatar2 def test_resolve_auctions_no_refund_consideration(dummy_avatar, mock_item_data): """测试拍卖结算时不考虑后续装备出售的退款(防止透支)""" auction = Auction() item1 = mock_item_data["obj_weapon"] # 贵, 先结算 item2 = mock_item_data["obj_elixir"] # 便宜, 后结算 avatar = dummy_avatar avatar.magic_stone = 100 # 假设 avatar 身上有装备,卖出可得 50 old_weapon = MagicMock() avatar.weapon = old_weapon # 但 resolve_auctions 只看 snapshot,不看装备退款 needs = { item1: {avatar: 5}, # 梭哈 item1, cost 100 item2: {avatar: 5} # 梭哈 item2 } # Mock prices: item1=80, item2=50 # item1 price 80, need 5 -> bid 100 (balance). Deal: 100*0.6 = 60. # item2 price 50. Remaining balance 100-60=40. # need 5 -> bid 40 (balance). Deal: 40*0.6 = 24. # If refund (50) was considered, balance would be 40+50=90. # Bid 90 -> Deal 54. def get_price_side_effect(item): if item == item1: return 80 return 50 with patch("src.classes.prices.prices.get_price", side_effect=get_price_side_effect): deal_results, _, _ = auction.resolve_auctions(needs) # item1 应该成交,消耗 60 (100 * 0.6) assert deal_results[item1][0] == avatar assert deal_results[item1][1] == 60 # item2 应该成交,消耗 24 (40 * 0.6) # 证明使用了 40 的余额,而不是 90 (如果包含退款) assert item2 in deal_results assert deal_results[item2][1] == 24 # 总消耗 84 <= 100 assert deal_results[item1][1] + deal_results[item2][1] <= 100 @pytest.mark.asyncio async def test_execute_item_types(base_world, dummy_avatar, mock_item_data): """测试不同类型物品的执行逻辑 (Elixir)""" auction = Auction() elixir = mock_item_data["obj_elixir"] dummy_avatar.magic_stone = 1000 base_world.circulation.sold_elixirs = [elixir] # Register avatar base_world.avatar_manager.avatars[dummy_avatar.id] = dummy_avatar # Mock resolve_auctions auction.resolve_auctions = MagicMock(return_value=( {elixir: (dummy_avatar, 100)}, [], {} )) # Mock dependencies auction.get_related_avatars = MagicMock(return_value=[dummy_avatar.id]) auction.get_needs = AsyncMock(return_value={}) # ignored by mocked resolve auction._generate_deal_events = MagicMock(return_value=[]) auction._generate_rivalry_events = MagicMock(return_value=[]) auction._generate_story = AsyncMock(return_value=[]) # Mock circulation remove base_world.circulation.remove_item = MagicMock() # Mock consume_elixir dummy_avatar.consume_elixir = MagicMock() # Ensure items are "in" circulation logic (count > 0) # Circulation.sold_item_count is a property, depends on lists. # We set sold_elixirs above, so it should be > 0. await auction.execute(base_world) # Verify consume_elixir called dummy_avatar.consume_elixir.assert_called_once_with(elixir) # Verify remove_item called base_world.circulation.remove_item.assert_called_once_with(elixir) @pytest.mark.asyncio async def test_get_needs_parsing(base_world, dummy_avatar, mock_item_data): """测试 get_needs 的 LLM 结果解析逻辑""" auction = Auction() item = mock_item_data["obj_weapon"] # Mock circulation base_world.circulation.sold_weapons = [item] # Mock LLM response mock_response = { dummy_avatar.name: { str(item.id): 5 # High need } } with patch("src.classes.gathering.auction.call_llm_with_template", new_callable=AsyncMock) as mock_llm: mock_llm.return_value = mock_response needs = await auction.get_needs(base_world, [dummy_avatar]) assert item in needs assert needs[item][dummy_avatar] == 5 # Test filtering of low needs (<=1) mock_response_low = { dummy_avatar.name: { str(item.id): 1 } } with patch("src.classes.gathering.auction.call_llm_with_template", new_callable=AsyncMock) as mock_llm: mock_llm.return_value = mock_response_low needs = await auction.get_needs(base_world, [dummy_avatar]) # Should be empty because score 1 is filtered assert item not in needs or not needs.get(item)