From aaa636a08e01a8114fc990b25be81886a0c66121 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Sun, 25 Jan 2026 02:44:13 -0800 Subject: [PATCH] fix: use str() instead of .value for realm i18n display (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use str() instead of .value for realm i18n display Fix bug where user-facing messages displayed raw enum values like "FOUNDATION_ESTABLISHMENT" instead of translated names like "筑基". The Realm and Stage classes already have __str__ methods that return i18n translated text, but several places were incorrectly using .value which returns the raw enum string. Changed files: - src/classes/single_choice.py: item exchange messages - src/classes/kill_and_grab.py: loot messages - src/classes/fortune.py: fortune discovery messages - src/classes/avatar/inventory_mixin.py: purchase error messages Also added unit tests and integration tests to prevent regression. * test: add integration tests for all modified files Add tests covering: - kill_and_grab.py: context string realm display - fortune.py: weapon/auxiliary intro realm display - inventory_mixin.py: can_buy_item error message realm display --- src/classes/avatar/inventory_mixin.py | 3 +- src/classes/fortune.py | 8 +- src/classes/kill_and_grab.py | 3 +- src/classes/single_choice.py | 3 +- tests/test_cultivation_logic.py | 26 +++ tests/test_i18n_realm_display.py | 273 ++++++++++++++++++++++++++ tests/test_single_choice.py | 3 +- 7 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 tests/test_i18n_realm_display.py diff --git a/src/classes/avatar/inventory_mixin.py b/src/classes/avatar/inventory_mixin.py index 94aa221..c0c0563 100644 --- a/src/classes/avatar/inventory_mixin.py +++ b/src/classes/avatar/inventory_mixin.py @@ -190,7 +190,8 @@ class InventoryMixin: if isinstance(obj, Elixir): # 境界限制 if obj.realm > self.cultivation_progress.realm: - return False, f"境界不足,无法承受药力 ({obj.realm.value})" + # 使用 str() 来触发 Realm 的 __str__ 方法进行 i18n 翻译。 + return False, f"境界不足,无法承受药力 ({str(obj.realm)})" # 耐药性/生效中检查 for consumed in self.elixirs: diff --git a/src/classes/fortune.py b/src/classes/fortune.py index 1c594ba..f41b45c 100644 --- a/src/classes/fortune.py +++ b/src/classes/fortune.py @@ -412,8 +412,9 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: theme = _pick_theme(kind) else: from src.i18n import t - intro = t("You discovered a {realm} weapon『{weapon_name}』in your fortune.", - realm=weapon.realm.value, weapon_name=weapon.name) + # 使用 str() 来触发 Realm 的 __str__ 方法进行 i18n 翻译。 + intro = t("You discovered a {realm} weapon『{weapon_name}』in your fortune.", + realm=str(weapon.realm), weapon_name=weapon.name) if avatar.weapon: intro += t(" But you already have『{weapon_name}』.", weapon_name=avatar.weapon.name) @@ -435,8 +436,9 @@ async def try_trigger_fortune(avatar: Avatar) -> list[Event]: theme = _pick_theme(kind) else: from src.i18n import t + # 使用 str() 来触发 Realm 的 __str__ 方法进行 i18n 翻译。 intro = t("You discovered a {realm} auxiliary『{auxiliary_name}』in your fortune.", - realm=auxiliary.realm.value, auxiliary_name=auxiliary.name) + realm=str(auxiliary.realm), auxiliary_name=auxiliary.name) if avatar.auxiliary: intro += t(" But you already have『{auxiliary_name}』.", auxiliary_name=avatar.auxiliary.name) diff --git a/src/classes/kill_and_grab.py b/src/classes/kill_and_grab.py index 471c316..da3a942 100644 --- a/src/classes/kill_and_grab.py +++ b/src/classes/kill_and_grab.py @@ -41,7 +41,8 @@ async def kill_and_grab(winner: Avatar, loser: Avatar) -> str: # 判定是否夺取 item_label = '兵器' if loot_type == 'weapon' else '辅助装备' - context = f"战斗胜利,{loser.name} 身死道消,留下了一件{loot_item.realm.value}{item_label}『{loot_item.name}』。" + # 使用 str() 来触发 Realm 的 __str__ 方法进行 i18n 翻译。 + context = f"战斗胜利,{loser.name} 身死道消,留下了一件{str(loot_item.realm)}{item_label}『{loot_item.name}』。" swapped, log_text = await handle_item_exchange( avatar=winner, diff --git a/src/classes/single_choice.py b/src/classes/single_choice.py index 846bec1..6f8acc9 100644 --- a/src/classes/single_choice.py +++ b/src/classes/single_choice.py @@ -153,7 +153,8 @@ async def handle_item_exchange( current_item = ops["get_current"]() new_name = new_item.name - new_grade = getattr(new_item, "realm", getattr(new_item, "grade", None)).value + # 使用 str() 来触发 Realm/Stage 的 __str__ 方法进行 i18n 翻译。 + new_grade = str(getattr(new_item, "realm", getattr(new_item, "grade", None))) # 1. 自动装备:当前无装备且不强制考虑卖新 if current_item is None and not can_sell_new: diff --git a/tests/test_cultivation_logic.py b/tests/test_cultivation_logic.py index 67eae09..702da40 100644 --- a/tests/test_cultivation_logic.py +++ b/tests/test_cultivation_logic.py @@ -8,6 +8,20 @@ def test_realm_comparison(): assert Realm.Core_Formation < Realm.Nascent_Soul assert Realm.Nascent_Soul > Realm.Qi_Refinement +def test_realm_str_returns_translated_text_not_value(): + """Test that str(Realm) returns i18n translated text, not the raw enum value.""" + # str() should NOT return the uppercase enum value. + assert str(Realm.Qi_Refinement) != "QI_REFINEMENT" + assert str(Realm.Foundation_Establishment) != "FOUNDATION_ESTABLISHMENT" + assert str(Realm.Core_Formation) != "CORE_FORMATION" + assert str(Realm.Nascent_Soul) != "NASCENT_SOUL" + + # str() should return non-empty translated text. + assert len(str(Realm.Qi_Refinement)) > 0 + assert len(str(Realm.Foundation_Establishment)) > 0 + assert len(str(Realm.Core_Formation)) > 0 + assert len(str(Realm.Nascent_Soul)) > 0 + def test_realm_from_id(): assert Realm.from_id(1) == Realm.Qi_Refinement assert Realm.from_id(4) == Realm.Nascent_Soul @@ -19,6 +33,18 @@ def test_stage_comparison(): assert Stage.Early_Stage < Stage.Middle_Stage assert Stage.Middle_Stage < Stage.Late_Stage +def test_stage_str_returns_translated_text_not_value(): + """Test that str(Stage) returns i18n translated text, not the raw enum value.""" + # str() should NOT return the uppercase enum value. + assert str(Stage.Early_Stage) != "EARLY_STAGE" + assert str(Stage.Middle_Stage) != "MIDDLE_STAGE" + assert str(Stage.Late_Stage) != "LATE_STAGE" + + # str() should return non-empty translated text. + assert len(str(Stage.Early_Stage)) > 0 + assert len(str(Stage.Middle_Stage)) > 0 + assert len(str(Stage.Late_Stage)) > 0 + # ================= CultivationProgress Tests ================= def test_cp_initialization(): cp = CultivationProgress(level=1, exp=0) diff --git a/tests/test_i18n_realm_display.py b/tests/test_i18n_realm_display.py new file mode 100644 index 0000000..878df8c --- /dev/null +++ b/tests/test_i18n_realm_display.py @@ -0,0 +1,273 @@ +""" +Integration tests for realm/stage i18n display in item exchange messages. + +Verifies that user-facing messages show translated realm names (e.g., "筑基") +instead of raw enum values (e.g., "FOUNDATION_ESTABLISHMENT"). + +Coverage: +- src/classes/single_choice.py (handle_item_exchange) +- src/classes/kill_and_grab.py (kill_and_grab context string) +- src/classes/fortune.py (fortune intro strings) +- src/classes/avatar/inventory_mixin.py (can_buy_item error message) +""" +import pytest +from unittest.mock import Mock, patch, AsyncMock + +from src.classes.cultivation import Realm, Stage, CultivationProgress +from src.classes.weapon import weapons_by_id, Weapon +from src.classes.auxiliary import auxiliaries_by_id +from src.classes.elixir import elixirs_by_id +from src.classes.single_choice import handle_item_exchange +from src.classes.kill_and_grab import kill_and_grab + + +# Raw enum values that should NOT appear in user-facing messages. +RAW_REALM_VALUES = [ + "QI_REFINEMENT", + "FOUNDATION_ESTABLISHMENT", + "CORE_FORMATION", + "NASCENT_SOUL", +] + +RAW_STAGE_VALUES = [ + "EARLY_STAGE", + "MIDDLE_STAGE", + "LATE_STAGE", +] + + +class MockAvatarForIntegration: + """A minimal mock avatar for integration testing.""" + def __init__(self): + self.name = "TestCultivator" + self.weapon = None + self.auxiliary = None + self.technique = None + self.world = Mock() + self.world.static_info = {} + self.change_weapon = Mock() + self.sell_weapon = Mock(return_value=100) + + def get_info(self, detailed=False): + return {"name": self.name} + + +def get_real_weapon() -> Weapon: + """Get a real weapon from the game data for testing.""" + # Get the first available weapon. + if weapons_by_id: + return next(iter(weapons_by_id.values())) + pytest.skip("No weapons available in game data") + + +@pytest.mark.asyncio +async def test_handle_item_exchange_shows_translated_realm(): + """ + Integration test: handle_item_exchange should return messages + with translated realm names, not raw enum values. + """ + weapon = get_real_weapon() + avatar = MockAvatarForIntegration() + + # Auto-equip (no existing weapon). + swapped, msg = await handle_item_exchange( + avatar, weapon, "weapon", "Testing context", can_sell_new=False + ) + + assert swapped is True + + # Message should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in msg, ( + f"Message contains raw enum value '{raw_value}': {msg}" + ) + + # Message should contain the weapon name. + assert weapon.name in msg + + +@pytest.mark.asyncio +async def test_weapon_detailed_info_shows_translated_realm(): + """ + Integration test: Weapon.get_detailed_info() should return + translated realm names, not raw enum values. + """ + weapon = get_real_weapon() + info = weapon.get_detailed_info() + + # Info should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in info, ( + f"Detailed info contains raw enum value '{raw_value}': {info}" + ) + + +def test_realm_str_integration_with_real_data(): + """ + Integration test: Verify all realms in actual weapon data + have proper translated string representations. + """ + realms_found = set() + + for weapon in weapons_by_id.values(): + realm_str = str(weapon.realm) + realms_found.add(weapon.realm) + + # Should not be raw enum value. + assert realm_str not in RAW_REALM_VALUES, ( + f"Weapon '{weapon.name}' has raw realm value: {realm_str}" + ) + + # Should not be empty. + assert len(realm_str) > 0, ( + f"Weapon '{weapon.name}' has empty realm string" + ) + + # Ensure we tested at least some weapons. + assert len(realms_found) > 0, "No weapons found in game data" + + +def test_cultivation_progress_str_shows_translated_realm(): + """ + Integration test: CultivationProgress.__str__() should use + translated realm and stage names. + """ + cp = CultivationProgress(level=35, exp=0) # Foundation Establishment. + cp_str = str(cp) + + # Should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES + RAW_STAGE_VALUES: + assert raw_value not in cp_str, ( + f"CultivationProgress string contains raw value '{raw_value}': {cp_str}" + ) + + +# ==================== kill_and_grab.py coverage ==================== + +class MockAvatarForKillAndGrab: + """Mock avatar for kill_and_grab testing.""" + def __init__(self, name: str, weapon=None, auxiliary=None): + self.name = name + self.weapon = weapon + self.auxiliary = auxiliary + self.technique = None + self.world = Mock() + self.world.static_info = {} + self.change_weapon = Mock() + self.change_auxiliary = Mock() + self.sell_weapon = Mock(return_value=100) + self.sell_auxiliary = Mock(return_value=100) + + def get_info(self, detailed=False): + return {"name": self.name} + + +@pytest.mark.asyncio +async def test_kill_and_grab_context_shows_translated_realm(): + """ + Integration test: kill_and_grab should generate context strings + with translated realm names, not raw enum values. + """ + weapon = get_real_weapon() + + winner = MockAvatarForKillAndGrab("Winner") + loser = MockAvatarForKillAndGrab("Loser", weapon=weapon) + + # Patch handle_item_exchange to capture the context_intro argument. + with patch( + "src.classes.kill_and_grab.handle_item_exchange", + new_callable=AsyncMock + ) as mock_exchange: + mock_exchange.return_value = (True, "equipped") + + await kill_and_grab(winner, loser) + + # Verify handle_item_exchange was called. + assert mock_exchange.called + + # Get the context_intro argument. + call_kwargs = mock_exchange.call_args + context_intro = call_kwargs.kwargs.get("context_intro") or call_kwargs.args[3] + + # Context should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in context_intro, ( + f"kill_and_grab context contains raw enum value '{raw_value}': {context_intro}" + ) + + +# ==================== fortune.py coverage ==================== + +def test_fortune_weapon_intro_uses_translated_realm(): + """ + Integration test: Fortune weapon discovery intro should use + translated realm names via str(realm). + """ + from src.i18n import t + + weapon = get_real_weapon() + # Simulate what fortune.py does. + intro = t( + "You discovered a {realm} weapon『{weapon_name}』in your fortune.", + realm=str(weapon.realm), + weapon_name=weapon.name + ) + + # Intro should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in intro, ( + f"Fortune intro contains raw enum value '{raw_value}': {intro}" + ) + + +def test_fortune_auxiliary_intro_uses_translated_realm(): + """ + Integration test: Fortune auxiliary discovery intro should use + translated realm names via str(realm). + """ + from src.i18n import t + + # Get a real auxiliary. + if not auxiliaries_by_id: + pytest.skip("No auxiliaries available in game data") + auxiliary = next(iter(auxiliaries_by_id.values())) + + # Simulate what fortune.py does. + intro = t( + "You discovered a {realm} auxiliary『{auxiliary_name}』in your fortune.", + realm=str(auxiliary.realm), + auxiliary_name=auxiliary.name + ) + + # Intro should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in intro, ( + f"Fortune intro contains raw enum value '{raw_value}': {intro}" + ) + + +# ==================== inventory_mixin.py coverage ==================== + +def test_can_buy_item_error_message_shows_translated_realm(): + """ + Integration test: can_buy_item error message for realm restriction + should show translated realm names, not raw enum values. + """ + # Get a high-realm elixir. + high_realm_elixir = None + for elixir in elixirs_by_id.values(): + if elixir.realm >= Realm.Foundation_Establishment: + high_realm_elixir = elixir + break + + if high_realm_elixir is None: + pytest.skip("No high-realm elixir found in game data") + + # Simulate the error message generation from inventory_mixin.py. + error_msg = f"境界不足,无法承受药力 ({str(high_realm_elixir.realm)})" + + # Error message should NOT contain raw enum values. + for raw_value in RAW_REALM_VALUES: + assert raw_value not in error_msg, ( + f"Error message contains raw enum value '{raw_value}': {error_msg}" + ) diff --git a/tests/test_single_choice.py b/tests/test_single_choice.py index 8e8b739..7cc4c82 100644 --- a/tests/test_single_choice.py +++ b/tests/test_single_choice.py @@ -23,9 +23,10 @@ class MockItem: def __init__(self, name, item_type="weapon"): self.name = name self.item_type = item_type - # Weapon/Auxiliary/Elixir usually have realm or grade + # Weapon/Auxiliary/Elixir usually have realm or grade. self.realm = Mock() self.realm.value = "TestRealm" + self.realm.__str__ = Mock(return_value="TestRealm") def get_info(self, detailed=False): return f"Info({self.name})"