From a8666950b1a631c07163182fe9a15ab1f79e39d9 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Mon, 19 Jan 2026 23:09:38 -0800 Subject: [PATCH] test: add comprehensive tests for mutual actions (talk, spar, impart) (#73) - Talk: 31% -> 98% coverage - Spar: 49% -> 100% coverage - Impart: 37% -> 95% coverage - Add 30 new test cases - Overall coverage: 58.64% -> 60% (meets CI threshold) Testing strategy: Mock LLM calls via call_llm_with_task_name Closes #69 --- tests/test_mutual_actions.py | 614 +++++++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 tests/test_mutual_actions.py diff --git a/tests/test_mutual_actions.py b/tests/test_mutual_actions.py new file mode 100644 index 0000000..8908808 --- /dev/null +++ b/tests/test_mutual_actions.py @@ -0,0 +1,614 @@ +""" +Tests for src/classes/mutual_action/ modules. + +This module tests mutual action classes including: +- Talk: initiate conversation +- Spar: friendly combat for weapon proficiency +- Impart: master teaching disciple + +Testing Strategy: + We mock `call_llm_with_task_name` to simulate LLM feedback responses. + This allows testing the action logic without actual LLM calls. +""" + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + +from src.classes.mutual_action.talk import Talk +from src.classes.mutual_action.spar import Spar +from src.classes.mutual_action.impart import Impart +from src.classes.action_runtime import ActionStatus +from src.classes.relation import Relation + + +class TestTalk: + """Tests for Talk mutual action.""" + + @pytest.fixture + def target_avatar(self, base_world, dummy_avatar): + """Create a target avatar for talk tests.""" + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm + from src.classes.calendar import Year, Month, create_month_stamp + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.utils.id_generator import get_avatar_id + + target = Avatar( + world=base_world, + name="TalkTarget", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(25, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, # Same position as dummy_avatar + pos_y=0, + root=Root.WATER, + personas=[], + alignment=Alignment.NEUTRAL + ) + target.weapon = MagicMock() + target.weapon.get_detailed_info.return_value = "Test Weapon" + target.thinking = "" + target.short_term_objective = "" + # Register in world + base_world.avatar_manager.avatars[target.name] = target + return target + + def test_talk_can_start_success(self, dummy_avatar, target_avatar): + """Test that Talk can start when target is in observation range.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + with patch("src.classes.observe.is_within_observation", return_value=True): + can_start, reason = action.can_start(target_avatar) + + assert can_start is True + assert reason == "" + + def test_talk_cannot_start_target_not_in_range(self, dummy_avatar, target_avatar): + """Test that Talk cannot start when target is out of range.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + with patch("src.classes.observe.is_within_observation", return_value=False): + can_start, reason = action.can_start(target_avatar) + + assert can_start is False + assert "不在交互范围内" in reason + + def test_talk_cannot_start_self_target(self, dummy_avatar): + """Test that Talk cannot target self.""" + action = Talk(dummy_avatar, dummy_avatar.world) + can_start, reason = action.can_start(dummy_avatar) + + assert can_start is False + assert "自己" in reason + + def test_talk_cannot_start_target_not_exist(self, dummy_avatar): + """Test that Talk cannot start with non-existent target.""" + action = Talk(dummy_avatar, dummy_avatar.world) + can_start, reason = action.can_start("NonExistentAvatar") + + assert can_start is False + assert "不存在" in reason + + def test_talk_start_returns_event(self, dummy_avatar, target_avatar): + """Test that Talk.start() returns proper event.""" + action = Talk(dummy_avatar, dummy_avatar.world) + event = action.start(target_avatar) + + assert event is not None + assert dummy_avatar.name in event.content + assert target_avatar.name in event.content + assert "攀谈" in event.content + assert dummy_avatar.id in event.related_avatars + assert target_avatar.id in event.related_avatars + + @pytest.mark.asyncio + async def test_talk_step_with_accept_feedback(self, dummy_avatar, target_avatar): + """Test Talk step flow when target accepts.""" + action = Talk(dummy_avatar, dummy_avatar.world) + action._start_month_stamp = 100 + + # Mock target's methods for loading action chain + target_avatar.load_decide_result_chain = MagicMock() + target_avatar.commit_next_plan = MagicMock(return_value=None) + + mock_response = { + target_avatar.name: { + "thinking": "This person seems friendly.", + "feedback": "Talk" + } + } + + with patch("src.classes.observe.is_within_observation", return_value=True): + with patch("src.classes.mutual_action.mutual_action.call_llm_with_task_name", new_callable=AsyncMock) as mock_llm: + mock_llm.return_value = mock_response + + # First step: trigger LLM task + res1 = action.step(target_avatar) + assert res1.status == ActionStatus.RUNNING + assert action._feedback_task is not None + + # Wait for task + await action._feedback_task + + # Second step: consume result + res2 = action.step(target_avatar) + assert res2.status == ActionStatus.COMPLETED + + # Should have accept event + assert len(res2.events) >= 1 + assert "接受" in res2.events[0].content + + @pytest.mark.asyncio + async def test_talk_step_with_reject_feedback(self, dummy_avatar, target_avatar): + """Test Talk step flow when target rejects.""" + action = Talk(dummy_avatar, dummy_avatar.world) + action._start_month_stamp = 100 + + mock_response = { + target_avatar.name: { + "thinking": "I don't want to talk.", + "feedback": "Reject" + } + } + + with patch("src.classes.observe.is_within_observation", return_value=True): + with patch("src.classes.mutual_action.mutual_action.call_llm_with_task_name", new_callable=AsyncMock) as mock_llm: + mock_llm.return_value = mock_response + + res1 = action.step(target_avatar) + assert action._feedback_task is not None + await action._feedback_task + res2 = action.step(target_avatar) + + assert res2.status == ActionStatus.COMPLETED + assert len(res2.events) >= 1 + assert "拒绝" in res2.events[0].content + + def test_talk_step_with_none_target(self, dummy_avatar): + """Test Talk step with None target returns FAILED.""" + action = Talk(dummy_avatar, dummy_avatar.world) + res = action.step(None) # type: ignore[arg-type] - intentionally testing None input + + assert res.status == ActionStatus.FAILED + + +class TestSpar: + """Tests for Spar mutual action.""" + + @pytest.fixture + def target_avatar(self, base_world, dummy_avatar): + """Create a target avatar for spar tests.""" + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm + from src.classes.calendar import Year, Month, create_month_stamp + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.utils.id_generator import get_avatar_id + + target = Avatar( + world=base_world, + name="SparTarget", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(25, Realm.Qi_Refinement), + gender=Gender.MALE, + pos_x=0, + pos_y=0, + root=Root.FIRE, + personas=[], + alignment=Alignment.NEUTRAL + ) + target.weapon = MagicMock() + target.weapon.get_detailed_info.return_value = "Test Sword" + target.weapon_proficiency = 50.0 + target.thinking = "" + target.short_term_objective = "" + # Mock methods for spar + target.increase_weapon_proficiency = MagicMock() + target.add_event = MagicMock() + base_world.avatar_manager.avatars[target.name] = target + return target + + def test_spar_has_cooldown(self): + """Test that Spar has cooldown configured.""" + assert Spar.ACTION_CD_MONTHS == 12 + + def test_spar_start_returns_event(self, dummy_avatar, target_avatar): + """Test that Spar.start() returns proper event.""" + action = Spar(dummy_avatar, dummy_avatar.world) + event = action.start(target_avatar) + + assert event is not None + assert dummy_avatar.name in event.content + assert target_avatar.name in event.content + assert "切磋" in event.content + + def test_spar_settle_feedback_accept(self, dummy_avatar, target_avatar): + """Test that accepting spar increases weapon proficiency.""" + action = Spar(dummy_avatar, dummy_avatar.world) + dummy_avatar.increase_weapon_proficiency = MagicMock() + dummy_avatar.add_event = MagicMock() + + # Mock battle result + with patch("src.classes.mutual_action.spar.decide_battle") as mock_battle: + mock_battle.return_value = (dummy_avatar, target_avatar, 0, 0) + + action._settle_feedback(target_avatar, "Accept") + + # Both should have proficiency increased + dummy_avatar.increase_weapon_proficiency.assert_called_once() + target_avatar.increase_weapon_proficiency.assert_called_once() + + # Winner (dummy_avatar) should get more + winner_gain = dummy_avatar.increase_weapon_proficiency.call_args[0][0] + loser_gain = target_avatar.increase_weapon_proficiency.call_args[0][0] + assert winner_gain > loser_gain + + def test_spar_settle_feedback_reject(self, dummy_avatar, target_avatar): + """Test that rejecting spar does nothing.""" + action = Spar(dummy_avatar, dummy_avatar.world) + dummy_avatar.increase_weapon_proficiency = MagicMock() + + action._settle_feedback(target_avatar, "Reject") + + # Should not increase proficiency + dummy_avatar.increase_weapon_proficiency.assert_not_called() + target_avatar.increase_weapon_proficiency.assert_not_called() + + @pytest.mark.asyncio + async def test_spar_finish_generates_story(self, dummy_avatar, target_avatar): + """Test that Spar.finish() generates a story event. + + Note: cooldown_action decorator wraps finish() to use **kwargs, + so we must call with keyword arguments. + """ + action = Spar(dummy_avatar, dummy_avatar.world) + action._last_result = (dummy_avatar, target_avatar, 15.0, 5.0) + + with patch("src.classes.mutual_action.spar.StoryTeller.tell_story", new_callable=AsyncMock) as mock_story: + mock_story.return_value = "A great sparring match occurred." + + # cooldown_action wraps finish to accept **kwargs + result = action.finish(target_avatar=target_avatar) + events = await result # The wrapper returns coroutine + + assert len(events) == 1 + assert events[0].is_story is True + assert "great sparring match" in events[0].content + + @pytest.mark.asyncio + async def test_spar_finish_without_result(self, dummy_avatar, target_avatar): + """Test that Spar.finish() returns empty list without result.""" + action = Spar(dummy_avatar, dummy_avatar.world) + # No _last_result set + + # cooldown_action wraps finish to accept **kwargs + result = action.finish(target_avatar=target_avatar) + events = await result + + assert events == [] + + +class TestImpart: + """Tests for Impart mutual action.""" + + @pytest.fixture + def master_avatar(self, base_world): + """Create a master avatar (high level). + + Note: Avatar's cultivation_progress defaults to level 0. + We must manually set level to ensure level diff >= 20 for Impart. + """ + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm, CultivationProgress + from src.classes.calendar import Year, Month, create_month_stamp + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.utils.id_generator import get_avatar_id + + master = Avatar( + world=base_world, + name="MasterAvatar", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(1900), Month.JANUARY), + age=Age(100, Realm.Nascent_Soul), + gender=Gender.MALE, + pos_x=0, + pos_y=0, + root=Root.GOLD, + personas=[], + alignment=Alignment.RIGHTEOUS + ) + # Set high cultivation level (Nascent Soul = 91+) + master.cultivation_progress = CultivationProgress(level=95, exp=0) + master.weapon = MagicMock() + master.weapon.get_detailed_info.return_value = "Master Sword" + master.thinking = "" + master.short_term_objective = "" + base_world.avatar_manager.avatars[master.name] = master + return master + + @pytest.fixture + def disciple_avatar(self, base_world): + """Create a disciple avatar (low level).""" + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm, CultivationProgress + from src.classes.calendar import Year, Month, create_month_stamp + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.utils.id_generator import get_avatar_id + + disciple = Avatar( + world=base_world, + name="DiscipleAvatar", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, + pos_y=0, + root=Root.WATER, + personas=[], + alignment=Alignment.RIGHTEOUS + ) + # Set low cultivation level (Qi Refinement = 1-30) + disciple.cultivation_progress = CultivationProgress(level=10, exp=0) + disciple.weapon = MagicMock() + disciple.weapon.get_detailed_info.return_value = "Disciple Sword" + disciple.thinking = "" + disciple.short_term_objective = "" + disciple.add_event = MagicMock() + base_world.avatar_manager.avatars[disciple.name] = disciple + return disciple + + def test_impart_has_cooldown(self): + """Test that Impart has cooldown configured.""" + assert Impart.ACTION_CD_MONTHS == 6 + + def test_impart_can_start_success(self, master_avatar, disciple_avatar): + """Test Impart can start with valid master-disciple relation.""" + action = Impart(master_avatar, master_avatar.world) + + # Mock relation check: master has MASTER relation to disciple + master_avatar.get_relation = MagicMock(return_value=Relation.MASTER) + + with patch("src.classes.observe.is_within_observation", return_value=True): + can_start, reason = action.can_start(target_avatar=disciple_avatar) + + assert can_start is True + assert reason == "" + + def test_impart_cannot_start_not_disciple(self, master_avatar, disciple_avatar): + """Test Impart cannot start when target is not disciple.""" + action = Impart(master_avatar, master_avatar.world) + + # Mock relation check: not master-disciple + master_avatar.get_relation = MagicMock(return_value=Relation.FRIEND) + + with patch("src.classes.observe.is_within_observation", return_value=True): + can_start, reason = action.can_start(target_avatar=disciple_avatar) + + assert can_start is False + assert "徒弟" in reason + + def test_impart_cannot_start_level_diff_too_small(self, master_avatar, disciple_avatar): + """Test Impart cannot start when level difference < 20.""" + action = Impart(master_avatar, master_avatar.world) + + # Mock relation check + master_avatar.get_relation = MagicMock(return_value=Relation.MASTER) + + # Set levels close together + master_avatar.cultivation_progress.level = 25 + disciple_avatar.cultivation_progress.level = 10 # Diff = 15 < 20 + + with patch("src.classes.observe.is_within_observation", return_value=True): + can_start, reason = action.can_start(target_avatar=disciple_avatar) + + assert can_start is False + assert "等级差不足20级" in reason + + def test_impart_cannot_start_target_not_in_range(self, master_avatar, disciple_avatar): + """Test Impart cannot start when target out of range.""" + action = Impart(master_avatar, master_avatar.world) + + master_avatar.get_relation = MagicMock(return_value=Relation.MASTER) + + with patch("src.classes.observe.is_within_observation", return_value=False): + can_start, reason = action.can_start(target_avatar=disciple_avatar) + + assert can_start is False + assert "不在交互范围内" in reason + + def test_impart_start_returns_event(self, master_avatar, disciple_avatar): + """Test that Impart.start() returns proper event.""" + action = Impart(master_avatar, master_avatar.world) + event = action.start(disciple_avatar) + + assert event is not None + assert master_avatar.name in event.content + assert disciple_avatar.name in event.content + assert "传道" in event.content + assert hasattr(action, '_impart_success') + assert action._impart_success is False + + def test_impart_settle_feedback_accept(self, master_avatar, disciple_avatar): + """Test that accepting impart gives exp to disciple.""" + action = Impart(master_avatar, master_avatar.world) + action._impart_success = False + action._impart_exp_gain = 0 + + initial_exp = disciple_avatar.cultivation_progress.exp + + action._settle_feedback(disciple_avatar, "Accept") + + assert action._impart_success is True + assert action._impart_exp_gain == 2000 # 100 * 5 * 4 + # Exp should be added to disciple + assert disciple_avatar.cultivation_progress.exp > initial_exp + + def test_impart_settle_feedback_reject(self, master_avatar, disciple_avatar): + """Test that rejecting impart does not give exp.""" + action = Impart(master_avatar, master_avatar.world) + action._impart_success = False + action._impart_exp_gain = 0 + + initial_exp = disciple_avatar.cultivation_progress.exp + + action._settle_feedback(disciple_avatar, "Reject") + + assert action._impart_success is False + # Exp should not change + assert disciple_avatar.cultivation_progress.exp == initial_exp + + @pytest.mark.asyncio + async def test_impart_finish_with_success(self, master_avatar, disciple_avatar): + """Test Impart.finish() generates result event on success. + + Note: cooldown_action decorator wraps finish() to use **kwargs. + """ + action = Impart(master_avatar, master_avatar.world) + action._impart_success = True + action._impart_exp_gain = 2000 + + # cooldown_action wraps finish to accept **kwargs + result = action.finish(target_avatar=disciple_avatar) + events = await result + + assert len(events) == 1 + assert "2000" in events[0].content + assert disciple_avatar.name in events[0].content + + @pytest.mark.asyncio + async def test_impart_finish_with_failure(self, master_avatar, disciple_avatar): + """Test Impart.finish() returns empty on rejection.""" + action = Impart(master_avatar, master_avatar.world) + action._impart_success = False + action._impart_exp_gain = 0 + + # cooldown_action wraps finish to accept **kwargs + result = action.finish(target_avatar=disciple_avatar) + events = await result + + assert events == [] + + +class TestMutualActionBase: + """Tests for MutualAction base class.""" + + @pytest.fixture + def target_avatar(self, base_world, dummy_avatar): + """Create a generic target avatar.""" + from src.classes.avatar import Avatar, Gender + from src.classes.age import Age + from src.classes.cultivation import Realm + from src.classes.calendar import Year, Month, create_month_stamp + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.utils.id_generator import get_avatar_id + + target = Avatar( + world=base_world, + name="GenericTarget", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(2000), Month.JANUARY), + age=Age(25, Realm.Qi_Refinement), + gender=Gender.FEMALE, + pos_x=0, + pos_y=0, + root=Root.EARTH, + personas=[], + alignment=Alignment.NEUTRAL + ) + target.weapon = MagicMock() + target.weapon.get_detailed_info.return_value = "Test Weapon" + target.thinking = "" + target.is_dead = False + base_world.avatar_manager.avatars[target.name] = target + return target + + def test_cannot_start_with_dead_target(self, dummy_avatar, target_avatar): + """Test that mutual action cannot start with dead target.""" + action = Talk(dummy_avatar, dummy_avatar.world) + target_avatar.is_dead = True + + can_start, reason = action.can_start(target_avatar) + + assert can_start is False + assert "死亡" in reason + + def test_get_target_avatar_by_name(self, dummy_avatar, target_avatar): + """Test _get_target_avatar with string name.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + result = action._get_target_avatar("GenericTarget") + + assert result == target_avatar + + def test_get_target_avatar_by_object(self, dummy_avatar, target_avatar): + """Test _get_target_avatar with Avatar object.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + result = action._get_target_avatar(target_avatar) + + assert result == target_avatar + + def test_get_target_avatar_not_found(self, dummy_avatar): + """Test _get_target_avatar returns None for non-existent name.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + result = action._get_target_avatar("NonExistent") + + assert result is None + + @pytest.mark.asyncio + async def test_step_running_then_completed(self, dummy_avatar, target_avatar): + """Test that step returns RUNNING first, then COMPLETED.""" + action = Talk(dummy_avatar, dummy_avatar.world) + action._start_month_stamp = 100 + + mock_response = { + target_avatar.name: { + "thinking": "Test thinking", + "feedback": "Reject" + } + } + + with patch("src.classes.observe.is_within_observation", return_value=True): + with patch("src.classes.mutual_action.mutual_action.call_llm_with_task_name", new_callable=AsyncMock) as mock_llm: + mock_llm.return_value = mock_response + + # First call should be RUNNING + res1 = action.step(target_avatar) + assert res1.status == ActionStatus.RUNNING + assert action._feedback_task is not None + + # Wait for task + await action._feedback_task + + # Second call should be COMPLETED + res2 = action.step(target_avatar) + assert res2.status == ActionStatus.COMPLETED + assert action._feedback_task is None + assert action._feedback_cached is None + + def test_build_prompt_infos(self, dummy_avatar, target_avatar): + """Test _build_prompt_infos returns correct structure.""" + action = Talk(dummy_avatar, dummy_avatar.world) + + infos = action._build_prompt_infos(target_avatar) + + assert "world_info" in infos + assert "avatar_infos" in infos + assert "avatar_name_1" in infos + assert "avatar_name_2" in infos + assert "action_name" in infos + assert "feedback_actions" in infos + assert infos["avatar_name_1"] == dummy_avatar.name + assert infos["avatar_name_2"] == target_avatar.name