The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.
The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.
Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
270 lines
8.6 KiB
Python
270 lines
8.6 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""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 == {}
|