Files
UmbraAudio/tests/test_languages.py
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
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.
2026-11-06 16:55:49 -08:00

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)