Files
UmbraAudio/tests/test_translation_resolution.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

267 lines
8.6 KiB
Python

"""Tests for resolving a translation from the DB and the on-disk sample files.
The behaviours pinned here are the ones that made the review UI lie: a valid
Chinese translation existed on disk but the page showed English, and the DB
held an English string under a ``zh-Hans`` code that the page would have
rendered as "translated" if it had simply trusted the row.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
import pytest
from umbraaudio import translations as tr
def _seed(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE project (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE chapters (id INTEGER PRIMARY KEY, chapter_index INTEGER);
CREATE TABLE utterances (
id INTEGER PRIMARY KEY,
chapter_id INTEGER,
utterance_index INTEGER,
speaker_name TEXT,
text TEXT,
context_before TEXT,
context_after TEXT,
language_code TEXT,
language_origin TEXT
);
INSERT INTO project (id, name) VALUES (1, 'Test');
INSERT INTO chapters (id, chapter_index) VALUES (1, 1);
INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
(1, 1, 0, 'Narrator', 'Welcome to the book.'),
(2, 1, 1, 'Kainda', 'Hello, I am the main character.');
"""
)
tr.ensure_tables(conn)
conn.commit()
@pytest.fixture
def conn() -> sqlite3.Connection:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
_seed(c)
yield c
c.close()
def _write_sample(directory: Path, code: str, texts: list[str], chapter_id: int = 1) -> None:
directory.mkdir(parents=True, exist_ok=True)
payload = {
"project": "Test",
"chapter_id": chapter_id,
"language_code": code,
"utterances": [
{"index": str(i), "speaker": "Narrator", "type": "narration", "text": text}
for i, text in enumerate(texts)
],
}
(directory / f"sample_ch1_{code.upper()}.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
# --- code normalisation ---------------------------------------------------
@pytest.mark.parametrize(
("value", "expected"),
[
("zh", "zh"),
("zh-Hans", "zh"),
("zh_CN", "zh"),
("EN", "en"),
("en-US", "en"),
("Russian", "ru"),
("nonsense", None),
("", None),
(None, None),
],
)
def test_canonical_code_tolerates_script_subtags(value: str | None, expected: str | None) -> None:
assert tr.canonical_code(value) == expected
# --- reading the sample files ---------------------------------------------
def test_sample_translations_dir_is_beside_the_project(tmp_path: Path) -> None:
# .umbra is gitignored runtime state; the translations are project content.
assert tr.sample_translations_dir(tmp_path) == tmp_path / "samples" / "translations"
def test_load_sample_translations_reads_every_file(tmp_path: Path) -> None:
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "en", ["Welcome.", "Hello."])
_write_sample(directory, "zh", ["欢迎。", "你好。"])
files = tr.load_sample_translations(tmp_path)
assert set(files) == {"en", "zh"}
assert files["zh"][(1, 0)] == "欢迎。"
assert files["zh"][(1, 1)] == "你好。"
def test_load_sample_translations_survives_a_broken_file(tmp_path: Path) -> None:
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "zh", ["欢迎。", "你好。"])
(directory / "sample_ch1_BROKEN.json").write_text("{not json", encoding="utf-8")
files = tr.load_sample_translations(tmp_path)
assert files["zh"][(1, 0)] == "欢迎。"
def test_load_sample_translations_missing_directory_is_empty(tmp_path: Path) -> None:
assert tr.load_sample_translations(tmp_path) == {}
# --- resolving ------------------------------------------------------------
def test_sample_file_supplies_a_translation_the_db_does_not_have(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "en", ["Welcome.", "Hello."])
_write_sample(directory, "zh", ["欢迎。", "你好。"])
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved[(1, 0)].text == "欢迎。"
assert resolved[(1, 1)].text == "你好。"
assert resolved[(1, 0)].origin == "sample"
def test_english_is_not_reported_as_a_translation(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
# The English file is the reference the other files are compared against,
# so English must never come back as a translation of itself.
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "en", ["Welcome.", "Hello."])
resolved = tr.resolved_translations(conn, tmp_path, "en")
assert resolved == {}
def test_db_row_wins_over_the_sample_file(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "en", ["Welcome.", "Hello."])
_write_sample(directory, "zh", ["欢迎。", "你好。"])
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status, origin)
VALUES (1, 1, 'zh', 'Chinese', '从数据库来的欢迎。', 'confirmed', 'umbratrans')
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved[(1, 0)].text == "从数据库来的欢迎。"
assert resolved[(1, 0)].status == "confirmed"
assert resolved[(1, 0)].origin == "umbratrans"
# The second utterance still comes from the file.
assert resolved[(1, 1)].text == "你好。"
def test_db_row_holding_the_english_source_is_a_placeholder_not_a_translation(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
# This is the shape of the one row that was actually in Deal Hunter's DB:
# the English text stored under a zh-Hans code. Rendering it would show
# English under a Chinese heading and claim it was translated.
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status, origin)
VALUES (1, 1, 'zh-Hans', 'Chinese', 'Welcome to the book.', 'draft', 'umbratrans')
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved == {}
def test_script_subtag_rows_are_matched_to_the_dropdown_code(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status, origin)
VALUES (1, 1, 'zh-Hans', 'Chinese', '从数据库来的欢迎。', 'draft', 'umbratrans')
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved[(1, 0)].text == "从数据库来的欢迎。"
def test_a_row_for_another_language_is_not_returned(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status, origin)
VALUES (1, 1, 'ru', 'Russian', 'Добро пожаловать.', 'draft', 'umbratrans')
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved == {}
def test_missing_translations_table_is_not_an_error(
tmp_path: Path,
) -> None:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
c.executescript(
"""
CREATE TABLE utterances (
id INTEGER PRIMARY KEY, chapter_id INTEGER, utterance_index INTEGER,
speaker_name TEXT, text TEXT
);
"""
)
c.commit()
try:
assert tr.resolved_translations(c, tmp_path, "zh") == {}
finally:
c.close()
def test_untranslated_target_file_is_not_counted_as_translated(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
# A target file whose text is identical to the source file has not been
# translated; reporting it as translated would hide the gap.
directory = tr.sample_translations_dir(tmp_path)
_write_sample(directory, "en", ["Welcome.", "Hello."])
_write_sample(directory, "ru", ["Welcome.", "Hello."])
resolved = tr.resolved_translations(conn, tmp_path, "ru")
assert resolved == {}