Files
office_translator/tests/test_new_features.py
sepehr 526c87348f
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
2026-08-29 18:38:09 +02:00

345 lines
13 KiB
Python

"""New feature tests: formality/regional prompts, TM, bilingual output,
QA report, OpenAI JSON batching, font hints."""
import pytest
from docx import Document
from openpyxl import Workbook
from services.glossary_service import build_full_prompt
from services.quality.qa_report import (
_number_fidelity,
_untranslated_ratio,
run_qa_report,
)
from services.translation_tm import TMScope, translate_with_tm
from services.translation_cache import reset_cache_for_tests
from translators.bilingual import make_bilingual_docx
from translators.word_translator import WordTranslator, _font_hints_for_target
# ===========================================================================
# Formality + regional variant in prompts
# ===========================================================================
class TestFormalityPrompt:
def test_formal_adds_tone_directive(self):
prompt = build_full_prompt(None, None, "fr", "en", formality="formal")
assert "TONE:" in prompt and "formal" in prompt
def test_informal_adds_tone_directive(self):
prompt = build_full_prompt(None, None, "fr", "en", formality="informal")
assert "TONE:" in prompt and "informal" in prompt
def test_no_formality_no_tone(self):
assert "TONE:" not in build_full_prompt(None, None, "fr", "en")
def test_regional_variant_auto(self):
prompt = build_full_prompt(None, None, "fr", "pt-BR")
assert "REGIONAL VARIANT" in prompt
assert "Portuguese" in prompt
def test_no_region_no_variant(self):
assert "REGIONAL VARIANT" not in build_full_prompt(None, None, "fr", "en")
# ===========================================================================
# Translation memory (per-user scoping)
# ===========================================================================
class TestTranslationMemory:
@pytest.fixture(autouse=True)
def clean_cache(self):
reset_cache_for_tests()
yield
reset_cache_for_tests()
def test_no_scope_passthrough(self):
calls = []
def fake_translate(texts):
calls.extend(texts)
return [t.upper() for t in texts]
out = translate_with_tm(
["a", "b"], "en", "fr", "fake", None, fake_translate
)
assert out == ["A", "B"]
assert calls == ["a", "b"]
def test_reuse_on_second_call_same_user(self):
from services.translation_cache import get_cache
get_cache() # init LRU backend
calls = []
def fake_translate(texts):
calls.extend(texts)
return [f"T:{t}" for t in texts]
scope = TMScope.from_prompt("user-1", "ctx")
first = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
assert first == ["T:hello", "T:world"]
assert len(calls) == 2
second = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
assert second == ["T:hello", "T:world"]
# Everything came from the TM — no new provider calls
assert len(calls) == 2
def test_users_are_isolated(self):
from services.translation_cache import get_cache
get_cache()
out = translate_with_tm(
["x"], "en", "fr", "fake",
TMScope.from_prompt("user-A", None), lambda ts: [f"A:{ts[0]}"],
)
assert out == ["A:x"]
out_b = translate_with_tm(
["x"], "en", "fr", "fake",
TMScope.from_prompt("user-B", None), lambda ts: [f"B:{ts[0]}"],
)
assert out_b == ["B:x"]
def test_identity_translations_not_stored(self):
from services.translation_cache import get_cache
get_cache()
scope = TMScope.from_prompt("user-1", None)
# identity provider: returns input unchanged (a provider failure)
translate_with_tm(["same"], "en", "fr", "fake", scope, lambda ts: ts)
out = translate_with_tm(
["same"], "en", "fr", "fake", scope, lambda ts: ["CALLED"]
)
# Not poisoned by the identity entry: the provider was consulted
assert out == ["CALLED"]
def test_different_prompt_context_misses(self):
from services.translation_cache import get_cache
get_cache()
s1 = TMScope.from_prompt("u", "prompt-A")
s2 = TMScope.from_prompt("u", "prompt-B")
translate_with_tm(["k"], "en", "fr", "fake", s1, lambda ts: ["v1"])
out = translate_with_tm(["k"], "en", "fr", "fake", s2, lambda ts: ["v2"])
assert out == ["v2"]
# ===========================================================================
# Bilingual output
# ===========================================================================
class TestBilingual:
def test_source_interleaved_above_translation(self, tmp_path):
src = Document()
src.add_paragraph("Bonjour le monde")
src.add_paragraph("")
src.add_paragraph("Deuxième paragraphe")
src_in = tmp_path / "src.docx"
src.save(str(src_in))
tr = Document()
tr.add_paragraph("Hello world")
tr.add_paragraph("")
tr.add_paragraph("Second paragraph")
tr_out = tmp_path / "tr.docx"
tr.save(str(tr_out))
result = make_bilingual_docx(src_in, tr_out, tmp_path / "bi.docx")
assert result is not None
doc = Document(str(result))
texts = [p.text for p in doc.paragraphs]
# Source paragraph precedes its translation
assert texts[0] == "Bonjour le monde"
assert texts[1] == "Hello world"
assert texts[3] == "Deuxième paragraphe"
assert texts[4] == "Second paragraph"
def test_structure_mismatch_returns_none(self, tmp_path):
a = Document(); a.add_paragraph("x"); a.save(str(tmp_path / "a.docx"))
b = Document(); b.add_paragraph("x"); b.add_paragraph("y")
b.save(str(tmp_path / "b.docx"))
assert make_bilingual_docx(
tmp_path / "a.docx", tmp_path / "b.docx", tmp_path / "bi.docx"
) is None
# ===========================================================================
# QA report
# ===========================================================================
class TestQAReport:
def test_number_fidelity_full(self):
r = _number_fidelity("Prix: 12,50 € sur 3 pages", "Price: 12.50 € on 3 pages")
assert r["fidelity"] >= 0.9
def test_number_fidelity_missing(self):
r = _number_fidelity("Ref 123 and 456", "Ref 123 only")
assert r["fidelity"] < 1.0
def test_untranslated_ratio_high_when_identity(self):
identity_source = (
"This document contains substantial english text about pricing, "
"delivery schedules and quarterly reporting obligations for the "
"regional sales team and their managers."
)
assert _untranslated_ratio(identity_source, identity_source) > 0.8
def test_untranslated_ratio_low_when_translated(self):
assert _untranslated_ratio(
"Ce document contient beaucoup de mots différents sur la facturation, "
"les échéances de livraison et les obligations trimestrielles.",
"This document holds many different words about invoicing, "
"delivery deadlines and quarterly obligations.",
) < 0.35
def test_run_qa_report_on_docx(self, tmp_path):
src = Document()
src.add_paragraph("Le total est de 42 euros pour la livraison.")
src.add_paragraph("Merci de votre confiance renouvelée.")
src_in = tmp_path / "in.docx"
src.save(str(src_in))
out_doc = Document()
out_doc.add_paragraph("The total is 42 euros for the delivery.")
out_doc.add_paragraph("Thank you for your renewed trust.")
out_p = tmp_path / "out.docx"
out_doc.save(str(out_p))
report = run_qa_report(src_in, out_p, "en", ".docx")
assert report is not None
assert report["score"] >= 80
assert report["numbers"]["fidelity"] == 1.0
def test_run_qa_report_scores_untranslated_low(self, tmp_path):
long_fr = (
"Ce paragraphe contient suffisamment de mots différents pour "
"satisfaire l'heuristique du rapport qualité automatisé, avec "
"des notions de facturation, livraison et obligations."
)
src = Document()
src.add_paragraph(long_fr)
src_in = tmp_path / "in.docx"
src.save(str(src_in))
import shutil
out_p = tmp_path / "out.docx"
shutil.copy(str(src_in), str(out_p)) # "translation" = original
report = run_qa_report(src_in, out_p, "en", ".docx")
assert report is not None
assert report["score"] < 50
assert report["untranslated_ratio"] > 0.5
# ===========================================================================
# OpenAI JSON batching
# ===========================================================================
class TestOpenAIBatch:
def _provider(self):
from services.providers.openai_provider import OpenAITranslationProvider
return OpenAITranslationProvider(api_key="test-key", model="gpt-test")
def _requests(self, texts):
from services.providers.schemas import TranslationRequest
return [TranslationRequest(text=t, target_language="fr") for t in texts]
def test_batch_single_request_success(self, monkeypatch):
provider = self._provider()
seen = {}
def fake_api(text, system_prompt):
import json
seen["text"] = text
items = json.loads(text)
reply = json.dumps(
[{"id": it["id"], "translation": f"FR:{it['text']}"} for it in items]
)
return reply, {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
out = provider.translate_batch(self._requests(["one", "two", "three"]))
assert [r.translated_text for r in out] == [
"FR:one", "FR:two", "FR:three",
]
# One API call for the whole batch
assert isinstance(seen.get("text"), str) and '"id"' in seen["text"]
def test_batch_falls_back_on_bad_json(self, monkeypatch):
provider = self._provider()
calls = {"batch": 0, "single": 0}
def fake_api(text, system_prompt):
if text.startswith("["):
calls["batch"] += 1
return "not valid json at all", {}
calls["single"] += 1
return f"FR:{text}", {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
out = provider.translate_batch(self._requests(["alpha", "beta"]))
assert [r.translated_text for r in out] == ["FR:alpha", "FR:beta"]
assert calls["batch"] == 1
assert calls["single"] == 2
def test_batch_falls_back_on_wrong_length(self, monkeypatch):
import json
provider = self._provider()
def fake_api(text, system_prompt):
return json.dumps([{"id": 0, "translation": "only one"}]), {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
def fail_single(req):
from services.providers.schemas import TranslationResponse
return TranslationResponse(
translated_text=f"S:{req.text}", provider_name="openai"
)
monkeypatch.setattr(provider, "translate_text", fail_single)
out = provider.translate_batch(self._requests(["a", "b"]))
assert [r.translated_text for r in out] == ["S:a", "S:b"]
# ===========================================================================
# CJK font hints (Word)
# ===========================================================================
class TestFontHints:
def test_hint_mapping(self):
assert _font_hints_for_target("zh-CN")[0] == "SimSun"
assert _font_hints_for_target("ja")[0] == "Yu Mincho"
assert _font_hints_for_target("ar")[1] == "Arial"
assert _font_hints_for_target("en") == (None, None)
def test_applied_on_translate(self, tmp_path):
class _CJK:
def get_name(self):
return "mock"
def is_available(self):
return True
def translate_batch(self, texts, target_language, source_language="auto"):
return [f"译:{t}" for t in texts]
from docx.oxml.ns import qn as _qn
doc = Document()
doc.add_paragraph("Hello")
src = tmp_path / "in.docx"
doc.save(str(src))
t = WordTranslator(provider=_CJK())
out = tmp_path / "out.docx"
t.translate_file(src, out, "zh-CN", "en")
result = Document(str(out))
para = result.paragraphs[0]
rFonts = para.runs[0]._r.find(_qn("w:rPr")).find(_qn("w:rFonts"))
assert rFonts is not None
assert rFonts.get(_qn("w:eastAsia")) == "SimSun"