专名本土化(信达雅):冻结译名表 + 渲染层,并修掉校验器的正则盲点

- 新增 dfannals/glossary.py(译名表)与 dfannals/localize.py(渲染层)
  人名=音译+绰号意译(Ral Fastenhatchets→拉尔·缚斧、朗古德·商熊「剖胎之寒」),
  地名整体意译(Fordedwinds→涉风渡、The Volcano Of Ravens→渡鸦火山)
- 生成链路本土化:素材渲染、人物表、扩写白名单全部走译名;已有产物做确定性替换
- 修复 factcheck 的 \b 盲点:Unicode 下汉字也算词字符,紧贴汉字的英文名不被检查
  (曾因此漏掉编造专名)。修好后抓出并定点修掉长版第 1 集的 2 处编造
- 修正 title_form 大小写(YETI→Yeti),此前 Yeti/Goblin 一直漏译
- 新增 tests/test_localize.py(13 项回归)
This commit is contained in:
Chen Yi
2026-10-05 22:39:53 +08:00
parent 8630dcad55
commit 16b476167e
14 changed files with 924 additions and 350 deletions
+119
View File
@@ -0,0 +1,119 @@
"""专名本土化的回归测试。
重点盯住两个曾经真实踩过的坑:
1. 只处理全名,漏掉正文里大量使用的简称(`Ral`)与单呼的姓(`Fastenhatchets`)。
2. `YETI` 这类全大写枚举只改首字母得到 `YETI`,永远匹配不上正文里的 `Yeti`。
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dfannals import glossary, localize
def test_title_form_lowercases_tail() -> None:
assert localize.title_form("YETI") == "Yeti"
assert localize.title_form("EAGLE_MAN") == "Eagle_man"
assert localize.title_form("fastenhatchets") == "Fastenhatchets"
def test_person_full_name() -> None:
assert localize.translate_person("Ral Fastenhatchets") == "拉尔·缚斧"
assert localize.translate_person("Guspu Frillyknots") == "古斯普·褶结"
assert localize.translate_person("Rigoth Stilledearthen") == "里戈斯·静土"
def test_person_with_epithet() -> None:
name = "Langgud Tradebears The Eviscerated Chill Of Pregnancies"
assert localize.translate_person(name) == "朗古德·商熊「剖胎之寒」"
def test_person_unknown_returns_none() -> None:
assert localize.translate_person("Zzzq Unknownsurname") is None
def test_place_and_epithet() -> None:
assert localize.translate_place("Fordedwinds") == "涉风渡"
assert localize.translate_place("Paperhushed") == "纸寂"
assert localize.translate_place("Gloryholes The Trickery Of Mornings") == "荣耀穴(晨之诡计)"
def test_group_translation() -> None:
assert localize.translate_group("The Volcano Of Ravens") == "渡鸦火山"
def test_race_enum_and_display_form() -> None:
assert localize.translate_race("YETI") == "雪人"
assert localize.translate_race("DWARF") == "矮人"
assert localize.translate_race("UNKNOWN_RACE") == "UNKNOWN_RACE"
def _fake_translator() -> localize.Translator:
"""不依赖真实世界数据的小翻译器,专测替换顺序。"""
translator = localize.Translator(
people={"Ral Fastenhatchets": "拉尔·缚斧", "Rigoth Stilledearthen": "里戈斯·静土"},
places={"Fordedwinds": "涉风渡"},
groups={"The Volcano Of Ravens": "渡鸦火山"},
stems={"Ral": "拉尔", "Rigoth": "里戈斯"},
surnames={"Fastenhatchets": "缚斧"},
races={"Yeti": "雪人", "EAGLE_MAN": "鹰人"},
)
return translator
def test_render_full_name_wins_over_short_name() -> None:
out = _fake_translator().render("Ral Fastenhatchets守着Fordedwinds。")
assert out == "拉尔·缚斧守着涉风渡。", out
def test_render_short_name_and_bare_surname() -> None:
out = _fake_translator().render("Ral对Rigoth说:她那一支姓Fastenhatchets。")
assert "拉尔" in out and "里戈斯" in out and "缚斧" in out, out
assert not any(ch.isascii() and ch.isalpha() for ch in out), out
def test_render_race_display_form() -> None:
out = _fake_translator().render("一个Yeti站了起来。军团是EAGLE_MAN。")
assert "雪人" in out and "鹰人" in out, out
def test_render_tidies_spaces() -> None:
out = _fake_translator().render("Ral 说 完 就 走 了")
assert "拉尔" in out and "拉尔 说" not in out, out
def test_accepted_includes_pieces() -> None:
accepted = _fake_translator().accepted()
assert "拉尔·缚斧" in accepted
assert "拉尔" in accepted and "缚斧" in accepted, sorted(accepted)
def test_glossary_tables_are_lowercase_keys() -> None:
for table in (glossary.STEMS, glossary.SURNAMES, glossary.PLACES):
for key in table:
assert key == key.lower(), f"{key} 应小写,否则 title_form 对不上"
def _main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for fn in tests:
try:
fn()
except AssertionError as exc:
failed += 1
print(f" ✗ {fn.__name__}: {exc}")
except Exception as exc: # noqa: BLE001
failed += 1
print(f" ✗ {fn.__name__}: {type(exc).__name__}: {exc}")
else:
print(f" ✓ {fn.__name__}")
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(_main())