The pipeline was hard-coded to Simplified Chinese in three places: the drafter was told to translate into Chinese, the critic was told to compare English with Chinese, and the glossary's translated forms were read from a "zh" key. A production that declared target_language: es-ES was therefore translated into Chinese and filed under a Spanish name - measured on oldbox, deal-hunter-es held 3,038 paragraphs of Chinese and was reported complete. A new registry, tools/translation/languages.py, is the single source of truth for what a target language is: its names, its script, its punctuation, its glossary key, its typography and its chapter-numbering convention. Ten languages are registered. The prompt scaffolding stays in Chinese - that is what the models read best - and the target is named with its Chinese, English and native names so there is no room to misread it. The registry now reaches every stage: - prompts.py: the analyst, drafter, critic and glossary blocks take the target. The hard rules about this book - Deal Hunter is the ship's name, the AI's name and the book's title, so it stays in English - are the language's own table for Chinese and the global rules for everything else, so a language with no table does not lose them. The Chinese rules are byte-for-byte what they were. - pipeline.py: --target-language, a run-directory guard that refuses to reuse a directory belonging to another language (from the manifest, or by measuring the text it already holds), a draft-keyed-by-target reader, and a refusal of a draft that came back in the wrong script. - book.py: per-language chapter-title and subtitle tables. A language with no table falls back to the English title, never to another language's; a language with a table and a missing chapter gets a visible marker in its own language. - qa.py: the target's quote marks, punctuation, script count and forbidden title forms. The Latin-residue check is skipped for a Latin target, where it would flag the whole book. - align.py: the aligner and verifier prompts name the target, and the sentence and clause splitting uses the target's own terminators. - render.py: the fonts, the language and country codes, the writing direction, the chapter headings and the fixed editorial strings around the translation are the target's. The Latin-run span is skipped for a Latin target, where marking the text as English would tell the renderer it is English. - project_status.py: the dashboard measures a sample of a production's text and reports a production whose text is not in the language it declares as "mismatched", not "complete". A chapter count is a claim about the files; the script check is a measurement of the text. Also fixes: the dashboard gated every language's production on the source production existing, so a Spanish production could never be reported even when it was there. The Chinese render is byte-identical to before on the real book, and the Chinese rules are unchanged. Suite: 620 passed, the same 68 pre-existing environment failures, +49 new tests.
159 lines
6.5 KiB
Python
159 lines
6.5 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
||
# SPDX-License-Identifier: Apache-2.0
|
||
|
||
"""Tests for the renderer.
|
||
|
||
The renderer writes the book the reader receives, so its typography is part
|
||
of the translation: the fonts, the language codes, the chapter-numbering
|
||
convention and the fixed editorial strings all have to be the target
|
||
language's. A Spanish edition typeset with Chinese fonts and Chinese chapter
|
||
headings is the same defect as a Chinese draft filed under a Spanish name,
|
||
one level down and much easier to miss.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE.parent))
|
||
|
||
from languages import resolve # noqa: E402
|
||
from render import build_asciidoc, build_fodt, build_styles # noqa: E402
|
||
|
||
CHINESE = resolve("zh-Hans")
|
||
SPANISH = resolve("es-ES")
|
||
ARABIC = resolve("ar-SA")
|
||
|
||
|
||
def _book(target: str = "zh-Hans", subtitle: str = "交易猎人") -> dict:
|
||
return {
|
||
"title": "Deal Hunter",
|
||
"title_target": "Deal Hunter",
|
||
"subtitle_target": subtitle,
|
||
"target_language": target,
|
||
"chapters": [
|
||
{
|
||
"id": "ch-6",
|
||
"index": 6,
|
||
"row": 6,
|
||
"matter": "body",
|
||
"title": "Salvage",
|
||
"title_target": "Salvage",
|
||
"paragraphs": [
|
||
{"id": "ch-6-p0000", "type": "narration", "text": "The chair has"},
|
||
{"id": "ch-6-p0001", "type": "dialogue", "text": "Hello."},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
def _run_dir(root: Path, text: str) -> Path:
|
||
chapter = root / "ch-6"
|
||
chapter.mkdir(parents=True, exist_ok=True)
|
||
(chapter / "translation.json").write_text(
|
||
json.dumps({"paragraphs": {"ch-6-p0000": text, "ch-6-p0001": text}}),
|
||
encoding="utf-8",
|
||
)
|
||
return root
|
||
|
||
|
||
class RenderTypographyTests(unittest.TestCase):
|
||
"""The renderer's typography is the target language's."""
|
||
|
||
def test_the_asciidoc_language_is_the_target_language(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
asciidoc = build_asciidoc(_book("es-ES", "Cazador de Ofertas"), run, target=SPANISH)
|
||
self.assertIn(":lang: es-ES", asciidoc)
|
||
self.assertNotIn("zh-CN", asciidoc)
|
||
|
||
def test_the_stylesheet_carries_the_target_language_codes(self) -> None:
|
||
styles = build_styles(SPANISH)
|
||
self.assertIn('fo:language="es"', styles)
|
||
self.assertIn('fo:country="ES"', styles)
|
||
self.assertIn('style:font-name="Noto Serif"', styles)
|
||
self.assertNotIn('fo:language="zh"', styles)
|
||
|
||
def test_an_rtl_target_gets_a_right_to_left_direction(self) -> None:
|
||
self.assertIn('fo:direction="rtl"', build_styles(ARABIC))
|
||
self.assertNotIn('fo:direction="rtl"', build_styles(SPANISH))
|
||
|
||
def test_the_chapter_heading_follows_the_target_convention(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
spanish = build_asciidoc(_book("es-ES"), run, target=SPANISH)
|
||
chinese = build_asciidoc(_book(), run, target=CHINESE)
|
||
self.assertIn("== Capítulo 1 Salvage", spanish)
|
||
self.assertIn("== 第一章 Salvage", chinese)
|
||
|
||
def test_a_latin_target_does_not_mark_its_text_as_english(self) -> None:
|
||
"""The Latin-run span exists so Chinese line-breaking leaves names alone.
|
||
|
||
For a Spanish paragraph the whole text is Latin, and marking it as
|
||
English would tell the renderer that the paragraph is English.
|
||
"""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
spanish = build_fodt(_book("es-ES"), run, target=SPANISH)
|
||
chinese = build_fodt(_book(), run, target=CHINESE)
|
||
self.assertNotIn('text:style-name="Latin"', spanish)
|
||
self.assertIn('text:style-name="Latin"', chinese)
|
||
|
||
def test_the_fixed_strings_are_the_target_language_s(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
spanish = build_asciidoc(_book("es-ES"), run, target=SPANISH, sample=True)
|
||
chinese = build_asciidoc(_book(), run, target=CHINESE, sample=True)
|
||
self.assertIn("Muestra de revisión", spanish)
|
||
self.assertIn("Traducido por Umbra Tools", spanish)
|
||
self.assertNotIn("审阅样本", spanish)
|
||
self.assertIn("审阅样本", chinese)
|
||
|
||
def test_the_contents_heading_is_the_target_language_s(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
spanish = build_fodt(_book("es-ES"), run, target=SPANISH)
|
||
self.assertIn(">Índice</text:index-title-template>", spanish)
|
||
self.assertNotIn(">目录</text:index-title-template>", spanish)
|
||
|
||
def test_the_running_header_carries_the_target_subtitle(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
spanish = build_fodt(_book("es-ES", "Cazador de Ofertas"), run, target=SPANISH)
|
||
self.assertIn("Deal Hunter · Cazador de Ofertas", spanish)
|
||
|
||
def test_the_old_book_format_still_renders(self) -> None:
|
||
"""A book file written before the subtitle field existed must still work.
|
||
|
||
The old format stored the subtitle in the book-level ``title_zh``.
|
||
"""
|
||
book = _book()
|
||
del book["subtitle_target"]
|
||
book["title_zh"] = "交易猎人"
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "中间的椅子旁。")
|
||
asciidoc = build_asciidoc(book, run, target=CHINESE)
|
||
fodt = build_fodt(book, run, target=CHINESE)
|
||
self.assertIn("= Deal Hunter: 交易猎人", asciidoc)
|
||
self.assertIn("交易猎人", fodt)
|
||
|
||
def test_a_chapter_with_no_target_title_falls_back_to_the_english_one(self) -> None:
|
||
"""A missing translation is visible; borrowing another language's is not."""
|
||
book = _book("es-ES")
|
||
del book["chapters"][0]["title_target"]
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
run = _run_dir(Path(tmp), "La silla estaba sucia.")
|
||
asciidoc = build_asciidoc(book, run, target=SPANISH)
|
||
self.assertIn("Salvage", asciidoc)
|
||
self.assertNotIn("打捞", asciidoc)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|