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.
267 lines
8.6 KiB
Python
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 == {}
|