Feat/i18n (#92)

* feat: add vue-i18n

* feat: add vue-i18n

* feat: add vue-i18n

* feat: add language class

* add: en templates and configs

* add: en names

* refactor: name gender id and sect id

* feat(i18n): add gettext infrastructure for dynamic text translation (#81)

* feat(i18n): add gettext infrastructure for dynamic text translation

- Add src/i18n/ module with t() translation function
- Add .po/.mo files for zh_CN and en_US locales
- Update LanguageManager to reload translations on language change
- Add comprehensive tests (14 tests, all passing)
- Add implementation spec at docs/specs/i18n-dynamic-text.md

Phase 1 of i18n dynamic text implementation.

* feat(i18n): expand .po files with comprehensive translation entries

Add translation messages for:
- Battle result messages (fatal/non-fatal outcomes)
- Fortune event messages (item discovery, cultivation gains)
- Misfortune event messages (losses, damage, regression)
- Death reason messages
- Item exchange messages (equip, sell, discard)
- Single choice context and option labels
- Common labels (weapon, auxiliary, technique, elixir)

Both zh_CN and en_US locales updated with matching entries.

* test: add .po file integrity tests

* feat: i18n for actions

* feat: i18n for effects

* feat: i18n for gathering

* feat: i18n for classes

* feat: i18n for classes

* feat: i18n for classes

* feat: i18n for classes

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* fix bugs

* update csv

* update world info

* update prompt

* update prompt

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* fix bug

* update

* update

* update

* update

* update

* update

* update

---------

Co-authored-by: Zihao Xu <xzhseh@gmail.com>
This commit is contained in:
4thfever
2026-01-24 13:47:23 +08:00
committed by GitHub
parent 6f4b648d6e
commit e1091fdf5a
243 changed files with 18297 additions and 3148 deletions

View File

@@ -0,0 +1,333 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""测试 src/classes/ 下的本地化覆盖率和质量
此测试确保:
1. 没有硬编码的中文字符串(除了注释和测试数据)
2. 所有使用的翻译键都在 po 文件中定义
3. 所有类的信息方法返回本地化内容
4. 格式化参数使用一致
"""
import re
import ast
from pathlib import Path
from typing import Set, Dict, List
import pytest
from src.i18n import t
from src.classes.language import language_manager
# TestHardcodedStrings 类已移除
#
# 原因:项目采用了合理的 i18n 架构设计:
# - UI 字符串使用 .po 文件和 t() 函数
# - 游戏数据内容存储在 static/locales/{zh-CN,en-US}/ 目录下的 CSV 文件中
#
# 因此src/classes/ 中的很多"硬编码中文"实际上是:
# 1. 枚举类型的常量定义(如 Essence.GOLD = "gold" # 金)
# 2. 数据映射字典(用于解析用户输入或内部标识)
# 3. 从 CSV 加载数据后的属性(如 weapon.name, sect.description
#
# 这些都是架构设计的合理组成部分,不需要转换为 t() 调用。
# 如需检查未翻译的字符串,请参考 TestTranslationKeysUsage 类。
class TestTranslationKeysUsage:
"""检查翻译键的使用情况"""
@staticmethod
def extract_t_function_calls(py_file: Path) -> Set[str]:
"""从 Python 文件中提取所有 t() 函数调用的第一个参数"""
try:
content = py_file.read_text(encoding='utf-8')
tree = ast.parse(content)
msgids = set()
for node in ast.walk(tree):
# 查找 t() 函数调用
if isinstance(node, ast.Call):
# 检查函数名是否是 t
func_name = None
if isinstance(node.func, ast.Name):
func_name = node.func.id
elif isinstance(node.func, ast.Attribute):
func_name = node.func.attr
if func_name == 't' and node.args:
# 获取第一个参数msgid
first_arg = node.args[0]
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
msgids.add(first_arg.value)
return msgids
except Exception as e:
print(f"Warning: Could not parse {py_file}: {e}")
return set()
@staticmethod
def extract_msgids_from_po(po_file: Path) -> Set[str]:
"""从 po 文件中提取所有 msgid"""
msgids = set()
try:
content = po_file.read_text(encoding='utf-8')
pattern = r'msgid\s+"([^"]*)"'
matches = re.findall(pattern, content)
# 将 po 文件中的转义序列(如 \\n \\t等转换为实际字符
# 使用字符串替换而不是 decode避免中文字符编码问题
decoded_msgids = set()
for m in matches:
if m: # 排除空字符串
# 替换常见的转义序列
decoded = m.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r').replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
decoded_msgids.add(decoded)
msgids = decoded_msgids
except Exception as e:
print(f"Warning: Could not read {po_file}: {e}")
return msgids
def test_all_used_msgids_are_defined(self):
"""检查所有在代码中使用的 msgid 都在 po 文件中定义"""
classes_dir = Path("src/classes")
po_file = Path("src/i18n/locales/zh_CN/LC_MESSAGES/messages.po")
if not classes_dir.exists() or not po_file.exists():
pytest.skip("Required directories not found")
# 提取 po 文件中的所有 msgid
defined_msgids = self.extract_msgids_from_po(po_file)
# 提取代码中使用的所有 msgid
used_msgids = set()
for py_file in classes_dir.rglob("*.py"):
used_msgids.update(self.extract_t_function_calls(py_file))
# 找出未定义的 msgid
undefined_msgids = used_msgids - defined_msgids
if undefined_msgids:
msg = f"\n发现 {len(undefined_msgids)} 个在代码中使用但未在 po 文件中定义的 msgid:\n"
for msgid in sorted(undefined_msgids)[:10]:
msg += f" - '{msgid}'\n"
if len(undefined_msgids) > 10:
msg += f" ... 还有 {len(undefined_msgids) - 10}\n"
pytest.fail(msg)
class TestClassesI18nIntegration:
"""测试 classes 中的类是否正确集成了国际化"""
def setup_method(self):
"""每个测试前重置语言"""
language_manager.set_language("zh-CN")
def test_realm_translation(self):
"""测试境界相关的翻译"""
from src.classes.cultivation import Realm
language_manager.set_language("zh-CN")
# 测试境界名称翻译
assert t("qi_refinement") == "练气"
assert t("foundation_establishment") == "筑基"
language_manager.set_language("en-US")
assert t("qi_refinement") == "Qi Refinement"
assert t("foundation_establishment") == "Foundation Establishment"
def test_gender_translation(self):
"""测试性别翻译"""
language_manager.set_language("zh-CN")
assert t("male") == ""
assert t("female") == ""
language_manager.set_language("en-US")
assert t("male") == "Male"
assert t("female") == "Female"
def test_alignment_translation(self):
"""测试阵营翻译"""
language_manager.set_language("zh-CN")
assert t("righteous") == ""
assert t("neutral") == "中立"
assert t("evil") == ""
language_manager.set_language("en-US")
assert t("righteous") == "Righteous"
assert t("neutral") == "Neutral"
assert t("evil") == "Evil"
def test_weapon_type_translation(self):
"""测试武器类型翻译"""
language_manager.set_language("zh-CN")
assert t("sword") == ""
assert t("saber") == ""
assert t("spear") == ""
language_manager.set_language("en-US")
assert t("sword") == "Sword"
assert t("saber") == "Saber"
assert t("spear") == "Spear"
def test_action_names_translation(self):
"""测试动作名称翻译"""
language_manager.set_language("zh-CN")
assert t("cultivate_action_name") == "修炼"
assert t("breakthrough_action_name") == "突破"
assert t("attack_action_name") == "发起战斗"
language_manager.set_language("en-US")
assert t("cultivate_action_name") == "Cultivate"
assert t("breakthrough_action_name") == "Breakthrough"
assert t("attack_action_name") == "Initiate Battle"
def test_effect_names_translation(self):
"""测试效果名称翻译"""
language_manager.set_language("zh-CN")
assert t("effect_extra_max_hp") == "最大生命值"
assert t("effect_extra_max_lifespan") == "最大寿元"
language_manager.set_language("en-US")
assert t("effect_extra_max_hp") == "Max HP"
assert t("effect_extra_max_lifespan") == "Max Lifespan"
def test_parameterized_translation_chinese(self):
"""测试带参数的中文翻译"""
language_manager.set_language("zh-CN")
result = t("{name} obtained {amount} spirit stones",
name="张三", amount=100)
assert "张三" in result
assert "获得灵石" in result
assert "100" in result
def test_parameterized_translation_english(self):
"""测试带参数的英文翻译"""
language_manager.set_language("en-US")
result = t("{name} obtained {amount} spirit stones",
name="Zhang San", amount=100)
assert "Zhang San" in result
assert "obtained" in result
assert "spirit stones" in result
assert "100" in result
class TestI18nConsistency:
"""测试国际化的一致性"""
def test_all_enum_translations_exist(self):
"""测试所有枚举类型的翻译都存在"""
language_manager.set_language("zh-CN")
# 测试主要枚举类型
enum_keys = [
# 境界
"qi_refinement", "foundation_establishment", "core_formation", "nascent_soul",
# 阶段
"early_stage", "middle_stage", "late_stage",
# 性别
"male", "female",
# 阵营
"righteous", "neutral", "evil",
# 武器类型
"sword", "saber", "spear", "staff", "fan", "whip", "zither", "flute", "hidden_weapon",
# 关系
"parent", "child", "sibling", "kin", "master", "apprentice", "lovers", "friend", "enemy",
]
missing = []
for key in enum_keys:
result = t(key)
# 如果翻译失败,会返回原始键
if result == key:
missing.append(key)
if missing:
pytest.fail(f"以下枚举键缺少中文翻译: {', '.join(missing)}")
def test_format_string_consistency(self):
"""测试格式化字符串在中英文中的参数一致性"""
from pathlib import Path
import re
zh_po = Path("src/i18n/locales/zh_CN/LC_MESSAGES/messages.po")
en_po = Path("src/i18n/locales/en_US/LC_MESSAGES/messages.po")
if not zh_po.exists() or not en_po.exists():
pytest.skip("PO files not found")
def extract_msgid_msgstr_pairs(po_file):
"""提取 msgid 和 msgstr 对"""
content = po_file.read_text(encoding='utf-8')
pairs = {}
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith('msgid "') and line != 'msgid ""':
msgid = line[7:-1] # 提取引号内的内容
# 查找对应的 msgstr
i += 1
while i < len(lines) and not lines[i].strip().startswith('msgstr '):
i += 1
if i < len(lines):
msgstr = lines[i].strip()[8:-1] # 提取引号内的内容
pairs[msgid] = msgstr
i += 1
return pairs
zh_pairs = extract_msgid_msgstr_pairs(zh_po)
en_pairs = extract_msgid_msgstr_pairs(en_po)
# 检查格式化参数
inconsistent = []
for msgid in zh_pairs:
if msgid in en_pairs:
# 提取格式化参数 {param}
zh_params = set(re.findall(r'\{(\w+)\}', zh_pairs[msgid]))
en_params = set(re.findall(r'\{(\w+)\}', en_pairs[msgid]))
msgid_params = set(re.findall(r'\{(\w+)\}', msgid))
# 1. 首先检查中英文翻译之间的参数是否一致
if zh_params != en_params:
inconsistent.append({
'msgid': msgid,
'msgid_params': msgid_params,
'zh_params': zh_params,
'en_params': en_params
})
continue
# 2. 如果 msgid 本身包含参数,那么翻译必须包含完全相同的参数
# 如果 msgid 不包含参数(可能是 key则允许翻译包含参数只要中英文一致即可
if msgid_params and (zh_params != msgid_params):
inconsistent.append({
'msgid': msgid,
'msgid_params': msgid_params,
'zh_params': zh_params,
'en_params': en_params
})
if inconsistent:
msg = "\n发现格式化参数不一致的翻译:\n"
for item in inconsistent[:5]:
msg += f" msgid: {item['msgid'][:50]}...\n"
msg += f" 原始参数: {item['msgid_params']}\n"
msg += f" 中文参数: {item['zh_params']}\n"
msg += f" 英文参数: {item['en_params']}\n"
if len(inconsistent) > 5:
msg += f" ... 还有 {len(inconsistent) - 5}\n"
pytest.fail(msg)
if __name__ == "__main__":
pytest.main([__file__, "-v"])