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.
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""Tests for source-language tagging and per-language translations (spec 31).
|
|
|
|
The behaviours pinned here are the ones that would silently corrupt audio or
|
|
lose alignment if they regressed: the source/target axis split, the per-language
|
|
voiced flag, idempotent re-delivery, and refusal to overwrite approved text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
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), (2, 2);
|
|
INSERT INTO utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
|
|
(1, 1, 0, 'narrator', 'The ship landed.'),
|
|
(2, 1, 1, 'Kabir', 'Hola, amigo.'),
|
|
(3, 1, 2, 'Kabir', 'We should go.'),
|
|
(4, 2, 0, 'narrator', 'Later that day.'),
|
|
(5, 2, 1, 'Nanny', 'Danger.');
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def conn() -> sqlite3.Connection:
|
|
c = sqlite3.connect(":memory:")
|
|
c.row_factory = sqlite3.Row
|
|
_seed(c)
|
|
yield c
|
|
c.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def plain_conn() -> sqlite3.Connection:
|
|
"""No row_factory - proves the accessors do not assume sqlite3.Row."""
|
|
c = sqlite3.connect(":memory:")
|
|
_seed(c)
|
|
yield c
|
|
c.close()
|
|
|
|
|
|
# --- source axis ---------------------------------------------------------
|
|
|
|
|
|
def test_source_language_defaults_to_english(conn: sqlite3.Connection) -> None:
|
|
assert tr.source_language(conn) == "en"
|
|
|
|
|
|
def test_source_language_reads_project_column() -> None:
|
|
c = sqlite3.connect(":memory:")
|
|
c.execute("CREATE TABLE project (id INTEGER PRIMARY KEY, source_language TEXT)")
|
|
c.execute("INSERT INTO project VALUES (1, 'ZH')")
|
|
assert tr.source_language(c) == "zh"
|
|
c.close()
|
|
|
|
|
|
def test_set_source_language_normalises_and_reports_change(conn: sqlite3.Connection) -> None:
|
|
assert tr.set_source_language(conn, 2, "Spanish") is True
|
|
row = conn.execute(
|
|
"SELECT language_code, language_origin FROM utterances WHERE id=2"
|
|
).fetchone()
|
|
assert row["language_code"] == "es"
|
|
assert row["language_origin"] == "umbratrans"
|
|
# Setting the same value again is not a change.
|
|
assert tr.set_source_language(conn, 2, "es") is False
|
|
|
|
|
|
def test_set_source_language_unknown_id_is_not_an_error(conn: sqlite3.Connection) -> None:
|
|
assert tr.set_source_language(conn, 9999, "es") is False
|
|
|
|
|
|
def test_set_source_language_rejects_bad_origin(conn: sqlite3.Connection) -> None:
|
|
with pytest.raises(ValueError):
|
|
tr.set_source_language(conn, 1, "es", "nonsense")
|
|
|
|
|
|
def test_operator_tag_outranks_machine_reidentification(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR)
|
|
assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS) is False
|
|
row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone()
|
|
assert row["language_code"] == "es"
|
|
|
|
|
|
def test_force_overrides_operator_tag(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es", tr.lang.ORIGIN_OPERATOR)
|
|
assert tr.set_source_language(conn, 2, "fr", tr.lang.ORIGIN_UMBRATRANS, force=True) is True
|
|
row = conn.execute("SELECT language_code FROM utterances WHERE id=2").fetchone()
|
|
assert row["language_code"] == "fr"
|
|
|
|
|
|
def test_foreign_lines_are_excluded_from_translation(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es")
|
|
assert tr.foreign_utterance_ids(conn) == {2}
|
|
to_translate = tr.utterances_to_translate(conn)
|
|
assert 2 not in to_translate
|
|
assert to_translate == [1, 3, 4, 5]
|
|
|
|
|
|
def test_untagged_rows_count_as_source_language(conn: sqlite3.Connection) -> None:
|
|
# NULL tag = the project's primary language (spec 31 section 6.1).
|
|
assert tr.foreign_utterance_ids(conn) == set()
|
|
assert tr.utterances_to_translate(conn) == [1, 2, 3, 4, 5]
|
|
|
|
|
|
def test_effective_language_resolves_null_to_primary(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es")
|
|
assert tr.effective_language(conn, 1) == "en"
|
|
assert tr.effective_language(conn, 2) == "es"
|
|
|
|
|
|
def test_unknown_tag_never_falls_back_to_the_primary_language(conn: sqlite3.Connection) -> None:
|
|
"""A Klingon line must not be spoken as English.
|
|
|
|
This is the fail-loud guarantee (spec 31 section 4.3). If an unrecognised tag
|
|
collapsed to NULL, the line would look untagged, resolve to 'en', and be
|
|
voiced with English phonology - wrong, and silent about being wrong.
|
|
"""
|
|
tr.set_source_language(conn, 1, "klingon")
|
|
assert tr.effective_language(conn, 1) == "klingon"
|
|
assert 1 in tr.foreign_utterance_ids(conn), "an unreadable language is not the primary one"
|
|
assert not tr.lang.is_voiceable(tr.effective_language(conn, 1))
|
|
|
|
|
|
# --- target axis ---------------------------------------------------------
|
|
|
|
|
|
def test_import_stores_a_translation(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(
|
|
conn,
|
|
1,
|
|
{
|
|
"target_language": "de",
|
|
"utterances": [
|
|
{"utterance_id": 1, "source_language": "en", "text": "Das Schiff landete."}
|
|
],
|
|
},
|
|
)
|
|
assert report.accepted == 1
|
|
assert report.source_tagged == 1
|
|
row = conn.execute(
|
|
"SELECT language_name, text, status, origin, voiced"
|
|
" FROM utterance_translations WHERE utterance_id=1"
|
|
).fetchone()
|
|
assert row["language_name"] == "German"
|
|
assert row["text"] == "Das Schiff landete."
|
|
assert row["status"] == "draft"
|
|
assert row["origin"] == "umbratrans"
|
|
assert row["voiced"] == 1, "a normal translation is voiced by default"
|
|
|
|
|
|
def test_import_is_idempotent_on_redelivery(conn: sqlite3.Connection) -> None:
|
|
payload = {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Erste"}]}
|
|
tr.import_translations(conn, 1, payload)
|
|
payload["utterances"][0]["text"] = "Zweite"
|
|
tr.import_translations(conn, 1, payload)
|
|
rows = conn.execute("SELECT text FROM utterance_translations WHERE utterance_id=1").fetchall()
|
|
assert len(rows) == 1, "re-delivery updates in place, it does not accumulate"
|
|
assert rows[0]["text"] == "Zweite"
|
|
|
|
|
|
def test_confirmed_translation_is_not_overwritten(conn: sqlite3.Connection) -> None:
|
|
tr.import_translations(
|
|
conn,
|
|
1,
|
|
{"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Approved"}]},
|
|
confirm=True,
|
|
)
|
|
report = tr.import_translations(
|
|
conn, 1, {"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Sneaky"}]}
|
|
)
|
|
assert report.skipped_confirmed == 1
|
|
assert report.accepted == 0
|
|
row = conn.execute(
|
|
"SELECT text, status FROM utterance_translations WHERE utterance_id=1"
|
|
).fetchone()
|
|
assert row["text"] == "Approved"
|
|
assert row["status"] == "confirmed"
|
|
|
|
|
|
def test_foreign_line_returns_identification_only(conn: sqlite3.Connection) -> None:
|
|
"""The whole point of the source axis: a Spanish line is not translated."""
|
|
report = tr.import_translations(
|
|
conn,
|
|
1,
|
|
{"target_language": "de", "utterances": [{"utterance_id": 2, "source_language": "es"}]},
|
|
)
|
|
assert report.source_tagged == 1
|
|
assert report.foreign_untouched == 1
|
|
assert report.accepted == 0
|
|
assert (
|
|
conn.execute("SELECT COUNT(*) FROM utterance_translations WHERE utterance_id=2").fetchone()[
|
|
0
|
|
]
|
|
== 0
|
|
)
|
|
assert tr.effective_language(conn, 2) == "es"
|
|
|
|
|
|
def test_collapse_orphan_is_kept_with_a_reason(conn: sqlite3.Connection) -> None:
|
|
"""Three source sentences collapsing into two leaves one with no target."""
|
|
report = tr.import_translations(
|
|
conn,
|
|
1,
|
|
{
|
|
"target_language": "de",
|
|
"utterances": [
|
|
{"utterance_id": 1, "text": "Das Schiff landete. Wir gingen."},
|
|
{"utterance_id": 3, "voiced": False, "reason": "absorbed into utterance 1"},
|
|
],
|
|
},
|
|
)
|
|
assert report.accepted == 2
|
|
assert report.not_voiced == 1
|
|
row = conn.execute(
|
|
"SELECT voiced, review_reason FROM utterance_translations WHERE utterance_id=3"
|
|
).fetchone()
|
|
assert row["voiced"] == 0
|
|
assert row["review_reason"] == "absorbed into utterance 1"
|
|
orphans = tr.not_voiced_rows(conn, "de")
|
|
assert [o["utterance_id"] for o in orphans] == [3]
|
|
|
|
|
|
def test_gap_language_tag_is_stored_and_render_refused(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(
|
|
conn,
|
|
1,
|
|
{"target_language": "de", "utterances": [{"utterance_id": 1, "source_language": "nl"}]},
|
|
)
|
|
assert report.refused_voice == 1
|
|
assert tr.effective_language(conn, 1) == "nl"
|
|
refused = tr.refused_render_rows(conn)
|
|
assert len(refused) == 1
|
|
assert refused[0]["language_code"] == "nl"
|
|
assert "Dutch" in refused[0]["reason"]
|
|
|
|
|
|
def test_unknown_language_is_reported_not_crashed(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(
|
|
conn,
|
|
1,
|
|
{
|
|
"target_language": "de",
|
|
"utterances": [{"utterance_id": 1, "source_language": "klingon"}],
|
|
},
|
|
)
|
|
assert "klingon" in report.unknown_language
|
|
assert tr.refused_render_rows(conn)[0]["reason"].startswith("unrecognised")
|
|
|
|
|
|
def test_identification_only_payload_needs_no_target_language(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(
|
|
conn, 1, {"utterances": [{"utterance_id": 2, "source_language": "es"}]}
|
|
)
|
|
assert report.source_tagged == 1
|
|
assert report.problems == []
|
|
|
|
|
|
def test_text_without_target_language_is_a_problem(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(conn, 1, {"utterances": [{"utterance_id": 1, "text": "Hallo"}]})
|
|
assert report.problems and "target_language" in report.problems[0]
|
|
|
|
|
|
def test_row_without_utterance_id_is_reported(conn: sqlite3.Connection) -> None:
|
|
report = tr.import_translations(
|
|
conn, 1, {"target_language": "de", "utterances": [{"text": "x"}]}
|
|
)
|
|
assert report.problems and "utterance_id" in report.problems[0]
|
|
|
|
|
|
def test_utterances_must_be_a_list(conn: sqlite3.Connection) -> None:
|
|
with pytest.raises(ValueError):
|
|
tr.import_translations(conn, 1, {"target_language": "de", "utterances": {"a": 1}})
|
|
|
|
|
|
def test_plain_tuple_connection_is_supported(plain_conn: sqlite3.Connection) -> None:
|
|
"""No row_factory set - the accessors must still work."""
|
|
tr.set_source_language(plain_conn, 2, "es")
|
|
assert tr.effective_language(plain_conn, 2) == "es"
|
|
report = tr.import_translations(
|
|
plain_conn,
|
|
1,
|
|
{"target_language": "de", "utterances": [{"utterance_id": 1, "text": "Hallo"}]},
|
|
)
|
|
assert report.accepted == 1
|
|
|
|
|
|
# --- coverage and export -------------------------------------------------
|
|
|
|
|
|
def test_coverage_counts_the_gap_honestly(conn: sqlite3.Connection) -> None:
|
|
tr.import_translations(
|
|
conn,
|
|
1,
|
|
{
|
|
"target_language": "de",
|
|
"utterances": [
|
|
{"utterance_id": 1, "text": "Eins"},
|
|
{"utterance_id": 3, "voiced": False, "reason": "merged"},
|
|
],
|
|
},
|
|
)
|
|
cov = tr.coverage(conn, "de")
|
|
assert cov["total"] == 5
|
|
assert cov["delivered"] == 2
|
|
assert cov["voiced"] == 1
|
|
assert cov["not_voiced"] == 1
|
|
assert cov["missing"] == 3
|
|
|
|
|
|
def test_coverage_of_an_untouched_language_is_all_missing(conn: sqlite3.Connection) -> None:
|
|
cov = tr.coverage(conn, "fr")
|
|
assert cov["delivered"] == 0 and cov["missing"] == 5 and cov["not_voiced"] == 0
|
|
|
|
|
|
def test_export_payload_shape(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es")
|
|
payload = tr.build_export_payload(conn)
|
|
assert payload["book_language"] == "en"
|
|
assert payload["utterance_count"] == 5
|
|
assert payload["foreign_count"] == 1
|
|
first = payload["utterances"][0]
|
|
assert first["source_text"] == "The ship landed."
|
|
assert first["language_code"] is None, "untagged lines are null on the first pass"
|
|
spanish = [u for u in payload["utterances"] if u["utterance_id"] == 2][0]
|
|
assert spanish["language_code"] == "es", "a known tag is sent so it is not re-identified"
|
|
|
|
|
|
def test_export_can_exclude_foreign_lines(conn: sqlite3.Connection) -> None:
|
|
tr.set_source_language(conn, 2, "es")
|
|
payload = tr.build_export_payload(conn, include_foreign=False)
|
|
assert payload["utterance_count"] == 4
|
|
assert 2 not in [u["utterance_id"] for u in payload["utterances"]]
|
|
assert payload["foreign_count"] == 1, "the count is still reported even when omitted"
|
|
|
|
|
|
def test_ensure_tables_is_idempotent(conn: sqlite3.Connection) -> None:
|
|
tr.ensure_tables(conn)
|
|
tr.ensure_tables(conn)
|
|
assert (
|
|
conn.execute(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE name='utterance_translations'"
|
|
).fetchone()[0]
|
|
== 1
|
|
)
|