Files
UmbraAudio/tests/test_translation_gaps.py
Umbra Tools ff73c99f8d Show the reviewer why a translation lane has a gap, not just that it has one
A delivered gap is not a missing translation. The delivery arrives and says
this target language does not voice this utterance, with a reason - the
translation renders a different paragraph, say. That is a different problem
with a different fix, and the reviewer needs to be able to tell them apart.

The reason was being thrown away. The row is stored with an empty text and
a review_reason; the resolver skipped every row with empty text, so the gap
was dropped and the review lane said "Chinese has not been translated yet".
That is a false statement about a translation that was delivered and
rejected.

So the resolver now keeps a not-voiced row as a gap with its reason, the
chapter page renders it as a gap rather than as missing text, and the
language page lists the delivered gaps under "Needs attention" with their
reasons. A gap row is also no longer counted as translated text, so the
coverage the reviewer sees is the coverage the book has.

4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures).

Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any
target-language audio lane is whether Qwen3-TTS can speak Chinese with an
English voice reference. The probe answers it by transcription rather than
by listening - Whisper transcribes each clip and reports what language it
heard, and the English-reference clip reading Chinese is compared against
the same clip reading Chinese with English phonology, so the only variable
is the language parameter.
2026-11-06 21:56:28 -08:00

146 lines
4.6 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Tests for a delivered gap in the translation lane (spec 31 section 6.2).
A delivered gap is not a missing translation. The delivery arrived and said
this target language does not voice this utterance, with a reason - the
translation renders a different paragraph, say. That is a different problem
with a different fix, so the reason has to reach the reviewer.
The bug this guards against is quiet: the row is stored with an empty text,
and a resolver that skips empty text throws the reason away. The review lane
then says "Chinese has not been translated yet", which is a false statement
about a translation that was delivered and rejected.
"""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
import pytest
from umbraaudio import translations as tr
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture
def conn() -> sqlite3.Connection:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
c.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 utterances (id, chapter_id, utterance_index, speaker_name, text) VALUES
(1, 1, 0, 'narrator', 'The ship landed.'),
(2, 1, 1, 'Kainda', 'Welcome to the ship.'),
(3, 1, 2, 'narrator', 'A line that was dropped.');
"""
)
c.commit()
tr.ensure_tables(c)
yield c
c.close()
def _insert_gap(conn: sqlite3.Connection, utterance_id: int, reason: str) -> None:
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status,
origin, voiced, review_reason)
VALUES (1, ?, 'zh', 'Chinese', '', 'draft', 'umbratrans', 0, ?)
""",
(utterance_id, reason),
)
conn.commit()
def test_a_delivered_gap_survives_the_resolver_with_its_reason(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
_insert_gap(conn, 3, "translation belongs to another paragraph")
resolved = tr.resolved_translations(conn, tmp_path, "zh")
entry = resolved[(1, 2)]
assert entry.voiced is False
assert entry.reason == "translation belongs to another paragraph"
assert entry.text == ""
def test_a_delivered_gap_is_not_reported_as_translated_text(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
"""The gap must not inflate the coverage the reviewer sees."""
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status,
origin, voiced)
VALUES (1, 1, 'zh', 'Chinese', '飞船降落了。', 'draft', 'umbratrans', 1)
"""
)
conn.commit()
_insert_gap(conn, 3, "translation belongs to another paragraph")
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved[(1, 0)].text == "飞船降落了。"
assert resolved[(1, 0)].voiced is True
# The gap is present as a gap, not as translated text.
assert resolved[(1, 2)].text == ""
def test_a_voiced_row_with_no_text_is_still_not_a_gap(
conn: sqlite3.Connection, tmp_path: Path
) -> None:
"""An empty text with no gap flag is an empty row, not a delivered gap."""
conn.execute(
"""
INSERT INTO utterance_translations
(project_id, utterance_id, language_code, language_name, text, status,
origin, voiced)
VALUES (1, 3, 'zh', 'Chinese', '', 'draft', 'umbratrans', 1)
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert (1, 2) not in resolved
def test_a_gap_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, voiced, review_reason)
VALUES (1, 3, 'ru', 'Russian', '', 'draft', 'umbratrans', 0, 'not Russian')
"""
)
conn.commit()
resolved = tr.resolved_translations(conn, tmp_path, "zh")
assert resolved == {}