The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""Tests for the language registry (spec 31 section 5).
|
|
|
|
The registry is the single source of truth for what this system can speak, so
|
|
these tests pin the two things that would silently mis-voice audio: the
|
|
voiceable/gap split, and the exact strings the TTS model expects.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from umbraaudio import languages as lang
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_voiceable_count_is_ten() -> None:
|
|
assert len(lang.QWEN_LANGUAGES) == 10
|
|
assert sum(1 for x in lang.QWEN_LANGUAGES if x.tts_name) == 10
|
|
|
|
|
|
def test_gap_languages_are_not_voiceable() -> None:
|
|
assert len(lang.GAP_LANGUAGES) == 5
|
|
for gap in lang.GAP_LANGUAGES:
|
|
assert gap.tts_name is None
|
|
assert not lang.is_voiceable(gap.code)
|
|
assert lang.is_known(gap.code), "gap languages are known, just not speakable"
|
|
|
|
|
|
def test_gap_set_is_exactly_the_five_unsupported() -> None:
|
|
# spec 30 section 8 named these five; a sixth appearing here silently would
|
|
# change what the product claims to support.
|
|
assert {g.code for g in lang.GAP_LANGUAGES} == {"nl", "no", "pl", "ar", "hi"}
|
|
|
|
|
|
def test_no_duplicate_codes_or_names() -> None:
|
|
codes = [x.code for x in lang.ALL_LANGUAGES]
|
|
names = [x.name for x in lang.ALL_LANGUAGES]
|
|
assert len(codes) == len(set(codes))
|
|
assert len(names) == len(set(names))
|
|
|
|
|
|
def test_codes_are_lowercase_iso() -> None:
|
|
for x in lang.ALL_LANGUAGES:
|
|
assert re.fullmatch(r"[a-z]{2}", x.code), x
|
|
|
|
|
|
def test_normalize_accepts_code_name_and_model_string() -> None:
|
|
for x in lang.ALL_LANGUAGES:
|
|
assert lang.normalize(x.code) == x.code
|
|
assert lang.normalize(x.code.upper()) == x.code
|
|
assert lang.normalize(x.name) == x.code
|
|
assert lang.normalize(f" {x.name.lower()} ") == x.code
|
|
|
|
|
|
def test_normalize_rejects_unknown_without_raising() -> None:
|
|
# spec 31 section 8: an unrecognised language is a finding, not a crash.
|
|
for bad in (None, "", " ", "klingon", "zz"):
|
|
assert lang.normalize(bad) is None
|
|
|
|
|
|
def test_tts_name_is_the_model_string() -> None:
|
|
assert lang.tts_name("es") == "spanish"
|
|
assert lang.tts_name("Spanish") == "spanish"
|
|
assert lang.tts_name("zh") == "chinese"
|
|
assert lang.tts_name("nl") is None
|
|
|
|
|
|
def test_language_name_falls_back_gracefully() -> None:
|
|
assert lang.language_name("fr") == "French"
|
|
assert lang.language_name("xx") == "xx"
|
|
assert lang.language_name(None) == "unknown"
|
|
|
|
|
|
def test_refusal_reason_is_explicit() -> None:
|
|
assert lang.refusal_reason("en") is None
|
|
assert lang.refusal_reason(None) is None
|
|
nl = lang.refusal_reason("nl")
|
|
assert nl is not None and "'nl'" in nl and "Dutch" in nl
|
|
xx = lang.refusal_reason("xx")
|
|
assert xx is not None and "unrecognised" in xx
|
|
|
|
|
|
def test_origins_cover_the_three_sources() -> None:
|
|
assert set(lang.ORIGINS) == {"unknown", "umbratrans", "operator"}
|
|
|
|
|
|
def test_matches_the_working_reference_map() -> None:
|
|
"""The renderer that actually produces 10 languages is the authority.
|
|
|
|
If the registry and render_lang_test.py disagree, one of them is wrong and
|
|
the audio is mis-voiced. This is the test that catches it.
|
|
"""
|
|
src = (REPO / "scripts" / "render_lang_test.py").read_text(encoding="utf-8")
|
|
block = re.search(r"LANG_CODES = \{(.*?)\}", src, re.S)
|
|
assert block is not None, "LANG_CODES not found - did the renderer change?"
|
|
pairs = dict(re.findall(r'"([A-Z]{2})": "(\w+)"', block.group(1)))
|
|
assert len(pairs) == 10
|
|
for code, model_name in pairs.items():
|
|
assert lang.tts_name(code.lower()) == model_name, (code, model_name)
|