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.
146 lines
4.6 KiB
Python
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 == {}
|