* 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>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
#!/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"])
|