Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text
Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Unit tests for the translation cleanup and QA helpers.
|
|
|
|
The pipeline calls an LLM, so these tests deliberately cover only the
|
|
deterministic parts: prompt-leak removal, quote normalisation, English
|
|
residue detection, JSON extraction and the mechanical QA checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE.parent))
|
|
|
|
from llm import parse_json # noqa: E402
|
|
from pipeline import latin_residue, sanitize # noqa: E402
|
|
from qa import check_chapter # noqa: E402
|
|
|
|
|
|
class SanitizeTests(unittest.TestCase):
|
|
def test_strips_dialogue_label(self) -> None:
|
|
self.assertEqual(sanitize("(对话;凯因达)“我在游艇上。”"), "“我在游艇上。”")
|
|
|
|
def test_strips_narration_label(self) -> None:
|
|
self.assertEqual(sanitize("(旁白)凯因达登上了游艇。"), "凯因达登上了游艇。")
|
|
|
|
def test_strips_paragraph_id(self) -> None:
|
|
self.assertEqual(sanitize("[ch-6-p0000] “你好。”"), "“你好。”")
|
|
|
|
def test_title_and_brand(self) -> None:
|
|
self.assertEqual(sanitize("《交易猎人》"), "《Deal Hunter》")
|
|
self.assertEqual(sanitize("由乌姆布拉音频系统制作"), "由Umbra Audio制作")
|
|
|
|
def test_missing_hour(self) -> None:
|
|
self.assertEqual(sanitize("她在小时前登船。"), "她一小时前登船。")
|
|
|
|
def test_straight_quotes_become_chinese(self) -> None:
|
|
self.assertEqual(sanitize('"你好",她说。'), "“你好”,她说。")
|
|
|
|
def test_unmatched_closing_quote_removed(self) -> None:
|
|
self.assertEqual(sanitize("凯因达登上了游艇。”"), "凯因达登上了游艇。")
|
|
|
|
def test_unmatched_opening_quote_closed(self) -> None:
|
|
self.assertEqual(sanitize("“你好"), "“你好”")
|
|
|
|
def test_ellipsis_and_dash(self) -> None:
|
|
self.assertEqual(sanitize("等等... 然后--"), "等等…… 然后——")
|
|
|
|
|
|
class ResidueTests(unittest.TestCase):
|
|
def test_lowercase_english_is_flagged(self) -> None:
|
|
common, proper = latin_residue("她被 escorted 到了舱室。")
|
|
self.assertEqual(common, ["escorted"])
|
|
self.assertEqual(proper, [])
|
|
|
|
def test_allowed_names_are_ignored(self) -> None:
|
|
common, proper = latin_residue("Deal Hunter 和 Hunter 在 Umbra Audio。")
|
|
self.assertEqual(common, [])
|
|
self.assertEqual(proper, [])
|
|
|
|
def test_unknown_capitalised_name_is_reported(self) -> None:
|
|
common, proper = latin_residue("这是 Foo 的船。")
|
|
self.assertEqual(common, [])
|
|
self.assertEqual(proper, ["Foo"])
|
|
|
|
|
|
class JsonTests(unittest.TestCase):
|
|
def test_plain_json(self) -> None:
|
|
self.assertEqual(parse_json('{"a": 1}'), {"a": 1})
|
|
|
|
def test_fenced_json(self) -> None:
|
|
self.assertEqual(parse_json('```json\n{"a": 1}\n```'), {"a": 1})
|
|
|
|
def test_json_with_prose(self) -> None:
|
|
self.assertEqual(parse_json('结果如下:\n{"a": 1}\n完毕。'), {"a": 1})
|
|
|
|
|
|
class QaTests(unittest.TestCase):
|
|
def test_missing_paragraph_is_block(self) -> None:
|
|
chapter = {
|
|
"id": "ch-x",
|
|
"index": 1,
|
|
"title": "Test",
|
|
"paragraphs": [
|
|
{"id": "ch-x-p0000", "type": "narration", "text": "One."},
|
|
{"id": "ch-x-p0001", "type": "narration", "text": "Two."},
|
|
],
|
|
}
|
|
data = {"paragraphs": {"ch-x-p0000": "第一段。"}}
|
|
issues, metrics = check_chapter(chapter, data)
|
|
kinds = {i["kind"] for i in issues}
|
|
self.assertIn("missing", kinds)
|
|
self.assertEqual(metrics["translated"], 1)
|
|
|
|
def test_metadata_leak_and_residue(self) -> None:
|
|
chapter = {
|
|
"id": "ch-x",
|
|
"index": 1,
|
|
"title": "Test",
|
|
"paragraphs": [
|
|
{"id": "ch-x-p0000", "type": "narration", "text": "One."},
|
|
{"id": "ch-x-p0001", "type": "narration", "text": "The Albert system is far."},
|
|
],
|
|
}
|
|
data = {
|
|
"paragraphs": {
|
|
"ch-x-p0000": "(旁白)第一段 escorted。",
|
|
"ch-x-p0001": "阿尔伯特系统很远。",
|
|
}
|
|
}
|
|
issues, _ = check_chapter(chapter, data)
|
|
kinds = {i["kind"] for i in issues}
|
|
self.assertIn("metadata_leak", kinds)
|
|
self.assertIn("english_residue", kinds)
|
|
self.assertIn("system_mistranslated", kinds)
|
|
|
|
def test_ship_systems_are_not_star_systems(self) -> None:
|
|
chapter = {
|
|
"id": "ch-x",
|
|
"index": 1,
|
|
"title": "Test",
|
|
"paragraphs": [
|
|
{"id": "ch-x-p0000", "type": "narration", "text": "Keep away from the ship systems."},
|
|
],
|
|
}
|
|
data = {"paragraphs": {"ch-x-p0000": "远离飞船系统。"}}
|
|
issues, _ = check_chapter(chapter, data)
|
|
self.assertNotIn("system_mistranslated", {i["kind"] for i in issues})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|