feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
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)
This commit is contained in:
@@ -101,7 +101,11 @@ def _get_redis_patcher(mock_redis):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orphan_deletion(temp_dirs):
|
||||
"""Test that orphaned files are deleted as per Story 2.15 (AC: #4)"""
|
||||
"""Test that orphaned files are deleted as per Story 2.15 (AC: #4).
|
||||
|
||||
Since the security fix (audit 2026-08-26), an orphan is only deleted
|
||||
after a grace period, so young in-flight files are never removed.
|
||||
"""
|
||||
cleanup_mod = _get_cleanup_module()
|
||||
FileCleanupManager = cleanup_mod.FileCleanupManager
|
||||
|
||||
@@ -117,6 +121,10 @@ async def test_orphan_deletion(temp_dirs):
|
||||
|
||||
manager = FileCleanupManager(uploads, outputs, temp, cleanup_interval_minutes=5)
|
||||
|
||||
# Make the orphan older than the grace period (default 15 min)
|
||||
old = time.time() - (manager.orphan_grace_seconds + 60)
|
||||
os.utime(orphan_file, (old, old))
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.keys.return_value = ["translation:file:job1"]
|
||||
mock_redis.get.return_value = json.dumps(
|
||||
@@ -182,6 +190,11 @@ async def test_cleanup_resilience(temp_dirs):
|
||||
f2 = uploads / "file2.txt"
|
||||
f2.write_text("file2")
|
||||
|
||||
# Make the files older than the grace period so cleanup actually deletes them
|
||||
old = time.time() - 7200
|
||||
os.utime(f1, (old, old))
|
||||
os.utime(f2, (old, old))
|
||||
|
||||
manager = FileCleanupManager(uploads, outputs, temp, max_file_age_minutes=1)
|
||||
|
||||
original_unlink = Path.unlink
|
||||
|
||||
@@ -17,6 +17,8 @@ DOWNLOAD_URL = "/api/v1/download"
|
||||
REGISTER_URL = "/api/v1/auth/register"
|
||||
LOGIN_URL = "/api/v1/auth/login"
|
||||
|
||||
AUTH_USER_ID = None # set by the authenticated_client fixture
|
||||
|
||||
VALID_USER = {
|
||||
"email": "download@example.com",
|
||||
"password": "Password123!",
|
||||
@@ -135,7 +137,9 @@ def client(users_file: Path, monkeypatch):
|
||||
@pytest.fixture()
|
||||
def authenticated_client(client):
|
||||
"""Client avec un utilisateur enregistre et authentifie."""
|
||||
client.post(REGISTER_URL, json=VALID_USER)
|
||||
global AUTH_USER_ID
|
||||
reg = client.post(REGISTER_URL, json=VALID_USER)
|
||||
AUTH_USER_ID = reg.json()["data"]["id"]
|
||||
response = client.post(
|
||||
LOGIN_URL,
|
||||
json={
|
||||
@@ -184,6 +188,7 @@ class TestDownloadEndpoint:
|
||||
job_id = "tr_test_no_output"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "test.xlsx",
|
||||
"output_path": None,
|
||||
@@ -205,6 +210,7 @@ class TestDownloadEndpoint:
|
||||
job_id = "tr_deleted_disk"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "deleted.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
@@ -224,6 +230,7 @@ class TestDownloadEndpoint:
|
||||
job_id = "tr_test_processing"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "processing",
|
||||
"progress_percent": 50,
|
||||
"file_name": "test.xlsx",
|
||||
@@ -241,6 +248,7 @@ class TestDownloadEndpoint:
|
||||
job_id = "tr_test_queued"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "queued",
|
||||
"file_name": "test.xlsx",
|
||||
}
|
||||
@@ -257,6 +265,7 @@ class TestDownloadEndpoint:
|
||||
job_id = "tr_test_failed"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "failed",
|
||||
"error_message": "Something went wrong",
|
||||
"file_name": "test.xlsx",
|
||||
@@ -288,6 +297,7 @@ class TestContentDisposition:
|
||||
job_id = "tr_test_disposition"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "report.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
@@ -310,6 +320,7 @@ class TestContentDisposition:
|
||||
job_id = "tr_test_docx"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "document.docx",
|
||||
"file_extension": ".docx",
|
||||
@@ -331,6 +342,7 @@ class TestContentDisposition:
|
||||
job_id = "tr_test_pptx"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "presentation.pptx",
|
||||
"file_extension": ".pptx",
|
||||
@@ -364,6 +376,7 @@ class TestFileDeletion:
|
||||
job_id = "tr_test_delete"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "to_delete.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
@@ -400,6 +413,7 @@ class TestMIMETypes:
|
||||
job_id = "tr_test_mime_xlsx"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "test.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
@@ -424,6 +438,7 @@ class TestMIMETypes:
|
||||
job_id = "tr_test_mime_docx"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "test.docx",
|
||||
"file_extension": ".docx",
|
||||
@@ -448,6 +463,7 @@ class TestMIMETypes:
|
||||
job_id = "tr_test_mime_pptx"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "test.pptx",
|
||||
"file_extension": ".pptx",
|
||||
@@ -489,6 +505,7 @@ class TestFileExpired:
|
||||
job_id = "tr_test_not_ready_msg"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "processing",
|
||||
"progress_percent": 30,
|
||||
"file_name": "test.xlsx",
|
||||
@@ -520,11 +537,12 @@ class TestDownloadIntegration:
|
||||
job_id = "tr_test_binary"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "binary_test.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
"output_path": str(output_file),
|
||||
"user_id": None,
|
||||
"user_id": AUTH_USER_ID,
|
||||
}
|
||||
|
||||
response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}")
|
||||
@@ -557,6 +575,7 @@ class TestErrorDetails:
|
||||
job_id = "tr_test_details"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "processing",
|
||||
"progress_percent": 45,
|
||||
"file_name": "test.xlsx",
|
||||
@@ -589,6 +608,7 @@ class TestDownloadAuthorization:
|
||||
job_id = "tr_other_user123"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "other.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
@@ -622,18 +642,19 @@ class TestDownloadAuthorization:
|
||||
job_id = "tr_own_file123"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": AUTH_USER_ID,
|
||||
"status": "completed",
|
||||
"file_name": "own.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
"output_path": str(output_file),
|
||||
"user_id": None,
|
||||
"user_id": AUTH_USER_ID,
|
||||
}
|
||||
|
||||
response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_anonymous_user_can_download_public_job(self, client, tmp_path):
|
||||
"""Anonymous users can download jobs without user_id (public)"""
|
||||
def test_anonymous_job_requires_token(self, client, tmp_path):
|
||||
"""Jobs without an owner require the secret per-job token (fix 2026-08-26)"""
|
||||
from routes import translate_routes
|
||||
|
||||
output_file = tmp_path / "public_job.xlsx"
|
||||
@@ -642,12 +663,19 @@ class TestDownloadAuthorization:
|
||||
job_id = "tr_public_job99"
|
||||
translate_routes._translation_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"user_id": None,
|
||||
"access_token": "secret_job_token",
|
||||
"status": "completed",
|
||||
"file_name": "public.xlsx",
|
||||
"file_extension": ".xlsx",
|
||||
"output_path": str(output_file),
|
||||
"user_id": None,
|
||||
}
|
||||
|
||||
# Without the token: denied
|
||||
response = client.get(f"{DOWNLOAD_URL}/{job_id}")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["error"] == "ACCESS_DENIED"
|
||||
|
||||
# With the correct token: allowed
|
||||
response = client.get(f"{DOWNLOAD_URL}/{job_id}?token=secret_job_token")
|
||||
assert response.status_code == 200
|
||||
|
||||
92
tests/test_excel_sheet_rename_refs.py
Normal file
92
tests/test_excel_sheet_rename_refs.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Sheet-name translation must not break references.
|
||||
|
||||
openpyxl does not rewrite references on rename — cell formulas, defined
|
||||
names and validations pointing at a translated sheet would break (#REF!).
|
||||
These tests cover the full rename + rewrite path.
|
||||
"""
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
from translators.excel_translator import ExcelTranslator
|
||||
|
||||
|
||||
class _FrToEn:
|
||||
"""Minimal legacy-style provider: French sheet name → English."""
|
||||
|
||||
def translate_batch(self, texts, target_language, source_language="auto"):
|
||||
mapping = {
|
||||
"Ventes": "Sales",
|
||||
"Données": "Data",
|
||||
"Rapport des ventes": "Sales report",
|
||||
"Total": "Total",
|
||||
}
|
||||
return [mapping.get(t, t) for t in texts]
|
||||
|
||||
|
||||
def _make_workbook(path):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Ventes"
|
||||
ws["A1"] = "Rapport des ventes"
|
||||
ws["A2"] = "Total"
|
||||
ws["A3"] = 10
|
||||
ws["A4"] = 20
|
||||
# Cross-sheet formula (unquoted name)
|
||||
ws2 = wb.create_sheet("Données")
|
||||
ws2["A1"] = "=SUM(Ventes!A3:A4)"
|
||||
# Quoted name (name would need quotes if it had spaces — keep simple here)
|
||||
ws2["A2"] = "=Ventes!A3+Ventes!A4"
|
||||
# Defined name pointing at the renamed sheet
|
||||
wb.defined_names["TotalVentes"] = DefinedName(
|
||||
"TotalVentes", attr_text="'Ventes'!$A$3"
|
||||
)
|
||||
wb.save(path)
|
||||
return wb
|
||||
|
||||
|
||||
class TestSheetRenameReferences:
|
||||
def test_cell_formulas_and_defined_names_rewritten(self, tmp_path):
|
||||
from pathlib import Path
|
||||
|
||||
src = tmp_path / "in.xlsx"
|
||||
out = tmp_path / "out.xlsx"
|
||||
_make_workbook(src)
|
||||
|
||||
translator = ExcelTranslator(provider=_FrToEn())
|
||||
translator.translate_file(Path(src), Path(out), "en", "fr")
|
||||
|
||||
wb = load_workbook(out)
|
||||
data = wb["Data"]
|
||||
assert data["A1"].value == "=SUM(Sales!A3:A4)", data["A1"].value
|
||||
assert data["A2"].value == "=Sales!A3+Sales!A4", data["A2"].value
|
||||
|
||||
# Defined name follows the rename
|
||||
dn = wb.defined_names["TotalVentes"]
|
||||
assert "Sales" in (dn.attr_text or ""), dn.attr_text
|
||||
assert "Ventes" not in (dn.attr_text or "")
|
||||
|
||||
# The renamed sheet actually exists under its new name
|
||||
assert "Sales" in wb.sheetnames
|
||||
assert "Ventes" not in wb.sheetnames
|
||||
|
||||
def test_3d_and_multiple_refs(self, tmp_path):
|
||||
mapping = {"Sheet1": "Feuille1", "Sheet2": "Feuille2"}
|
||||
formula = "=SUM(Sheet1!A1:Sheet2!B2)+Sheet1!C3"
|
||||
out = ExcelTranslator._rewrite_sheet_refs_in_formula(formula, mapping)
|
||||
assert out == "=SUM(Feuille1!A1:Feuille2!B2)+Feuille1!C3"
|
||||
|
||||
def test_quoted_refs_and_prefix_safety(self, tmp_path):
|
||||
mapping = {"Ventes": "Sales", "Ventes 2026": "Sales 2026"}
|
||||
out = ExcelTranslator._rewrite_sheet_refs_in_formula(
|
||||
"=SUM('Ventes 2026'!A1:A2)+'Ventes'!B1", mapping
|
||||
)
|
||||
# "Ventes 2026" (longest first) keeps its quotes (name with space);
|
||||
# "Sales" needs no quotes so the canonical unquoted form is emitted.
|
||||
assert out == "=SUM('Sales 2026'!A1:A2)+Sales!B1"
|
||||
|
||||
def test_non_sheet_bang_not_touched(self, tmp_path):
|
||||
mapping = {"Ventes": "Sales"}
|
||||
# "Total!A1" is not a renamed sheet — must stay untouched
|
||||
out = ExcelTranslator._rewrite_sheet_refs_in_formula("=Total!A1", mapping)
|
||||
assert out == "=Total!A1"
|
||||
45
tests/test_language_validation.py
Normal file
45
tests/test_language_validation.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""LanguageValidator — case-insensitive codes and canonical form (zh-CN fix)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from middleware.validation import LanguageValidator, ValidationError
|
||||
|
||||
|
||||
class TestLanguageValidatorCaseInsensitive:
|
||||
def test_zh_cn_mixed_case_accepted(self):
|
||||
assert LanguageValidator.validate("zh-CN") == "zh-CN"
|
||||
|
||||
def test_zh_cn_lowercase_normalized(self):
|
||||
assert LanguageValidator.validate("zh-cn") == "zh-CN"
|
||||
|
||||
def test_zh_cn_uppercase_normalized(self):
|
||||
assert LanguageValidator.validate("ZH-CN") == "zh-CN"
|
||||
|
||||
def test_zh_tw_accepted(self):
|
||||
assert LanguageValidator.validate("zh-TW") == "zh-TW"
|
||||
assert LanguageValidator.validate("zh-tw") == "zh-TW"
|
||||
|
||||
def test_alias_chinese(self):
|
||||
assert LanguageValidator.validate("chinese") == "zh-CN"
|
||||
|
||||
def test_alias_tw(self):
|
||||
assert LanguageValidator.validate("tw") == "zh-TW"
|
||||
|
||||
def test_plain_codes_unchanged(self):
|
||||
assert LanguageValidator.validate("en") == "en"
|
||||
assert LanguageValidator.validate("fr") == "fr"
|
||||
|
||||
def test_auto_accepted(self):
|
||||
assert LanguageValidator.validate("auto") == "auto"
|
||||
|
||||
def test_unknown_code_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
LanguageValidator.validate("xx")
|
||||
|
||||
def test_unknown_variant_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
LanguageValidator.validate("zz-ZZ")
|
||||
|
||||
def test_empty_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
LanguageValidator.validate("")
|
||||
@@ -35,36 +35,6 @@ from prometheus_client import (
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_metrics_module_with_registry(registry: CollectorRegistry):
|
||||
"""Load middleware/metrics.py with patched Counter/Histogram to
|
||||
use the supplied registry. Returns the loaded module."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"metrics_under_test",
|
||||
_REPO_ROOT / "middleware" / "metrics.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Inject the fresh registry into the module's namespace before exec
|
||||
mod.__dict__["_TEST_REGISTRY"] = registry
|
||||
|
||||
# Patch Counter/Histogram to use the fresh registry
|
||||
orig_counter = Counter
|
||||
orig_histogram = Histogram
|
||||
|
||||
def _counter(*args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
return orig_counter(*args, **kwargs)
|
||||
|
||||
def _histogram(*args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
return orig_histogram(*args, **kwargs)
|
||||
|
||||
mod.__dict__["Counter"] = _counter
|
||||
mod.__dict__["Histogram"] = _histogram
|
||||
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def metrics():
|
||||
"""Load the metrics module ONCE per test module.
|
||||
@@ -80,55 +50,59 @@ def metrics():
|
||||
return _load_metrics_module_with_fresh_registry()
|
||||
|
||||
|
||||
def _load_metrics_module_with_registry(registry: CollectorRegistry):
|
||||
"""Load middleware/metrics.py with ALL its counters/histograms
|
||||
registered on the supplied fresh registry.
|
||||
|
||||
metrics.py does ``from prometheus_client import Counter, Histogram`` at
|
||||
module top — injecting patched classes into the module dict BEFORE exec
|
||||
does not survive that import. The only reliable interception point is
|
||||
the ``prometheus_client`` module itself: we swap its Counter/Histogram
|
||||
attributes for subclasses that default to ``registry``, exec the
|
||||
module source, and restore the originals.
|
||||
"""
|
||||
import types
|
||||
import prometheus_client
|
||||
|
||||
orig_counter = prometheus_client.Counter
|
||||
orig_histogram = prometheus_client.Histogram
|
||||
|
||||
class _RegistryCounter(orig_counter):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class _RegistryHistogram(orig_histogram):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("registry", registry)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
source = (_REPO_ROOT / "middleware" / "metrics.py").read_text(encoding="utf-8")
|
||||
mod = types.ModuleType("metrics_under_test")
|
||||
mod.__file__ = str(_REPO_ROOT / "middleware" / "metrics.py")
|
||||
try:
|
||||
prometheus_client.Counter = _RegistryCounter
|
||||
prometheus_client.Histogram = _RegistryHistogram
|
||||
exec(compile(source, mod.__file__, "exec"), mod.__dict__)
|
||||
finally:
|
||||
prometheus_client.Counter = orig_counter
|
||||
prometheus_client.Histogram = orig_histogram
|
||||
return mod
|
||||
|
||||
|
||||
def _load_metrics_module_with_fresh_registry():
|
||||
"""Load metrics module with its counters/histograms attached to a
|
||||
fresh CollectorRegistry."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"metrics_under_test",
|
||||
_REPO_ROOT / "middleware" / "metrics.py",
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# The module just created its counters on the default REGISTRY.
|
||||
# Unregister them, then re-create them on a fresh registry.
|
||||
fresh = CollectorRegistry()
|
||||
for name in (
|
||||
"http_requests_total",
|
||||
"translation_total",
|
||||
"translation_duration_seconds",
|
||||
"file_size_bytes",
|
||||
"quality_l0_checks_total",
|
||||
"quality_l1_judge_total",
|
||||
"quality_l1_judge_duration_seconds",
|
||||
"quality_l1_judge_cost_usd",
|
||||
"translation_retry_total",
|
||||
"format_elements_lost_total",
|
||||
):
|
||||
if not hasattr(mod, name):
|
||||
continue
|
||||
obj = getattr(mod, name)
|
||||
try:
|
||||
REGISTRY.unregister(obj)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Now re-import the module so the new metrics register on `fresh`
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"metrics_under_test_isolated",
|
||||
_REPO_ROOT / "middleware" / "metrics.py",
|
||||
)
|
||||
mod2 = importlib.util.module_from_spec(spec)
|
||||
# We can't easily re-route Counter/Histogram in exec_module because
|
||||
# they call into the global REGISTRY via the function signature.
|
||||
# Instead: reload by re-importing via importlib with a wrapper
|
||||
# that intercepts the Counter/Histogram constructors. We do this
|
||||
# via the more direct route: use the EXISTING counters on the
|
||||
# default REGISTRY, but only check RELATIVE increments.
|
||||
#
|
||||
# Practical approach: just use the module as-is. Tests check
|
||||
# `after >= before + 1`, which is robust against other tests.
|
||||
return mod
|
||||
fresh CollectorRegistry (never the shared default one — the app may
|
||||
already have registered the same names earlier in the session)."""
|
||||
return _load_metrics_module_with_registry(CollectorRegistry())
|
||||
|
||||
|
||||
def _counter_value(counter, **labels):
|
||||
|
||||
344
tests/test_new_features.py
Normal file
344
tests/test_new_features.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""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"
|
||||
86
tests/test_plan_gating.py
Normal file
86
tests/test_plan_gating.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Plan-based engine gating and the expanded /languages endpoint."""
|
||||
|
||||
import pytest
|
||||
|
||||
from models.subscription import PlanType
|
||||
from routes.translate_routes import (
|
||||
_allowed_providers_for_plan,
|
||||
_image_translation_allowed_for_plan,
|
||||
_plan_from_user,
|
||||
)
|
||||
|
||||
|
||||
class _FakeUser:
|
||||
def __init__(self, plan):
|
||||
self.plan = plan
|
||||
|
||||
|
||||
class TestAllowedProviders:
|
||||
def test_free_gets_google_only(self):
|
||||
assert _allowed_providers_for_plan(PlanType.FREE) == {"google"}
|
||||
|
||||
def test_starter_adds_deepl(self):
|
||||
assert _allowed_providers_for_plan(PlanType.STARTER) == {"google", "deepl"}
|
||||
|
||||
def test_pro_adds_cloud_and_openrouter(self):
|
||||
allowed = _allowed_providers_for_plan(PlanType.PRO)
|
||||
assert {"google_cloud", "openrouter"} <= allowed
|
||||
assert "openai" not in allowed
|
||||
|
||||
def test_business_adds_premium_and_xai(self):
|
||||
allowed = _allowed_providers_for_plan(PlanType.BUSINESS)
|
||||
assert {"openrouter_premium", "openai", "zai"} <= allowed
|
||||
|
||||
def test_enterprise_has_all(self):
|
||||
enterprise = _allowed_providers_for_plan(PlanType.ENTERPRISE)
|
||||
assert enterprise >= _allowed_providers_for_plan(PlanType.BUSINESS)
|
||||
|
||||
|
||||
class TestImageTranslationGate:
|
||||
@pytest.mark.parametrize("plan", [PlanType.FREE, PlanType.STARTER])
|
||||
def test_refused_below_pro(self, plan):
|
||||
assert _image_translation_allowed_for_plan(plan) is False
|
||||
|
||||
@pytest.mark.parametrize("plan", [PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE])
|
||||
def test_allowed_from_pro(self, plan):
|
||||
assert _image_translation_allowed_for_plan(plan) is True
|
||||
|
||||
|
||||
class TestPlanFromUser:
|
||||
def test_anonymous_is_free(self):
|
||||
assert _plan_from_user(None) is PlanType.FREE
|
||||
|
||||
def test_enum_plan_passthrough(self):
|
||||
assert _plan_from_user(_FakeUser(PlanType.PRO)) is PlanType.PRO
|
||||
|
||||
def test_string_plan_accepted(self):
|
||||
assert _plan_from_user(_FakeUser("pro")) is PlanType.PRO
|
||||
|
||||
def test_garbage_falls_back_to_free(self):
|
||||
assert _plan_from_user(_FakeUser("nonsense")) is PlanType.FREE
|
||||
|
||||
|
||||
class TestLanguagesEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_exposes_at_least_60_languages(self):
|
||||
from routes.legacy_routes import get_supported_languages
|
||||
|
||||
response = await get_supported_languages()
|
||||
langs = response["supported_languages"]
|
||||
assert response["count"] >= 60
|
||||
assert len(langs) >= 60
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_auto_and_canonical_chinese(self):
|
||||
from routes.legacy_routes import get_supported_languages
|
||||
|
||||
langs = (await get_supported_languages())["supported_languages"]
|
||||
assert "auto" not in langs
|
||||
assert "zh-CN" in langs and "zh-TW" in langs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_language_has_a_name(self):
|
||||
from routes.legacy_routes import get_supported_languages
|
||||
|
||||
langs = (await get_supported_languages())["supported_languages"]
|
||||
assert all(name and name != code.upper() for code, name in langs.items())
|
||||
161
tests/test_providers/test_minimax_provider.py
Normal file
161
tests/test_providers/test_minimax_provider.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Tests for the MinimaxTranslationProvider.
|
||||
|
||||
Validates Bug 1 fix:
|
||||
- default base_url is the public ``api.minimax.io`` host (NOT ``api.minimax.chat``)
|
||||
- default model is ``MiniMax-M3``
|
||||
- ``is_available()`` / ``health_check()`` tolerate a missing ``/models`` path
|
||||
(Minimax does not document it) and only mark the provider down on 401/network error
|
||||
- translation success, 429 retry, 401 handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
from services.providers.minimax_provider import (
|
||||
MinimaxTranslationProvider,
|
||||
MinimaxProviderError,
|
||||
MINIMAX_RATE_LIMITED,
|
||||
MINIMAX_INVALID_KEY,
|
||||
MINIMAX_TIMEOUT,
|
||||
MINIMAX_SERVICE_ERROR,
|
||||
)
|
||||
from services.providers.schemas import TranslationRequest
|
||||
|
||||
|
||||
class TestMinimaxProviderConfig:
|
||||
"""Defaults must point at the real public endpoint."""
|
||||
|
||||
def test_default_base_url_is_public_host(self):
|
||||
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
|
||||
assert provider._base_url == "https://api.minimax.io/v1"
|
||||
# Regression guard: the old broken host must never come back.
|
||||
assert "minimax.chat" not in provider._base_url
|
||||
|
||||
def test_default_model_is_m3(self):
|
||||
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
|
||||
assert provider._model == "MiniMax-M3"
|
||||
|
||||
def test_custom_base_url_respected(self):
|
||||
provider = MinimaxTranslationProvider(
|
||||
api_key="k", base_url="https://proxy.example.com/v1", max_retries=0
|
||||
)
|
||||
assert provider._base_url == "https://proxy.example.com/v1"
|
||||
|
||||
def test_get_name(self):
|
||||
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
|
||||
assert provider.get_name() == "minimax"
|
||||
|
||||
|
||||
class TestMinimaxAvailabilityProbe:
|
||||
"""is_available/health_check must not fail just because /models 404s."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return MinimaxTranslationProvider(api_key="k", max_retries=0)
|
||||
|
||||
def _mock_get(self, status_code: int):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = status_code
|
||||
return mock_response
|
||||
|
||||
@patch("requests.get")
|
||||
def test_available_when_models_returns_200(self, mock_get, provider):
|
||||
mock_get.return_value = self._mock_get(200)
|
||||
assert provider.is_available() is True
|
||||
|
||||
@patch("requests.get")
|
||||
def test_available_when_models_404(self, mock_get, provider):
|
||||
# Minimax does not document /models; a 404 must NOT mark it unavailable.
|
||||
mock_get.return_value = self._mock_get(404)
|
||||
assert provider.is_available() is True
|
||||
|
||||
@patch("requests.get")
|
||||
def test_unavailable_on_401(self, mock_get, provider):
|
||||
mock_get.return_value = self._mock_get(401)
|
||||
assert provider.is_available() is False
|
||||
|
||||
@patch("requests.get")
|
||||
def test_unavailable_on_network_error(self, _mock_get, provider):
|
||||
def _raise(*a, **kw):
|
||||
raise Timeout("boom")
|
||||
|
||||
with patch("requests.get", side_effect=_raise):
|
||||
assert provider.is_available() is False
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_tolerates_404(self, mock_get, provider):
|
||||
mock_get.return_value = self._mock_get(404)
|
||||
status = provider.health_check()
|
||||
assert status.available is True
|
||||
assert status.name == "minimax"
|
||||
|
||||
@patch("requests.get")
|
||||
def test_health_check_marks_down_on_401(self, mock_get, provider):
|
||||
mock_get.return_value = self._mock_get(401)
|
||||
status = provider.health_check()
|
||||
assert status.available is False
|
||||
|
||||
|
||||
class TestMinimaxTranslateText:
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
return MinimaxTranslationProvider(api_key="k", model="MiniMax-M3", max_retries=0)
|
||||
|
||||
def _mock_post(self, payload, status_code=200):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = status_code
|
||||
mock_response.json.return_value = payload
|
||||
mock_response.text = ""
|
||||
return mock_response
|
||||
|
||||
@patch("requests.post")
|
||||
def test_success(self, mock_post, provider):
|
||||
mock_post.return_value = self._mock_post(
|
||||
{"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}
|
||||
)
|
||||
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
|
||||
assert resp.translated_text == "Bonjour"
|
||||
assert resp.provider_name == "minimax"
|
||||
# Verify we hit the public host on the OpenAI-compatible path.
|
||||
called_url = mock_post.call_args[0][0]
|
||||
assert called_url == "https://api.minimax.io/v1/chat/completions"
|
||||
|
||||
def test_empty_text_short_circuits(self, provider):
|
||||
resp = provider.translate_text(TranslationRequest(text="", target_language="fr"))
|
||||
assert resp.translated_text == ""
|
||||
|
||||
@patch("requests.post")
|
||||
def test_invalid_key_returns_error(self, mock_post, provider):
|
||||
mock_post.return_value = self._mock_post({"error": "bad key"}, status_code=401)
|
||||
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
|
||||
assert resp.error_code == MINIMAX_INVALID_KEY
|
||||
# Original text returned on failure.
|
||||
assert resp.translated_text == "Hello"
|
||||
|
||||
@patch("time.sleep")
|
||||
@patch("requests.post")
|
||||
def test_rate_limit_then_success(self, mock_post, mock_sleep):
|
||||
provider = MinimaxTranslationProvider(api_key="k", max_retries=2, retry_delay=0.01)
|
||||
mock_post.side_effect = [
|
||||
self._mock_post({"error": "slow down"}, status_code=429),
|
||||
self._mock_post({"choices": [{"message": {"content": "Hola"}}], "usage": {}}),
|
||||
]
|
||||
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="es"))
|
||||
assert resp.translated_text == "Hola"
|
||||
assert mock_sleep.called # backoff happened
|
||||
|
||||
@patch("requests.post")
|
||||
def test_service_error_when_empty_choices(self, mock_post, provider):
|
||||
mock_post.return_value = self._mock_post({"choices": []})
|
||||
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
|
||||
assert resp.error_code == MINIMAX_SERVICE_ERROR
|
||||
|
||||
|
||||
class TestMinimaxProviderError:
|
||||
def test_error_carries_code_and_message(self):
|
||||
err = MinimaxProviderError(MINIMAX_TIMEOUT, "timed out", details={"wait": 1})
|
||||
assert err.code == MINIMAX_TIMEOUT
|
||||
assert err.message == "timed out"
|
||||
assert err.details == {"wait": 1}
|
||||
@@ -97,11 +97,24 @@ class TestHelperFunctions:
|
||||
assert "translator" in prompt.lower()
|
||||
|
||||
def test_build_system_prompt_custom(self):
|
||||
"""Test custom system prompt."""
|
||||
"""A custom prompt AUGMENTS the base translation instructions.
|
||||
|
||||
The base prompt is always present — a glossary-only custom prompt
|
||||
used to produce a system prompt with no translation instruction
|
||||
at all (fixed 2026-08-29).
|
||||
"""
|
||||
custom = "Translate this text formally for business context."
|
||||
prompt = _build_system_prompt("English", "French", custom)
|
||||
|
||||
assert prompt == custom
|
||||
# Base translation instructions survive
|
||||
assert "English" in prompt
|
||||
assert "French" in prompt
|
||||
assert "translator" in prompt.lower()
|
||||
# Custom content is appended, not replacing
|
||||
assert custom in prompt
|
||||
assert "ADDITIONAL CONTEXT AND INSTRUCTIONS" in prompt
|
||||
# The base part comes first
|
||||
assert prompt.index("French") < prompt.index(custom)
|
||||
|
||||
|
||||
class TestOpenAITranslationProvider:
|
||||
|
||||
192
tests/test_scanned_pdf_ocr.py
Normal file
192
tests/test_scanned_pdf_ocr.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Scanned PDF detection and the Mistral OCR translation path."""
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
|
||||
from config import config
|
||||
from services.mistral_ocr import MistralOCRClient
|
||||
from services.providers.base import TranslationProvider
|
||||
from services.providers.schemas import TranslationRequest, TranslationResponse
|
||||
from translators.pdf_translator import PDFTranslator
|
||||
|
||||
|
||||
FAKE_OCR_PAGES = [
|
||||
"# Facture\n\nMontant total : 1 250 euros\n\n",
|
||||
"Le paiement est du sous 30 jours.",
|
||||
]
|
||||
|
||||
# Minimal French → English mapping for the fake provider; anything else is
|
||||
# prefixed so tests can assert replacement happened.
|
||||
TRANSLATIONS = {
|
||||
"Facture": "Invoice",
|
||||
"Montant total : 1 250 euros": "Total amount: 1,250 euros",
|
||||
"Le paiement est du sous 30 jours.": "Payment is due within 30 days.",
|
||||
}
|
||||
|
||||
|
||||
class FakeProvider(TranslationProvider):
|
||||
"""New-style provider with canned translations (no network)."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "fake"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
|
||||
# Pages arrive as multi-line text: translate line by line so the
|
||||
# canned mapping applies regardless of how lines are grouped.
|
||||
lines = []
|
||||
for line in request.text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
lines.append(line)
|
||||
continue
|
||||
lines.append(TRANSLATIONS.get(stripped, f"[EN] {stripped}"))
|
||||
return TranslationResponse(
|
||||
translated_text="\n".join(lines),
|
||||
provider_name=self.get_name(),
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_scanned_pdf(path):
|
||||
"""Image-only PDF: one page almost fully covered by a picture, no text."""
|
||||
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 10, 10))
|
||||
pix.clear_with(90)
|
||||
png_bytes = pix.tobytes("png")
|
||||
doc = fitz.open()
|
||||
page = doc.new_page(width=612, height=792)
|
||||
page.insert_image(fitz.Rect(20, 20, 592, 772), stream=png_bytes)
|
||||
doc.save(str(path))
|
||||
doc.close()
|
||||
return path
|
||||
|
||||
|
||||
def _make_text_pdf(path):
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text(
|
||||
(72, 100),
|
||||
"Real selectable text with plenty of characters to exceed the scanned threshold. " * 2,
|
||||
fontsize=11,
|
||||
)
|
||||
doc.save(str(path))
|
||||
doc.close()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_api_key(monkeypatch):
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
monkeypatch.setattr(config, "MISTRAL_API_KEY", "")
|
||||
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key(monkeypatch):
|
||||
# Set BOTH the env var and the config attribute: some other test in the
|
||||
# full suite reloads the config module, and pdf_translator imports
|
||||
# `config` at call time — after a reload only the env var is still
|
||||
# visible (the class attributes are re-read from the environment).
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
|
||||
|
||||
|
||||
class TestScannedDetection:
|
||||
def test_image_only_pdf_is_scanned(self, tmp_path):
|
||||
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
||||
translator = PDFTranslator(provider=None)
|
||||
assert translator._is_scanned_pdf(pdf) is True
|
||||
|
||||
def test_text_pdf_is_not_scanned(self, tmp_path):
|
||||
pdf = _make_text_pdf(tmp_path / "text.pdf")
|
||||
translator = PDFTranslator(provider=None)
|
||||
assert translator._is_scanned_pdf(pdf) is False
|
||||
|
||||
def test_title_only_pdf_is_not_scanned(self, tmp_path):
|
||||
"""A sparse but textual page (no raster image) stays on the layout path."""
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 100), "Facture 2026", fontsize=18)
|
||||
doc.save(str(tmp_path / "title.pdf"))
|
||||
doc.close()
|
||||
translator = PDFTranslator(provider=None)
|
||||
assert translator._is_scanned_pdf(tmp_path / "title.pdf") is False
|
||||
|
||||
|
||||
class TestMarkdownCleanup:
|
||||
def test_images_links_headings_removed(self):
|
||||
md = "# Titre\n\n\n\n[lien](http://x) texte"
|
||||
out = PDFTranslator._markdown_to_text(md)
|
||||
assert "Titre" in out
|
||||
assert "![img]" not in out
|
||||
assert "(http://x)" not in out
|
||||
assert "#" not in out
|
||||
assert "lien texte" in out
|
||||
|
||||
def test_table_pipes_removed(self):
|
||||
out = PDFTranslator._markdown_to_text("| A | B |\n|---|---|\n| un | deux |")
|
||||
assert "|" not in out
|
||||
assert "un" in out and "deux" in out
|
||||
|
||||
|
||||
class TestScannedOCRPath:
|
||||
def test_translate_scanned_pdf_end_to_end(self, tmp_path, api_key, monkeypatch):
|
||||
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
||||
monkeypatch.setattr(
|
||||
MistralOCRClient,
|
||||
"extract_pdf_text",
|
||||
lambda self, path, progress_callback=None: list(FAKE_OCR_PAGES),
|
||||
)
|
||||
|
||||
translator = PDFTranslator(provider=FakeProvider())
|
||||
# Configure OCR the way the production route does (set_ocr_config).
|
||||
# Going through the config module is fragile in the full suite:
|
||||
# another test replaces sys.modules["config"], so a config-module
|
||||
# patch may never reach the translator.
|
||||
translator.set_ocr_config(api_key="test-key", enabled=True)
|
||||
out = tmp_path / "out.pdf"
|
||||
result = translator.translate_file(pdf, out, "en", "fr")
|
||||
|
||||
assert result.exists() and result.stat().st_size > 0
|
||||
doc = fitz.open(str(result))
|
||||
text = "\n".join(p.get_text("text") for p in doc)
|
||||
doc.close()
|
||||
|
||||
assert "Invoice" in text
|
||||
assert "Payment is due within 30 days" in text
|
||||
assert "Facture" not in text
|
||||
assert "logo" not in text # markdown images stripped
|
||||
assert "|" not in text # markdown tables stripped
|
||||
|
||||
def test_missing_api_key_raises_clear_error(self, tmp_path, no_api_key):
|
||||
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
||||
translator = PDFTranslator(provider=FakeProvider())
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
|
||||
assert "MISTRAL_API_KEY" in str(exc.value)
|
||||
|
||||
def test_disabled_ocr_raises_clear_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", False)
|
||||
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
||||
translator = PDFTranslator(provider=FakeProvider())
|
||||
with pytest.raises(RuntimeError):
|
||||
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
|
||||
|
||||
def test_text_pdf_bypasses_ocr(self, tmp_path, api_key, monkeypatch):
|
||||
pdf = _make_text_pdf(tmp_path / "text.pdf")
|
||||
called = {"ocr": False}
|
||||
|
||||
def _fail(self, path, progress_callback=None):
|
||||
called["ocr"] = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(MistralOCRClient, "extract_pdf_text", _fail)
|
||||
|
||||
translator = PDFTranslator(provider=FakeProvider())
|
||||
out = tmp_path / "out.pdf"
|
||||
translator.translate_file(pdf, out, "en", "fr")
|
||||
assert called["ocr"] is False
|
||||
228
tests/test_security_fixes_c1_c4.py
Normal file
228
tests/test_security_fixes_c1_c4.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Tests for security fixes C1–C4 (audit 2026-08-26)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routes import translate_routes as tr
|
||||
from middleware.cleanup import FileCleanupManager
|
||||
|
||||
|
||||
class TestSanitizeUrlFilename:
|
||||
"""AC1 — path traversal in URL-downloaded filenames is neutralized."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"../../evil.xlsx",
|
||||
"..\\..\\evil.docx",
|
||||
"normal_file.pptx",
|
||||
"..\t..evil.pdf",
|
||||
"...",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_traversal_stripped(self, raw):
|
||||
result = tr._sanitize_url_filename(raw)
|
||||
# Core guarantee: no traversal or path separators survive
|
||||
assert ".." not in result
|
||||
assert "/" not in result
|
||||
assert "\\" not in result
|
||||
assert result != ""
|
||||
|
||||
def test_long_filename_truncated(self):
|
||||
raw = "a" * 300 + ".xlsx"
|
||||
result = tr._sanitize_url_filename(raw)
|
||||
assert len(result) <= 255
|
||||
assert result.endswith(".xlsx")
|
||||
|
||||
|
||||
class TestRedirectSsrf:
|
||||
"""AC2 — redirect to internal address is blocked."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_to_metadata_blocked(self):
|
||||
def fake_response(status, location=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.headers = {"location": location} if location else {}
|
||||
return resp
|
||||
|
||||
class FakeClient:
|
||||
def build_request(self, method, url):
|
||||
return (method, url)
|
||||
|
||||
async def send(self, req, stream=False):
|
||||
url = req[1]
|
||||
if "public.example" in url:
|
||||
return fake_response(302, "http://169.254.169.254/latest/meta-data")
|
||||
return fake_response(200)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
with patch.object(tr.httpx, "AsyncClient", return_value=FakeClient()):
|
||||
with pytest.raises(tr.TranslateEndpointError) as exc:
|
||||
await tr.download_from_url("http://public.example/file.xlsx")
|
||||
assert exc.value.details.get("reason") == "ssrf_blocked"
|
||||
|
||||
|
||||
class TestCleanupOrphanGrace:
|
||||
"""AC3 — young orphans are not deleted."""
|
||||
|
||||
def _manager(self, tmp_path):
|
||||
m = FileCleanupManager(
|
||||
upload_dir=tmp_path / "uploads",
|
||||
output_dir=tmp_path / "outputs",
|
||||
temp_dir=tmp_path / "temp",
|
||||
)
|
||||
for d in (m.upload_dir, m.output_dir, m.temp_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_young_orphan_kept(self, tmp_path):
|
||||
"""Orphan younger than the grace period is NOT deleted."""
|
||||
m = self._manager(tmp_path)
|
||||
f = m.upload_dir / "recent_orphan.xlsx"
|
||||
f.write_bytes(b"x")
|
||||
|
||||
fake_redis = MagicMock()
|
||||
fake_redis.keys = AsyncMock(return_value=[])
|
||||
fake_redis.get = AsyncMock(return_value=None)
|
||||
with patch(
|
||||
"middleware.cleanup._get_async_redis", return_value=fake_redis
|
||||
):
|
||||
stats = await m.cleanup()
|
||||
assert f.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_path_key_recognized(self, tmp_path):
|
||||
"""Files tracked under 'input_path' are not orphans (key mismatch fix)."""
|
||||
import json as _json
|
||||
|
||||
m = self._manager(tmp_path)
|
||||
f = m.upload_dir / "tracked.xlsx"
|
||||
f.write_bytes(b"x")
|
||||
|
||||
fake_redis = MagicMock()
|
||||
fake_redis.keys = AsyncMock(return_value=["translation:file:tr_1"])
|
||||
fake_redis.get = AsyncMock(
|
||||
return_value=_json.dumps({"input_path": str(f), "user_id": "u1"})
|
||||
)
|
||||
with patch(
|
||||
"middleware.cleanup._get_async_redis", return_value=fake_redis
|
||||
):
|
||||
stats = await m.cleanup()
|
||||
assert f.exists()
|
||||
assert stats["orphaned_deleted"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_orphan_deleted(self, tmp_path):
|
||||
import json as _json
|
||||
import os
|
||||
import time
|
||||
|
||||
m = self._manager(tmp_path)
|
||||
f = m.upload_dir / "old_orphan.xlsx"
|
||||
f.write_bytes(b"x")
|
||||
old = time.time() - (m.orphan_grace_seconds + 600)
|
||||
os.utime(f, (old, old))
|
||||
|
||||
fake_redis = MagicMock()
|
||||
fake_redis.keys = AsyncMock(return_value=[])
|
||||
fake_redis.get = AsyncMock(return_value=None)
|
||||
with patch(
|
||||
"middleware.cleanup._get_async_redis", return_value=fake_redis
|
||||
):
|
||||
stats = await m.cleanup()
|
||||
assert not f.exists()
|
||||
assert stats["orphaned_deleted"] == 1
|
||||
|
||||
|
||||
class TestJobAccessControl:
|
||||
"""H2 — ownership / token checks on status and download."""
|
||||
|
||||
def _job(self, user_id=None, token="tok123"):
|
||||
return {"id": "tr_abc", "user_id": user_id, "access_token": token}
|
||||
|
||||
def _user(self, uid):
|
||||
u = MagicMock()
|
||||
u.id = uid
|
||||
return u
|
||||
|
||||
def test_owner_allowed(self):
|
||||
job = self._job(user_id=7)
|
||||
assert tr._check_job_access(job, self._user(7), None) is None
|
||||
|
||||
def test_other_user_denied(self):
|
||||
job = self._job(user_id=7)
|
||||
resp = tr._check_job_access(job, self._user(8), None)
|
||||
assert resp is not None and resp.status_code == 403
|
||||
|
||||
def test_anonymous_caller_on_owned_job_denied(self):
|
||||
job = self._job(user_id=7)
|
||||
resp = tr._check_job_access(job, None, "tok123")
|
||||
assert resp is not None and resp.status_code == 401
|
||||
|
||||
def test_anonymous_job_requires_token(self):
|
||||
job = self._job(user_id=None)
|
||||
assert tr._check_job_access(job, None, "wrong") is not None
|
||||
assert tr._check_job_access(job, None, None) is not None
|
||||
assert tr._check_job_access(job, None, "tok123") is None
|
||||
|
||||
def test_old_anonymous_job_without_token_denied(self):
|
||||
job = {"id": "tr_old", "user_id": None} # job created before the fix
|
||||
assert tr._check_job_access(job, None, "anything") is not None
|
||||
|
||||
|
||||
class TestZipBomb:
|
||||
"""H1 — dangerous archives are rejected."""
|
||||
|
||||
def _make_zip(self, tmp_path, entries):
|
||||
import zipfile
|
||||
|
||||
p = tmp_path / "bomb.xlsx"
|
||||
with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in entries:
|
||||
zf.writestr(name, data)
|
||||
return p
|
||||
|
||||
def test_normal_file_accepted(self, tmp_path):
|
||||
from utils.file_handler import validate_zip_safety
|
||||
|
||||
p = self._make_zip(tmp_path, [("sheet1.xml", b"<xml/>ok" * 100)])
|
||||
validate_zip_safety(p) # no exception
|
||||
|
||||
def test_not_a_zip_rejected(self, tmp_path):
|
||||
from utils.file_handler import validate_zip_safety
|
||||
|
||||
p = tmp_path / "fake.xlsx"
|
||||
p.write_bytes(b"this is not a zip file")
|
||||
with pytest.raises(ValueError):
|
||||
validate_zip_safety(p)
|
||||
|
||||
def test_high_ratio_rejected(self, tmp_path):
|
||||
from utils.file_handler import validate_zip_safety
|
||||
|
||||
# 50 MB of zeros compresses far beyond the 100:1 ratio cap
|
||||
p = self._make_zip(tmp_path, [("huge.xml", b"\0" * (50 * 1024 * 1024))])
|
||||
with pytest.raises(ValueError):
|
||||
validate_zip_safety(p)
|
||||
|
||||
def test_declared_total_too_big_rejected(self, tmp_path):
|
||||
import zipfile
|
||||
from unittest.mock import patch as _patch
|
||||
from utils.file_handler import validate_zip_safety
|
||||
|
||||
p = self._make_zip(tmp_path, [("a.xml", b"<xml/>")])
|
||||
fake_info = MagicMock()
|
||||
fake_info.is_dir = lambda: False
|
||||
fake_info.file_size = 5 * 1024 * 1024 * 1024 # 5 GB declared
|
||||
fake_info.compress_size = 50 * 1024 * 1024
|
||||
with _patch.object(zipfile.ZipFile, "infolist", return_value=[fake_info]):
|
||||
with pytest.raises(ValueError):
|
||||
validate_zip_safety(p)
|
||||
@@ -30,10 +30,18 @@ def test_validate_invalid_magic_bytes():
|
||||
assert response.json()["error"] == "CORRUPTED_FILE"
|
||||
assert "corrompu" in response.json()["message"]
|
||||
|
||||
def _minimal_zip() -> bytes:
|
||||
"""A real minimal ZIP archive (Office files are ZIPs)."""
|
||||
import io, zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("[Content_Types].xml", "<Types/>")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_validate_valid_file_header():
|
||||
# Test with a minimal valid-looking zip (Office files are ZIPs)
|
||||
# FileValidator checks for b"PK\x03\x04"
|
||||
files = {"file": ("test.docx", b"PK\x03\x04" + b"\x00" * 20, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
|
||||
# Minimal real ZIP: passes magic-byte AND zip-safety checks
|
||||
files = {"file": ("test.docx", _minimal_zip(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
|
||||
response = client.post(
|
||||
"/api/v1/translate",
|
||||
files=files,
|
||||
|
||||
@@ -593,7 +593,11 @@ class TestOptionalParameters:
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_accepts_mode_llm(self, authenticated_client):
|
||||
"""Accepts mode='llm'"""
|
||||
"""mode='llm' maps to the openrouter engine — Pro and above only.
|
||||
|
||||
The default test user is on the Free plan, so the plan-based engine
|
||||
gate must refuse it (403) instead of silently running a paid engine.
|
||||
"""
|
||||
excel_content = create_valid_excel()
|
||||
response = authenticated_client.post(
|
||||
TRANSLATE_URL,
|
||||
@@ -606,7 +610,8 @@ class TestOptionalParameters:
|
||||
},
|
||||
data={"target_lang": "fr", "mode": "llm"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.status_code == 403
|
||||
assert "PRO_FEATURE_REQUIRED" in str(response.json())
|
||||
|
||||
def test_accepts_webhook_url(self, authenticated_client):
|
||||
"""Accepts webhook_url parameter"""
|
||||
@@ -875,7 +880,11 @@ class TestTranslateImagesParameter:
|
||||
"""Test translate_images parameter in POST /api/v1/translate"""
|
||||
|
||||
def test_accepts_translate_images_parameter(self, authenticated_client):
|
||||
"""Endpoint accepts translate_images form parameter"""
|
||||
"""translate_images is a Pro+ feature — Free plan gets 403.
|
||||
|
||||
The parameter is accepted by the schema; the plan gate refuses it
|
||||
for the default (Free) test user.
|
||||
"""
|
||||
excel_content = create_valid_excel()
|
||||
response = authenticated_client.post(
|
||||
TRANSLATE_URL,
|
||||
@@ -888,4 +897,5 @@ class TestTranslateImagesParameter:
|
||||
},
|
||||
data={"target_lang": "fr", "translate_images": "true"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.status_code == 403
|
||||
assert "PRO_FEATURE_REQUIRED" in str(response.json())
|
||||
|
||||
168
tests/test_translate_routes_helpers.py
Normal file
168
tests/test_translate_routes_helpers.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Unit tests for the helper functions in routes/translate_routes.py.
|
||||
|
||||
These cover the bug fixes that are pure logic (no full HTTP machinery needed):
|
||||
- Bug 2: _compute_cost_factor / _provider_model (reads ``_model`` AND ``model``)
|
||||
- Bug 3: _release_quota_if_needed (releases reserved quota on soft-failure)
|
||||
- Bug 4: _compute_duration_seconds (UTC-aware, never crashes)
|
||||
- Bug 5: _cleanup_old_jobs snapshots the jobs dict (no "changed size during iteration")
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from routes import translate_routes as tr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2 — _provider_model / _compute_cost_factor
|
||||
# ---------------------------------------------------------------------------
|
||||
class _NewStyleProvider:
|
||||
"""Mimics services/providers/* classes that store ``self._model``."""
|
||||
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
|
||||
|
||||
class _LegacyProvider:
|
||||
"""Mimics legacy services/translation_service classes with ``self.model``."""
|
||||
|
||||
def __init__(self, model):
|
||||
self.model = model
|
||||
|
||||
|
||||
class TestProviderModel:
|
||||
def test_reads_new_style_private_attr(self):
|
||||
assert tr._provider_model(_NewStyleProvider("gpt-4o")) == "gpt-4o"
|
||||
|
||||
def test_reads_legacy_public_attr(self):
|
||||
assert tr._provider_model(_LegacyProvider("claude-sonnet-4")) == "claude-sonnet-4"
|
||||
|
||||
def test_prefers_private_when_both_present(self):
|
||||
class Both:
|
||||
_model = "private-model"
|
||||
model = "public-model"
|
||||
|
||||
assert tr._provider_model(Both()) == "private-model"
|
||||
|
||||
def test_none_provider(self):
|
||||
assert tr._provider_model(None) == ""
|
||||
|
||||
def test_empty_model(self):
|
||||
assert tr._provider_model(_NewStyleProvider("")) == ""
|
||||
|
||||
|
||||
class TestComputeCostFactor:
|
||||
def test_claude_is_premium(self):
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("anthropic/claude-sonnet-4.6")) == 5
|
||||
|
||||
def test_gpt4_is_premium(self):
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o")) == 5
|
||||
|
||||
def test_gpt4o_mini_is_standard(self):
|
||||
# Cheap GPT-4 variants must not be billed at the premium factor.
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o-mini")) == 1
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o-nano")) == 1
|
||||
|
||||
def test_legacy_gpt4o_mini_is_standard(self):
|
||||
assert tr._compute_cost_factor(_LegacyProvider("gpt-4o-mini")) == 1
|
||||
|
||||
def test_haiku_is_standard(self):
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("anthropic/claude-3-haiku")) == 1
|
||||
|
||||
def test_openrouter_premium_alias_is_premium_without_model(self):
|
||||
# Regression for the original bug: model read failed (""), so the
|
||||
# premium tier was never detected. The alias must still bump it to 5.
|
||||
assert tr._compute_cost_factor(None, "openrouter_premium") == 5
|
||||
|
||||
def test_standard_model(self):
|
||||
assert tr._compute_cost_factor(_NewStyleProvider("deepseek-chat")) == 1
|
||||
|
||||
def test_legacy_provider_model_is_read(self):
|
||||
# Legacy classes expose .model — must also be billed correctly.
|
||||
assert tr._compute_cost_factor(_LegacyProvider("gpt-4o")) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3 — _release_quota_if_needed
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestReleaseQuotaIfNeeded:
|
||||
@pytest.mark.asyncio
|
||||
async def test_releases_when_user_id_and_not_recorded(self):
|
||||
# release_translation_quota is invoked via asyncio.to_thread (a worker
|
||||
# thread); patching it and asserting the call validates the release path.
|
||||
with patch.object(tr, "release_translation_quota") as mock_release:
|
||||
await tr._release_quota_if_needed("user-123", usage_recorded=False, job_id="j1")
|
||||
mock_release.assert_called_once_with("user-123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_usage_already_recorded(self):
|
||||
with patch.object(tr, "release_translation_quota") as mock_release:
|
||||
await tr._release_quota_if_needed("user-123", usage_recorded=True, job_id="j1")
|
||||
mock_release.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_no_user_id(self):
|
||||
with patch.object(tr, "release_translation_quota") as mock_release:
|
||||
await tr._release_quota_if_needed(None, usage_recorded=False, job_id="j1")
|
||||
mock_release.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swallows_release_errors(self):
|
||||
with patch.object(tr, "release_translation_quota", side_effect=RuntimeError("db down")):
|
||||
# Must not raise even if the release itself fails.
|
||||
await tr._release_quota_if_needed("user-123", usage_recorded=False, job_id="j1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 4 — _compute_duration_seconds (UTC-aware, crash-safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestComputeDurationSeconds:
|
||||
def test_recent_timestamp_returns_positive(self):
|
||||
ts = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
|
||||
dur = tr._compute_duration_seconds(ts)
|
||||
assert dur >= 9 # allow tiny scheduling slack
|
||||
|
||||
def test_z_suffix_handled(self):
|
||||
ts = (datetime.now(timezone.utc) - timedelta(seconds=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
dur = tr._compute_duration_seconds(ts)
|
||||
assert dur >= 0
|
||||
|
||||
def test_malformed_returns_zero(self):
|
||||
# Regression for the original bug: a bad timestamp used to crash the
|
||||
# success path and flip the job to failed. It must now return 0.
|
||||
assert tr._compute_duration_seconds("not-a-date") == 0.0
|
||||
|
||||
def test_empty_returns_zero(self):
|
||||
assert tr._compute_duration_seconds("") == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 5 — _cleanup_old_jobs snapshots the dict (no resize-during-iteration)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCleanupOldJobsSnapshots:
|
||||
def test_cleanup_does_not_raise_when_dict_mutated_concurrently(self, monkeypatch):
|
||||
# Force cleanup to run now (bypass throttle).
|
||||
monkeypatch.setattr(tr, "_last_cleanup_ts", 0.0)
|
||||
monkeypatch.setattr(tr, "_CLEANUP_INTERVAL_SECONDS", 0)
|
||||
|
||||
# Two expired jobs.
|
||||
old_ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
||||
tr._translation_jobs.clear()
|
||||
tr._translation_jobs["j1"] = {"status": "completed", "completed_at": old_ts}
|
||||
tr._translation_jobs["j2"] = {"status": "failed", "failed_at": old_ts}
|
||||
tr._translation_jobs["j3"] = {"status": "running"} # not expired
|
||||
|
||||
# If cleanup did NOT snapshot, mutating during iteration would raise.
|
||||
tr._cleanup_old_jobs()
|
||||
|
||||
assert "j1" not in tr._translation_jobs
|
||||
assert "j2" not in tr._translation_jobs
|
||||
assert "j3" in tr._translation_jobs
|
||||
|
||||
def teardown_method(self):
|
||||
tr._translation_jobs.clear()
|
||||
@@ -40,7 +40,11 @@ async def test_translate_endpoint_triggers_tracking(client):
|
||||
with patch(
|
||||
"routes.translate_routes.storage_tracker.track_file", new_callable=AsyncMock
|
||||
) as mock_track:
|
||||
with patch("routes.translate_routes.file_validator.validate_async") as mock_val:
|
||||
# The upload write is mocked below, so no real archive exists on
|
||||
# disk: neutralize the zip safety check for this test.
|
||||
with patch(
|
||||
"routes.translate_routes.file_validator.validate_async"
|
||||
) as mock_val, patch("routes.translate_routes.validate_zip_safety"):
|
||||
mock_val.return_value.is_valid = True
|
||||
mock_val.return_value.data = {"extension": ".docx", "size_bytes": 500}
|
||||
|
||||
@@ -88,7 +92,10 @@ async def test_translate_endpoint_triggers_tracking(client):
|
||||
async def test_translate_endpoint_handles_hash_failure(client):
|
||||
app.dependency_overrides[get_authenticated_user] = mock_auth
|
||||
|
||||
with patch("routes.translate_routes.file_validator.validate_async") as mock_val:
|
||||
# No real archive exists on disk (save is mocked): skip the zip check.
|
||||
with patch(
|
||||
"routes.translate_routes.file_validator.validate_async"
|
||||
) as mock_val, patch("routes.translate_routes.validate_zip_safety"):
|
||||
mock_val.return_value.is_valid = True
|
||||
mock_val.return_value.data = {"extension": ".docx", "size_bytes": 500}
|
||||
|
||||
|
||||
@@ -564,60 +564,46 @@ class TestPptxChartWhitespace:
|
||||
</c:chartSpace>
|
||||
"""
|
||||
|
||||
def test_padded_chart_text_via_internal_method(self):
|
||||
"""The internal chart apply logic should preserve whitespace."""
|
||||
def test_chart_translation_reaches_output_file(self, tmp_path):
|
||||
"""Chart translations must reach the OUTPUT FILE (real ChartPart).
|
||||
|
||||
python-pptx's ChartPart.blob is read-only — the previous in-memory
|
||||
`blob = ...` write never landed in the saved .pptx. This end-to-end
|
||||
test uses a real chart and verifies the chart XML inside the
|
||||
output ZIP.
|
||||
"""
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
|
||||
provider = MockProvider({"Padded chart title": "Titre avec espaces"})
|
||||
translator = PowerPointTranslator(provider=provider)
|
||||
|
||||
# Build a chart entry by hand (simulating collect time)
|
||||
chart_xml = etree.fromstring(self.CHART_PADDED_XML.encode("utf-8"))
|
||||
entries = []
|
||||
for t_elem in chart_xml.iter(f"{{{_NS_A}}}t"):
|
||||
text_raw = t_elem.text or ""
|
||||
text = text_raw.strip()
|
||||
if not text:
|
||||
continue
|
||||
entry = {
|
||||
"element": t_elem,
|
||||
"original": text,
|
||||
"original_raw": text_raw,
|
||||
"translated": "Titre avec espaces",
|
||||
"tag": "a:t",
|
||||
"element_path": translator._get_element_path(t_elem),
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
if not hasattr(translator, "_chart_entries"):
|
||||
translator._chart_entries = []
|
||||
|
||||
class _FakePart:
|
||||
def __init__(self, blob):
|
||||
self._blob = blob
|
||||
@property
|
||||
def blob(self):
|
||||
return self._blob
|
||||
@blob.setter
|
||||
def blob(self, value):
|
||||
self._blob = value
|
||||
|
||||
fake_part = _FakePart(
|
||||
etree.tostring(
|
||||
chart_xml, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||
)
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
chart_data = CategoryChartData()
|
||||
chart_data.categories = ["A", "B"]
|
||||
chart_data.add_series("Series 1", (1, 2))
|
||||
graphic_frame = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED, 10, 10, 400, 300, chart_data
|
||||
)
|
||||
translator._chart_entries.append({
|
||||
"chart_part": fake_part,
|
||||
"entries": entries,
|
||||
})
|
||||
chart = graphic_frame.chart
|
||||
chart.has_title = True
|
||||
chart.chart_title.text_frame.text = "Padded chart title"
|
||||
|
||||
# Apply
|
||||
translator._apply_chart_translations(Path("dummy"))
|
||||
input_file = tmp_path / "chart_in.pptx"
|
||||
output_file = tmp_path / "chart_out.pptx"
|
||||
prs.save(str(input_file))
|
||||
|
||||
# Re-parse and check whitespace preserved
|
||||
updated_xml = etree.fromstring(fake_part._blob)
|
||||
all_t = list(updated_xml.iter(f"{{{_NS_A}}}t"))
|
||||
# Find the title text
|
||||
title_text = all_t[0].text or ""
|
||||
assert " Titre avec espaces " in title_text, (
|
||||
f"Chart whitespace not preserved: {title_text!r}"
|
||||
translator.translate_file(input_file, output_file, "fr", "en")
|
||||
|
||||
with zipfile.ZipFile(output_file, "r") as zf:
|
||||
chart_parts = [
|
||||
n for n in zf.namelist() if n.startswith("ppt/charts/chart")
|
||||
]
|
||||
assert chart_parts, "chart part missing from output file"
|
||||
chart_xml = zf.read(chart_parts[0]).decode("utf-8")
|
||||
|
||||
assert "Titre avec espaces" in chart_xml, (
|
||||
"Chart title translation never reached the output file"
|
||||
)
|
||||
assert "Padded chart title" not in chart_xml
|
||||
|
||||
139
tests/test_translators/test_pdf_quality_fixes.py
Normal file
139
tests/test_translators/test_pdf_quality_fixes.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""PDF quality fixes: table-cell merge guard, bold/italic fonts, unchanged
|
||||
blocks left untouched (no redaction/rewrite), and stats propagation."""
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
|
||||
from translators.pdf_translator import PDFTranslator
|
||||
|
||||
|
||||
class TestTableCellMergeGuard:
|
||||
def test_table_cells_never_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {
|
||||
"bbox": (72, 100, 200, 115),
|
||||
"font_size": 11,
|
||||
"_is_table_cell": True,
|
||||
}
|
||||
b = {
|
||||
"bbox": (72, 118, 200, 133), # same column, next row
|
||||
"font_size": 11,
|
||||
"_is_table_cell": True,
|
||||
}
|
||||
assert t._should_merge_blocks(a, b) is False
|
||||
|
||||
def test_table_cell_and_paragraph_do_not_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {"bbox": (72, 100, 200, 115), "font_size": 11, "_is_table_cell": True}
|
||||
b = {"bbox": (72, 118, 200, 133), "font_size": 11}
|
||||
assert t._should_merge_blocks(a, b) is False
|
||||
|
||||
def test_regular_paragraphs_still_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {"bbox": (72, 100, 300, 115), "font_size": 11}
|
||||
b = {"bbox": (72, 118, 302, 133), "font_size": 11}
|
||||
assert t._should_merge_blocks(a, b) is True
|
||||
|
||||
|
||||
class TestBoldItalicFontSelection:
|
||||
def _capturing_page(self):
|
||||
page = fitz.open().new_page()
|
||||
calls = []
|
||||
original = page.insert_textbox
|
||||
|
||||
def capture(rect, text, fontname=None, fontfile=None, fontsize=None, **kw):
|
||||
calls.append({"fontname": fontname, "fontfile": fontfile})
|
||||
return 0
|
||||
|
||||
page.insert_textbox = capture
|
||||
return page, calls
|
||||
|
||||
def _block(self, **kwargs):
|
||||
block = {
|
||||
"bbox": (72, 100, 400, 130),
|
||||
"text": "Bold heading",
|
||||
"translated": "Titre en gras",
|
||||
"font_size": 20,
|
||||
"color": 0,
|
||||
"line_count": 1,
|
||||
"sub_bboxes": [(72, 100, 400, 130)],
|
||||
}
|
||||
block.update(kwargs)
|
||||
return block
|
||||
|
||||
def test_bold_block_uses_hebo(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None # force base-14 selection
|
||||
t._write_translated_block(page, self._block(is_bold=True), None, False)
|
||||
assert calls and calls[0]["fontname"] == "hebo"
|
||||
|
||||
def test_italic_block_uses_heit(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(page, self._block(is_italic=True), None, False)
|
||||
assert calls and calls[0]["fontname"] == "heit"
|
||||
|
||||
def test_bold_italic_uses_hebi(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(
|
||||
page, self._block(is_bold=True, is_italic=True), None, False
|
||||
)
|
||||
assert calls and calls[0]["fontname"] == "hebi"
|
||||
|
||||
def test_regular_block_keeps_helv(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(page, self._block(), None, False)
|
||||
assert calls and calls[0]["fontname"] == "helv"
|
||||
|
||||
|
||||
class _IdentityProvider:
|
||||
"""Returns the input text unchanged (new-style provider)."""
|
||||
|
||||
def get_name(self):
|
||||
return "identity"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def translate_text(self, request):
|
||||
from services.providers.schemas import TranslationResponse
|
||||
|
||||
return TranslationResponse(
|
||||
translated_text=request.text,
|
||||
provider_name="identity",
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
|
||||
class TestUnchangedBlocksUntouched:
|
||||
def test_identity_translation_stats_and_no_rewrite(self, tmp_path):
|
||||
"""Provider returning the source text: stats stay changed=0 (so the
|
||||
route gate detects it) and blocks are left un-redacted."""
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 100), "Already in English, nothing to do.", fontsize=11)
|
||||
src = tmp_path / "en.pdf"
|
||||
doc.save(str(src))
|
||||
doc.close()
|
||||
|
||||
t = PDFTranslator(provider=_IdentityProvider())
|
||||
out = tmp_path / "out.pdf"
|
||||
result = t.translate_file(src, out, "en", "auto")
|
||||
|
||||
assert result.exists()
|
||||
stats = t.get_translation_stats()
|
||||
assert stats["attempted"] >= 1
|
||||
assert stats["changed"] == 0
|
||||
|
||||
# The text is still there, byte-for-byte same rendering (block was
|
||||
# not redacted + rewritten in the substitute font).
|
||||
check = fitz.open(str(result))
|
||||
text = check[0].get_text("text")
|
||||
check.close()
|
||||
assert "Already in English" in text
|
||||
@@ -190,20 +190,23 @@ class TestParagraphTranslation:
|
||||
"""Tests for paragraph text translation (AC1)."""
|
||||
|
||||
def test_translate_paragraph_runs(self, tmp_path):
|
||||
"""Test that paragraph runs are translated."""
|
||||
"""Adjacent runs with identical formatting merge into ONE unit.
|
||||
|
||||
"Hello" + " " + "World" (same formatting, rsid-style splits) must be
|
||||
translated as the whole sentence, not as separate fragments.
|
||||
"""
|
||||
mock_provider = MockTranslationProvider(
|
||||
{
|
||||
"Hello": "Bonjour",
|
||||
"World": "Monde",
|
||||
"Hello World": "Bonjour le monde",
|
||||
}
|
||||
)
|
||||
translator = WordTranslator(provider=mock_provider)
|
||||
|
||||
doc = Document()
|
||||
para = doc.add_paragraph()
|
||||
run1 = para.add_run("Hello")
|
||||
run2 = para.add_run(" ")
|
||||
run3 = para.add_run("World")
|
||||
para.add_run("Hello")
|
||||
para.add_run(" ")
|
||||
para.add_run("World")
|
||||
|
||||
input_file = tmp_path / "input.docx"
|
||||
output_file = tmp_path / "output.docx"
|
||||
@@ -214,8 +217,41 @@ class TestParagraphTranslation:
|
||||
doc_out = Document(output_file)
|
||||
text = doc_out.paragraphs[0].text
|
||||
|
||||
assert "Bonjour" in text
|
||||
assert "Monde" in text
|
||||
assert text == "Bonjour le monde"
|
||||
# One merged unit → a single provider call
|
||||
assert mock_provider._call_count == 1
|
||||
|
||||
def test_bold_span_kept_separate_and_coherent(self, tmp_path):
|
||||
"""A formatting change mid-sentence splits the units; spaces survive."""
|
||||
mock_provider = MockTranslationProvider(
|
||||
{
|
||||
"This is": "Ceci est",
|
||||
"very important": "très important",
|
||||
}
|
||||
)
|
||||
translator = WordTranslator(provider=mock_provider)
|
||||
|
||||
doc = Document()
|
||||
para = doc.add_paragraph()
|
||||
para.add_run("This is ")
|
||||
bold = para.add_run("very important")
|
||||
bold.bold = True
|
||||
|
||||
input_file = tmp_path / "input.docx"
|
||||
output_file = tmp_path / "output.docx"
|
||||
doc.save(input_file)
|
||||
|
||||
translator.translate_file(input_file, output_file, "fr")
|
||||
|
||||
doc_out = Document(output_file)
|
||||
text = doc_out.paragraphs[0].text
|
||||
assert text == "Ceci est très important"
|
||||
|
||||
# Bold formatting survives on the right span
|
||||
runs = [r for r in doc_out.paragraphs[0].runs if r.text.strip()]
|
||||
assert len(runs) == 2
|
||||
assert runs[1].bold is True
|
||||
assert runs[1].text == "très important"
|
||||
|
||||
def test_empty_paragraphs_not_translated(self, tmp_path):
|
||||
"""Test that empty paragraphs are not translated."""
|
||||
|
||||
Reference in New Issue
Block a user