"""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"