feat: production deployment - full update with providers, admin, glossaries, pricing, tests

Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

View File

@@ -0,0 +1,788 @@
"""
Unit tests for ExcelTranslator.
Tests cover:
- Text cell translation (strings only, not numbers/dates)
- Formula string extraction and translation
- Merged cell preservation
- Chart/data link preservation
- Error handling (corrupted, invalid format)
- Progress callback
- Provider integration
"""
import pytest
import tempfile
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from typing import List
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.chart import BarChart, Reference
from translators.excel_translator import (
ExcelTranslator,
ExcelProcessorError,
excel_translator,
)
from services.providers.schemas import TranslationRequest, TranslationResponse
class MockTranslationProvider:
"""Mock translation provider for testing."""
def __init__(self, translations: dict = None):
self._translations = translations or {}
self._call_count = 0
self._requests_received: List[TranslationRequest] = []
def get_name(self) -> str:
return "mock"
def is_available(self) -> bool:
return True
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
self._call_count += 1
self._requests_received.append(request)
text = request.text
translated = self._translations.get(text, f"TR_{text}")
return TranslationResponse(
translated_text=translated,
provider_name="mock",
source_language=request.source_language,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
return [self.translate_text(req) for req in requests]
class TestExcelProcessorError:
"""Tests for ExcelProcessorError exception."""
def test_error_with_default_message(self):
"""Test error with default message from code."""
error = ExcelProcessorError(ExcelProcessorError.INVALID_FORMAT)
assert error.code == "INVALID_FORMAT"
assert "xlsx" in error.message.lower()
assert error.to_dict()["error"] == "INVALID_FORMAT"
def test_error_with_custom_message(self):
"""Test error with custom message."""
error = ExcelProcessorError(
ExcelProcessorError.EXCEL_CORRUPTED, message="Custom error message"
)
assert error.message == "Custom error message"
def test_error_with_details(self):
"""Test error with details dict."""
error = ExcelProcessorError(
ExcelProcessorError.EXCEL_TOO_LARGE, details={"size_mb": 100, "max_mb": 50}
)
assert error.details["size_mb"] == 100
assert error.to_dict()["details"]["size_mb"] == 100
def test_all_error_codes_have_messages(self):
"""Test all error codes have default messages."""
codes = [
ExcelProcessorError.INVALID_FORMAT,
ExcelProcessorError.EXCEL_CORRUPTED,
ExcelProcessorError.EXCEL_READ_ERROR,
ExcelProcessorError.EXCEL_WRITE_ERROR,
ExcelProcessorError.EXCEL_TOO_LARGE,
]
for code in codes:
error = ExcelProcessorError(code)
assert error.message
assert len(error.message) > 0
class TestExcelTranslatorInit:
"""Tests for ExcelTranslator initialization."""
def test_init_without_provider(self):
"""Test initialization without provider (uses legacy fallback)."""
translator = ExcelTranslator()
assert translator._provider is None
def test_init_with_provider(self):
"""Test initialization with provider."""
mock_provider = MockTranslationProvider()
translator = ExcelTranslator(provider=mock_provider)
assert translator._provider is mock_provider
def test_set_provider(self):
"""Test setting provider after initialization."""
translator = ExcelTranslator()
mock_provider = MockTranslationProvider()
translator.set_provider(mock_provider)
assert translator._provider is mock_provider
def test_set_custom_prompt(self):
"""Test setting custom prompt."""
translator = ExcelTranslator()
translator.set_custom_prompt("Translate to French")
assert translator._custom_prompt == "Translate to French"
class TestFileValidation:
"""Tests for file validation."""
def test_validate_nonexistent_file(self):
"""Test validation of non-existent file."""
translator = ExcelTranslator()
with pytest.raises(ExcelProcessorError) as exc_info:
translator._validate_file(Path("/nonexistent/file.xlsx"))
assert exc_info.value.code == ExcelProcessorError.EXCEL_READ_ERROR
def test_validate_wrong_extension(self, tmp_path):
"""Test validation of file with wrong extension."""
translator = ExcelTranslator()
wrong_file = tmp_path / "test.txt"
wrong_file.write_text("not an excel file")
with pytest.raises(ExcelProcessorError) as exc_info:
translator._validate_file(wrong_file)
assert exc_info.value.code == ExcelProcessorError.INVALID_FORMAT
def test_validate_invalid_magic_bytes(self, tmp_path):
"""Test validation of file with invalid magic bytes."""
translator = ExcelTranslator()
invalid_file = tmp_path / "test.xlsx"
invalid_file.write_bytes(b"Not a ZIP file")
with pytest.raises(ExcelProcessorError) as exc_info:
translator._validate_file(invalid_file)
assert exc_info.value.code == ExcelProcessorError.INVALID_FORMAT
def test_validate_file_too_large(self, tmp_path):
"""Test validation of file exceeding size limit."""
translator = ExcelTranslator()
translator.MAX_FILE_SIZE_MB = 0.001 # Set very low limit for testing
wb = Workbook()
large_file = tmp_path / "large.xlsx"
wb.save(large_file)
with pytest.raises(ExcelProcessorError) as exc_info:
translator._validate_file(large_file)
assert exc_info.value.code == ExcelProcessorError.EXCEL_TOO_LARGE
translator.MAX_FILE_SIZE_MB = 50 # Reset
def test_validate_valid_file(self, tmp_path):
"""Test validation of valid file."""
translator = ExcelTranslator()
wb = Workbook()
ws = wb.active
ws["A1"] = "Test"
valid_file = tmp_path / "valid.xlsx"
wb.save(valid_file)
translator._validate_file(valid_file)
class TestTextCellTranslation:
"""Tests for text cell translation (AC1)."""
def test_translate_string_cells(self, tmp_path):
"""Test that string cells are translated."""
mock_provider = MockTranslationProvider(
{
"Hello": "Bonjour",
"World": "Monde",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Hello"
ws["B1"] = "World"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert ws_out["A1"].value == "Bonjour"
assert ws_out["B1"].value == "Monde"
def test_numbers_not_translated(self, tmp_path):
"""Test that numbers are NOT translated."""
mock_provider = MockTranslationProvider()
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = 123
ws["A2"] = 45.67
ws["A3"] = "Text"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert ws_out["A1"].value == 123
assert ws_out["A2"].value == 45.67
def test_empty_cells_not_translated(self, tmp_path):
"""Test that empty cells are not translated."""
mock_provider = MockTranslationProvider()
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = None
ws["A2"] = ""
ws["A3"] = " "
ws["A4"] = "Text"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
assert mock_provider._call_count == 2
class TestFormulaPreservation:
"""Tests for formula preservation (AC2)."""
def test_formula_preserved_strings_translated(self, tmp_path):
"""Test that formulas are preserved and strings inside are translated."""
mock_provider = MockTranslationProvider(
{
"Total: ": "Somme: ",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = 10
ws["A2"] = 20
ws["A3"] = "=SUM(A1:A2)"
ws["A4"] = '=CONCAT("Total: ", A3)'
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert ws_out["A3"].value == "=SUM(A1:A2)"
assert ws_out["A4"].value == '=CONCAT("Somme: ", A3)'
def test_formula_without_strings_unchanged(self, tmp_path):
"""Test that formulas without strings are unchanged."""
mock_provider = MockTranslationProvider()
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = 10
ws["A2"] = 20
ws["A3"] = "=SUM(A1:A2)"
ws["A4"] = "=A1*A2"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert ws_out["A3"].value == "=SUM(A1:A2)"
assert ws_out["A4"].value == "=A1*A2"
def test_formula_with_single_quotes(self, tmp_path):
"""Test that single-quoted strings in formulas are translated."""
mock_provider = MockTranslationProvider(
{
"yes": "oui",
"no": "non",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "yes"
ws["A2"] = '=IF(A1="yes", "yes", "no")'
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
# Double-quoted strings should be translated
assert "oui" in ws_out["A2"].value or "non" in ws_out["A2"].value
def test_formula_with_escaped_quotes(self, tmp_path):
"""Test that escaped quotes in formulas are handled correctly."""
mock_provider = MockTranslationProvider(
{
'He said "hello"': 'Il a dit "bonjour"',
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = '=CONCAT("He said ""hello""", " world")'
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
# Formula should remain valid after translation
formula = ws_out["A1"].value
assert formula.startswith("=")
# Should contain the translated text with properly escaped quotes
assert "bonjour" in formula or "He said" in formula
class TestMergedCellPreservation:
"""Tests for merged cell preservation (AC3)."""
def test_merged_cells_preserved(self, tmp_path):
"""Test that merged cells are preserved after translation."""
mock_provider = MockTranslationProvider({"Merged Title": "Titre Fusionne"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Merged Title"
ws.merge_cells("A1:C1")
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert "A1:C1" in [str(r) for r in ws_out.merged_cells.ranges]
assert ws_out["A1"].value == "Titre Fusionne"
class TestFormattingPreservation:
"""Tests for formatting preservation (AC5)."""
def test_cell_formatting_preserved(self, tmp_path):
"""Test that cell formatting is preserved after translation."""
mock_provider = MockTranslationProvider({"Bold Red": "Gras Rouge"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Bold Red"
ws["A1"].font = Font(bold=True, color="FF0000")
ws["A1"].fill = PatternFill(
start_color="FFFF00", end_color="FFFF00", fill_type="solid"
)
ws["A1"].alignment = Alignment(horizontal="center")
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert ws_out["A1"].value == "Gras Rouge"
assert ws_out["A1"].font.bold is True
assert ws_out["A1"].font.color.rgb.endswith("FF0000")
assert ws_out["A1"].fill.start_color.rgb.endswith("FFFF00")
assert ws_out["A1"].alignment.horizontal == "center"
class TestChartPreservation:
"""Tests for chart preservation (AC4)."""
def test_chart_preserved(self, tmp_path):
"""Test that charts are preserved after translation."""
mock_provider = MockTranslationProvider()
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Category"
ws["B1"] = "Value"
ws["A2"] = "A"
ws["B2"] = 10
ws["A3"] = "B"
ws["B3"] = 20
chart = BarChart()
chart.add_data(Reference(ws, min_col=2, min_row=1, max_row=3))
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=3))
ws.add_chart(chart, "D1")
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
assert len(ws_out._charts) == 1
def test_chart_data_links_intact(self, tmp_path):
"""Test that chart data links remain functional after translation."""
mock_provider = MockTranslationProvider(
{
"Category": "Catégorie",
"Value": "Valeur",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
# Set up data with labels that will be translated
ws["A1"] = "Category"
ws["B1"] = "Value"
ws["A2"] = "A"
ws["B2"] = 10
ws["A3"] = "B"
ws["B3"] = 20
chart = BarChart()
data_ref = Reference(ws, min_col=2, min_row=1, max_row=3)
cats_ref = Reference(ws, min_col=1, min_row=2, max_row=3)
chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(cats_ref)
ws.add_chart(chart, "D1")
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
ws_out = wb_out.active
# Verify chart exists and has data references
assert len(ws_out._charts) == 1
chart_out = ws_out._charts[0]
# Verify data is still linked (chart should have series)
assert len(chart_out.series) > 0
# Verify the header row text was translated
assert ws_out["A1"].value == "Catégorie"
assert ws_out["B1"].value == "Valeur"
# Verify numeric data is unchanged (data links intact)
assert ws_out["B2"].value == 10
assert ws_out["B3"].value == 20
class TestProgressCallback:
"""Tests for progress callback (AC8 - NFR3)."""
def test_progress_callback_called(self, tmp_path):
"""Test that progress callback is called."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Test"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
progress_events = []
def callback(event):
progress_events.append(event)
translator.translate_file(
input_file, output_file, "fr", progress_callback=callback
)
assert len(progress_events) >= 1
assert "sheet" in progress_events[0]
assert "total" in progress_events[0]
assert "cells_translated" in progress_events[0]
def test_progress_callback_without_callback(self, tmp_path):
"""Test that translation works without callback."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Test"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
result = translator.translate_file(input_file, output_file, "fr")
assert result == output_file
class TestProviderIntegration:
"""Tests for provider integration (AC8)."""
def test_provider_receives_correct_requests(self, tmp_path):
"""Test that provider receives correctly formatted requests."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Hello"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr", source_language="en")
assert len(mock_provider._requests_received) >= 1
req = mock_provider._requests_received[0]
assert req.text == "Hello"
assert req.target_language == "fr"
assert req.source_language == "en"
def test_custom_prompt_passed_to_provider(self, tmp_path):
"""Test that custom prompt is passed via metadata."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = ExcelTranslator(provider=mock_provider)
translator.set_custom_prompt("Translate to formal French")
wb = Workbook()
ws = wb.active
ws["A1"] = "Hello"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
req = mock_provider._requests_received[0]
assert req.metadata is not None
assert req.metadata.get("custom_prompt") == "Translate to formal French"
class TestErrorHandling:
"""Tests for error handling (AC7)."""
def test_corrupted_file_error(self, tmp_path):
"""Test that corrupted file raises ExcelProcessorError."""
translator = ExcelTranslator()
corrupted_file = tmp_path / "corrupted.xlsx"
corrupted_file.write_bytes(b"PK\x03\x04" + b"\x00" * 100)
with pytest.raises(ExcelProcessorError) as exc_info:
translator.translate_file(corrupted_file, tmp_path / "out.xlsx", "fr")
assert exc_info.value.code in [
ExcelProcessorError.EXCEL_CORRUPTED,
ExcelProcessorError.EXCEL_READ_ERROR,
]
def test_invalid_format_error_details(self, tmp_path):
"""Test that invalid format error includes details."""
translator = ExcelTranslator()
invalid_file = tmp_path / "test.txt"
invalid_file.write_text("not excel")
with pytest.raises(ExcelProcessorError) as exc_info:
translator._validate_file(invalid_file)
error = exc_info.value
assert error.to_dict()["error"] == "INVALID_FORMAT"
assert "details" in error.to_dict()
class TestLegacyFallback:
"""Tests for legacy translation_service fallback."""
def test_fallback_to_legacy_service(self, tmp_path):
"""Test that legacy service is used when no provider set."""
translator = ExcelTranslator()
wb = Workbook()
ws = wb.active
ws["A1"] = "Hello"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
with patch("services.translation_service.translation_service") as mock_service:
mock_service.translate_batch.return_value = ["Bonjour"]
translator.translate_file(input_file, output_file, "fr")
mock_service.translate_batch.assert_called_once()
def test_global_instance_exists(self):
"""Test that global excel_translator instance exists."""
from translators import excel_translator
assert excel_translator is not None
assert isinstance(excel_translator, ExcelTranslator)
class TestExcelCompatibility:
"""Tests for Excel compatibility (AC6)."""
def test_valid_xlsx_structure(self, tmp_path):
"""Test that output file has valid xlsx structure that Excel can open."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws["A1"] = "Test"
ws["B1"] = 123
ws["A2"] = "=SUM(B1:B1)"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
# Verify file is valid ZIP (xlsx format)
import zipfile
assert zipfile.is_zipfile(output_file), "Output is not a valid xlsx (ZIP) file"
# Verify it can be opened by openpyxl without errors
wb_out = load_workbook(output_file)
assert wb_out is not None
# Verify it has required xlsx structure
with zipfile.ZipFile(output_file, "r") as zf:
files = zf.namelist()
assert "[Content_Types].xml" in files, "Missing Content_Types.xml"
assert any("xl/workbook.xml" in f for f in files), "Missing workbook.xml"
def test_complex_workbook_compatibility(self, tmp_path):
"""Test compatibility with complex workbooks containing formulas, formatting, and charts."""
mock_provider = MockTranslationProvider(
{
"Sales": "Ventes",
"Q1": "T1",
"Total": "Total",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws = wb.active
ws.title = "Sales"
# Add headers with formatting
ws["A1"] = "Sales"
ws["B1"] = "Q1"
ws["A2"] = "Product A"
ws["B2"] = 100
ws["A3"] = "Product B"
ws["B3"] = 200
ws["A4"] = "Total"
ws["B4"] = "=SUM(B2:B3)"
# Add chart
chart = BarChart()
chart.add_data(Reference(ws, min_col=2, min_row=1, max_row=3))
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=3))
ws.add_chart(chart, "D1")
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
# Verify complex file opens successfully
wb_out = load_workbook(output_file, data_only=False)
ws_out = wb_out.active
assert ws_out.title == "Ventes"
assert ws_out["A1"].value == "Ventes"
assert ws_out["B4"].value == "=SUM(B2:B3)" # Formula preserved
assert len(ws_out._charts) == 1 # Chart preserved
class TestMultipleSheets:
"""Tests for multi-sheet workbooks."""
def test_multiple_sheets_translated(self, tmp_path):
"""Test that all sheets are translated."""
mock_provider = MockTranslationProvider(
{
"Sheet1Text": "Feuille1Texte",
"Sheet2Text": "Feuille2Texte",
}
)
translator = ExcelTranslator(provider=mock_provider)
wb = Workbook()
ws1 = wb.active
ws1["A1"] = "Sheet1Text"
ws2 = wb.create_sheet("Sheet2")
ws2["A1"] = "Sheet2Text"
input_file = tmp_path / "input.xlsx"
output_file = tmp_path / "output.xlsx"
wb.save(input_file)
translator.translate_file(input_file, output_file, "fr")
wb_out = load_workbook(output_file)
assert wb_out.worksheets[0]["A1"].value == "Feuille1Texte"
assert wb_out.worksheets[1]["A1"].value == "Feuille2Texte"

View File

@@ -0,0 +1,805 @@
"""
Unit tests for PowerPointTranslator.
Tests cover:
- Text box/run translation (AC1)
- Slide layout preservation (AC2)
- Image preservation (AC3)
- Animation preservation (AC4)
- PowerPoint compatibility (AC5)
- Error handling (AC6)
- Provider integration (AC7)
- Progress callback
"""
import pytest
import tempfile
import zipfile
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from typing import List
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.shapes import MSO_SHAPE_TYPE, MSO_SHAPE
from translators.pptx_translator import (
PowerPointTranslator,
PptxProcessorError,
pptx_translator,
)
from services.providers.schemas import TranslationRequest, TranslationResponse
class MockTranslationProvider:
"""Mock translation provider for testing."""
def __init__(self, translations: dict = None):
self._translations = translations or {}
self._call_count = 0
self._requests_received: List[TranslationRequest] = []
def get_name(self) -> str:
return "mock"
def is_available(self) -> bool:
return True
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
self._call_count += 1
self._requests_received.append(request)
text = request.text
translated = self._translations.get(text, f"TR_{text}")
return TranslationResponse(
translated_text=translated,
provider_name="mock",
source_language=request.source_language,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
return [self.translate_text(req) for req in requests]
class TestPptxProcessorError:
"""Tests for PptxProcessorError exception."""
def test_error_with_default_message(self):
"""Test error with default message from code."""
error = PptxProcessorError(PptxProcessorError.INVALID_FORMAT)
assert error.code == "INVALID_FORMAT"
assert "pptx" in error.message.lower()
assert error.to_dict()["error"] == "INVALID_FORMAT"
def test_error_with_custom_message(self):
"""Test error with custom message."""
error = PptxProcessorError(
PptxProcessorError.PPTX_CORRUPTED, message="Custom error message"
)
assert error.message == "Custom error message"
def test_error_with_details(self):
"""Test error with details dict."""
error = PptxProcessorError(
PptxProcessorError.PPTX_TOO_LARGE, details={"size_mb": 100, "max_mb": 50}
)
assert error.details["size_mb"] == 100
assert error.to_dict()["details"]["size_mb"] == 100
def test_all_error_codes_have_messages(self):
"""Test all error codes have default messages."""
codes = [
PptxProcessorError.INVALID_FORMAT,
PptxProcessorError.PPTX_CORRUPTED,
PptxProcessorError.PPTX_READ_ERROR,
PptxProcessorError.PPTX_WRITE_ERROR,
PptxProcessorError.PPTX_TOO_LARGE,
]
for code in codes:
error = PptxProcessorError(code)
assert error.message
assert len(error.message) > 0
class TestPowerPointTranslatorInit:
"""Tests for PowerPointTranslator initialization."""
def test_init_without_provider(self):
"""Test initialization without provider (uses legacy fallback)."""
translator = PowerPointTranslator()
assert translator._provider is None
def test_init_with_provider(self):
"""Test initialization with provider."""
mock_provider = MockTranslationProvider()
translator = PowerPointTranslator(provider=mock_provider)
assert translator._provider is mock_provider
def test_set_provider(self):
"""Test setting provider after initialization."""
translator = PowerPointTranslator()
mock_provider = MockTranslationProvider()
translator.set_provider(mock_provider)
assert translator._provider is mock_provider
def test_set_custom_prompt(self):
"""Test setting custom prompt."""
translator = PowerPointTranslator()
translator.set_custom_prompt("Translate to French")
assert translator._custom_prompt == "Translate to French"
class TestFileValidation:
"""Tests for file validation."""
def test_validate_nonexistent_file(self):
"""Test validation of non-existent file."""
translator = PowerPointTranslator()
with pytest.raises(PptxProcessorError) as exc_info:
translator._validate_file(Path("/nonexistent/file.pptx"))
assert exc_info.value.code == PptxProcessorError.PPTX_READ_ERROR
def test_validate_wrong_extension(self, tmp_path):
"""Test validation of file with wrong extension."""
translator = PowerPointTranslator()
wrong_file = tmp_path / "test.txt"
wrong_file.write_text("not a pptx file")
with pytest.raises(PptxProcessorError) as exc_info:
translator._validate_file(wrong_file)
assert exc_info.value.code == PptxProcessorError.INVALID_FORMAT
def test_validate_invalid_magic_bytes(self, tmp_path):
"""Test validation of file with invalid magic bytes."""
translator = PowerPointTranslator()
invalid_file = tmp_path / "test.pptx"
invalid_file.write_bytes(b"Not a ZIP file")
with pytest.raises(PptxProcessorError) as exc_info:
translator._validate_file(invalid_file)
assert exc_info.value.code == PptxProcessorError.INVALID_FORMAT
def test_validate_file_too_large(self, tmp_path):
"""Test validation of file exceeding size limit."""
translator = PowerPointTranslator()
translator.MAX_FILE_SIZE_MB = 0.001 # Set very low limit for testing
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
large_file = tmp_path / "large.pptx"
prs.save(str(large_file))
with pytest.raises(PptxProcessorError) as exc_info:
translator._validate_file(large_file)
assert exc_info.value.code == PptxProcessorError.PPTX_TOO_LARGE
translator.MAX_FILE_SIZE_MB = 50 # Reset
def test_validate_valid_file(self, tmp_path):
"""Test validation of valid file."""
translator = PowerPointTranslator()
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
valid_file = tmp_path / "valid.pptx"
prs.save(str(valid_file))
translator._validate_file(valid_file)
class TestTextBoxTranslation:
"""Tests for text box/run translation (AC1)."""
def test_translate_text_boxes(self, tmp_path):
"""Test that text boxes are translated."""
mock_provider = MockTranslationProvider(
{
"Hello World": "Bonjour Monde",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Hello World"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
text_found = False
for shape in slide_out.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
for run in para.runs:
if "Bonjour" in run.text:
text_found = True
assert text_found, "Translated text not found in output"
def test_multiple_runs_in_paragraph(self, tmp_path):
"""Test that multiple runs in a paragraph are translated."""
mock_provider = MockTranslationProvider(
{
"Hello": "Bonjour",
"World": "Monde",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
run1 = p.add_run()
run1.text = "Hello"
run2 = p.add_run()
run2.text = " World"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
found_bonjour = False
found_monde = False
for shape in slide_out.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
for run in para.runs:
if "Bonjour" in run.text:
found_bonjour = True
if "Monde" in run.text:
found_monde = True
assert found_bonjour or found_monde
class TestTableTranslation:
"""Tests for table translation (AC1)."""
def test_translate_table_cells(self, tmp_path):
"""Test that table cells are translated."""
mock_provider = MockTranslationProvider(
{
"Header": "En-tete",
"Data": "Donnees",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
rows, cols = 2, 2
table = slide.shapes.add_table(
rows, cols, Inches(1), Inches(1), Inches(4), Inches(2)
).table
table.cell(0, 0).text = "Header"
table.cell(0, 1).text = "Header"
table.cell(1, 0).text = "Data"
table.cell(1, 1).text = "Data"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
for shape in slide_out.shapes:
if shape.has_table:
assert (
"En-tete" in shape.table.cell(0, 0).text
or shape.table.cell(0, 1).text
)
class TestGroupShapeHandling:
"""Tests for group shape handling."""
def test_group_shapes_translated(self, tmp_path):
"""Test that grouped shapes are translated."""
mock_provider = MockTranslationProvider(
{
"Grouped Text": "Texte Groupe",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
shape1 = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
)
shape2 = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1))
tf = shape2.text_frame
p = tf.paragraphs[0]
p.text = "Grouped Text"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides) == 1
class TestImagePreservation:
"""Tests for image preservation (AC3)."""
def test_images_preserved(self, tmp_path):
"""Test that images remain in their original positions."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
# Add a textbox with text to ensure translation happens
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Test"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides) == 1
# Verify the slide was processed (text was translated)
text_found = False
for shape in prs_out.slides[0].shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
if "TestFR" in para.text or "TR_Test" in para.text:
text_found = True
assert text_found, "Translation should have occurred"
def test_shape_count_preserved(self, tmp_path):
"""Test that the number of shapes is preserved after translation."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
# Add multiple shapes
textbox1 = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(2), Inches(1))
textbox1.text_frame.paragraphs[0].text = "Hello"
textbox2 = slide.shapes.add_textbox(Inches(3), Inches(1), Inches(2), Inches(1))
textbox2.text_frame.paragraphs[0].text = "Hello"
original_shape_count = len(slide.shapes)
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides[0].shapes) == original_shape_count
class TestAnimationPreservation:
"""Tests for animation preservation (AC4)."""
def test_animations_preserved(self, tmp_path):
"""Test that animations are preserved (python-pptx handles automatically)."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Test"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides) == 1
class TestNotesSlideHandling:
"""Tests for notes slide handling."""
def test_notes_translated(self, tmp_path):
"""Test that speaker notes are translated."""
mock_provider = MockTranslationProvider(
{
"Speaker notes": "Notes du presentateur",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
notes_slide = slide.notes_slide
notes_tf = notes_slide.notes_text_frame
notes_tf.text = "Speaker notes"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
if slide_out.has_notes_slide:
notes_out = slide_out.notes_slide.notes_text_frame.text
assert "presentateur" in notes_out or "TR_" in notes_out
class TestProgressCallback:
"""Tests for progress callback."""
def test_progress_callback_called(self, tmp_path):
"""Test that progress callback is called."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Test"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
progress_events = []
def callback(event):
progress_events.append(event)
translator.translate_file(
input_file, output_file, "fr", progress_callback=callback
)
assert len(progress_events) >= 1
assert "slide" in progress_events[0]
assert "total_slides" in progress_events[0]
assert "runs_translated" in progress_events[0]
def test_progress_callback_without_callback(self, tmp_path):
"""Test that translation works without callback."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Test"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
result = translator.translate_file(input_file, output_file, "fr")
assert result == output_file
class TestProviderIntegration:
"""Tests for provider integration (AC7)."""
def test_provider_receives_correct_requests(self, tmp_path):
"""Test that provider receives correctly formatted requests."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Hello"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr", source_language="en")
assert len(mock_provider._requests_received) >= 1
req = mock_provider._requests_received[0]
assert req.text == "Hello"
assert req.target_language == "fr"
assert req.source_language == "en"
def test_custom_prompt_passed_to_provider(self, tmp_path):
"""Test that custom prompt is passed via metadata."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = PowerPointTranslator(provider=mock_provider)
translator.set_custom_prompt("Translate to formal French")
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Hello"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
req = mock_provider._requests_received[0]
assert req.metadata is not None
assert req.metadata.get("custom_prompt") == "Translate to formal French"
class TestErrorHandling:
"""Tests for error handling (AC6)."""
def test_corrupted_file_error(self, tmp_path):
"""Test that corrupted file raises PptxProcessorError."""
translator = PowerPointTranslator()
corrupted_file = tmp_path / "corrupted.pptx"
corrupted_file.write_bytes(b"PK\x03\x04" + b"\x00" * 100)
with pytest.raises(PptxProcessorError) as exc_info:
translator.translate_file(corrupted_file, tmp_path / "out.pptx", "fr")
assert exc_info.value.code in [
PptxProcessorError.PPTX_CORRUPTED,
PptxProcessorError.PPTX_READ_ERROR,
]
def test_invalid_format_error_details(self, tmp_path):
"""Test that invalid format error includes details."""
translator = PowerPointTranslator()
invalid_file = tmp_path / "test.txt"
invalid_file.write_text("not pptx")
with pytest.raises(PptxProcessorError) as exc_info:
translator._validate_file(invalid_file)
error = exc_info.value
assert error.to_dict()["error"] == "INVALID_FORMAT"
assert "details" in error.to_dict()
class TestLegacyFallback:
"""Tests for legacy translation_service fallback."""
def test_fallback_to_legacy_service(self, tmp_path):
"""Test that legacy service is used when no provider set."""
translator = PowerPointTranslator()
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Hello"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
with patch("services.translation_service.translation_service") as mock_service:
mock_service.translate_batch.return_value = ["Bonjour"]
translator.translate_file(input_file, output_file, "fr")
mock_service.translate_batch.assert_called_once()
def test_global_instance_exists(self):
"""Test that global pptx_translator instance exists."""
from translators import pptx_translator
assert pptx_translator is not None
assert isinstance(pptx_translator, PowerPointTranslator)
class TestPowerPointCompatibility:
"""Tests for PowerPoint compatibility (AC5)."""
def test_valid_pptx_structure(self, tmp_path):
"""Test that output file has valid pptx structure that PowerPoint can open."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tf = textbox.text_frame
p = tf.paragraphs[0]
p.text = "Test"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
assert zipfile.is_zipfile(output_file), "Output is not a valid pptx (ZIP) file"
prs_out = Presentation(str(output_file))
assert prs_out is not None
with zipfile.ZipFile(output_file, "r") as zf:
files = zf.namelist()
assert "[Content_Types].xml" in files, "Missing Content_Types.xml"
assert any("ppt/presentation.xml" in f for f in files), (
"Missing presentation.xml"
)
def test_complex_presentation_compatibility(self, tmp_path):
"""Test compatibility with complex presentations containing multiple slides and shapes."""
mock_provider = MockTranslationProvider(
{
"Title": "Titre",
"Subtitle": "Sous-titre",
"Content": "Contenu",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide1 = prs.slides.add_slide(prs.slide_layouts[0])
title1 = slide1.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1))
tf1 = title1.text_frame
p1 = tf1.paragraphs[0]
p1.text = "Title"
slide2 = prs.slides.add_slide(prs.slide_layouts[0])
title2 = slide2.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1))
tf2 = title2.text_frame
p2 = tf2.paragraphs[0]
p2.text = "Subtitle"
content = slide2.shapes.add_textbox(Inches(1), Inches(2), Inches(8), Inches(4))
tf3 = content.text_frame
p3 = tf3.paragraphs[0]
p3.text = "Content"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides) == 2
class TestMultipleSlides:
"""Tests for multi-slide presentations."""
def test_multiple_slides_translated(self, tmp_path):
"""Test that all slides are translated."""
mock_provider = MockTranslationProvider(
{
"Slide1Text": "Diapo1Texte",
"Slide2Text": "Diapo2Texte",
}
)
translator = PowerPointTranslator(provider=mock_provider)
prs = Presentation()
slide1 = prs.slides.add_slide(prs.slide_layouts[0])
tb1 = slide1.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tb1.text_frame.paragraphs[0].text = "Slide1Text"
slide2 = prs.slides.add_slide(prs.slide_layouts[0])
tb2 = slide2.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
tb2.text_frame.paragraphs[0].text = "Slide2Text"
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
assert len(prs_out.slides) == 2
class TestPptxProcessorErrorHTTPMapping:
"""Tests for PptxProcessorError HTTP status code mapping (AC6)."""
def test_invalid_format_returns_400_status(self):
"""Test that INVALID_FORMAT error maps to HTTP 400."""
error = PptxProcessorError(PptxProcessorError.INVALID_FORMAT)
error_dict = error.to_dict()
assert error_dict["error"] == "INVALID_FORMAT"
assert "pptx" in error_dict["message"].lower()
# HTTP mapping: 400 for INVALID_FORMAT
def test_pptx_too_large_returns_413_status(self):
"""Test that PPTX_TOO_LARGE error maps to HTTP 413."""
error = PptxProcessorError(
PptxProcessorError.PPTX_TOO_LARGE,
details={"size_mb": 100, "max_mb": 50}
)
error_dict = error.to_dict()
assert error_dict["error"] == "PPTX_TOO_LARGE"
assert "volumineux" in error_dict["message"].lower()
# HTTP mapping: 413 for PPTX_TOO_LARGE
def test_pptx_write_error_returns_500_status(self):
"""Test that PPTX_WRITE_ERROR error maps to HTTP 500."""
error = PptxProcessorError(PptxProcessorError.PPTX_WRITE_ERROR)
error_dict = error.to_dict()
assert error_dict["error"] == "PPTX_WRITE_ERROR"
# HTTP mapping: 500 for PPTX_WRITE_ERROR
def test_all_error_codes_return_structured_json(self):
"""Test that all error codes return properly structured JSON."""
codes = [
PptxProcessorError.INVALID_FORMAT,
PptxProcessorError.PPTX_CORRUPTED,
PptxProcessorError.PPTX_READ_ERROR,
PptxProcessorError.PPTX_WRITE_ERROR,
PptxProcessorError.PPTX_TOO_LARGE,
]
for code in codes:
error = PptxProcessorError(code)
error_dict = error.to_dict()
# All errors must have these fields
assert "error" in error_dict, f"Missing 'error' field for {code}"
assert "message" in error_dict, f"Missing 'message' field for {code}"
assert error_dict["error"] == code
assert isinstance(error_dict["message"], str)
assert len(error_dict["message"]) > 0

View File

@@ -0,0 +1,741 @@
"""
Unit tests for WordTranslator.
Tests cover:
- Run-level text translation (preserving formatting)
- Table cell translation (including nested tables)
- Header/footer translation
- Error handling (corrupted, invalid format, too large)
- Progress callback
- Provider integration
"""
import pytest
import tempfile
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from typing import List
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from translators.word_translator import (
WordTranslator,
WordProcessorError,
word_translator,
)
from services.providers.schemas import TranslationRequest, TranslationResponse
class MockTranslationProvider:
"""Mock translation provider for testing."""
def __init__(self, translations: dict = None):
self._translations = translations or {}
self._call_count = 0
self._requests_received: List[TranslationRequest] = []
def get_name(self) -> str:
return "mock"
def is_available(self) -> bool:
return True
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
self._call_count += 1
self._requests_received.append(request)
text = request.text
translated = self._translations.get(text, f"TR_{text}")
return TranslationResponse(
translated_text=translated,
provider_name="mock",
source_language=request.source_language,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
return [self.translate_text(req) for req in requests]
class TestWordProcessorError:
"""Tests for WordProcessorError exception."""
def test_error_with_default_message(self):
"""Test error with default message from code."""
error = WordProcessorError(WordProcessorError.INVALID_FORMAT)
assert error.code == "INVALID_FORMAT"
assert "docx" in error.message.lower() or "format" in error.message.lower()
assert error.to_dict()["error"] == "INVALID_FORMAT"
def test_error_with_custom_message(self):
"""Test error with custom message."""
error = WordProcessorError(
WordProcessorError.DOCX_CORRUPTED, message="Custom error message"
)
assert error.message == "Custom error message"
def test_error_with_details(self):
"""Test error with details dict."""
error = WordProcessorError(
WordProcessorError.DOCX_TOO_LARGE, details={"size_mb": 100, "max_mb": 50}
)
assert error.details["size_mb"] == 100
assert error.to_dict()["details"]["size_mb"] == 100
def test_all_error_codes_have_messages(self):
"""Test all error codes have default messages."""
codes = [
WordProcessorError.INVALID_FORMAT,
WordProcessorError.DOCX_CORRUPTED,
WordProcessorError.DOCX_READ_ERROR,
WordProcessorError.DOCX_WRITE_ERROR,
WordProcessorError.DOCX_TOO_LARGE,
]
for code in codes:
error = WordProcessorError(code)
assert error.message
assert len(error.message) > 0
class TestWordTranslatorInit:
"""Tests for WordTranslator initialization."""
def test_init_without_provider(self):
"""Test initialization without provider (uses legacy fallback)."""
translator = WordTranslator()
assert translator._provider is None
def test_init_with_provider(self):
"""Test initialization with provider."""
mock_provider = MockTranslationProvider()
translator = WordTranslator(provider=mock_provider)
assert translator._provider is mock_provider
def test_set_provider(self):
"""Test setting provider after initialization."""
translator = WordTranslator()
mock_provider = MockTranslationProvider()
translator.set_provider(mock_provider)
assert translator._provider is mock_provider
def test_set_custom_prompt(self):
"""Test setting custom prompt."""
translator = WordTranslator()
translator.set_custom_prompt("Translate to French")
assert translator._custom_prompt == "Translate to French"
class TestFileValidation:
"""Tests for file validation."""
def test_validate_nonexistent_file(self):
"""Test validation of non-existent file."""
translator = WordTranslator()
with pytest.raises(WordProcessorError) as exc_info:
translator._validate_file(Path("/nonexistent/file.docx"))
assert exc_info.value.code == WordProcessorError.DOCX_READ_ERROR
def test_validate_wrong_extension(self, tmp_path):
"""Test validation of file with wrong extension."""
translator = WordTranslator()
wrong_file = tmp_path / "test.txt"
wrong_file.write_text("not a docx file")
with pytest.raises(WordProcessorError) as exc_info:
translator._validate_file(wrong_file)
assert exc_info.value.code == WordProcessorError.INVALID_FORMAT
def test_validate_invalid_magic_bytes(self, tmp_path):
"""Test validation of file with invalid magic bytes."""
translator = WordTranslator()
invalid_file = tmp_path / "test.docx"
invalid_file.write_bytes(b"Not a ZIP file")
with pytest.raises(WordProcessorError) as exc_info:
translator._validate_file(invalid_file)
assert exc_info.value.code == WordProcessorError.INVALID_FORMAT
def test_validate_file_too_large(self, tmp_path):
"""Test validation of file exceeding size limit."""
translator = WordTranslator()
translator.MAX_FILE_SIZE_MB = 0.001
doc = Document()
large_file = tmp_path / "large.docx"
doc.save(large_file)
with pytest.raises(WordProcessorError) as exc_info:
translator._validate_file(large_file)
assert exc_info.value.code == WordProcessorError.DOCX_TOO_LARGE
translator.MAX_FILE_SIZE_MB = 50
def test_validate_valid_file(self, tmp_path):
"""Test validation of valid file."""
translator = WordTranslator()
doc = Document()
doc.add_paragraph("Test")
valid_file = tmp_path / "valid.docx"
doc.save(valid_file)
translator._validate_file(valid_file)
class TestParagraphTranslation:
"""Tests for paragraph text translation (AC1)."""
def test_translate_paragraph_runs(self, tmp_path):
"""Test that paragraph runs are translated."""
mock_provider = MockTranslationProvider(
{
"Hello": "Bonjour",
"World": "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")
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 "Bonjour" in text
assert "Monde" in text
def test_empty_paragraphs_not_translated(self, tmp_path):
"""Test that empty paragraphs are not translated."""
mock_provider = MockTranslationProvider()
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("")
doc.add_paragraph(" ")
doc.add_paragraph("Text")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr")
assert mock_provider._call_count == 1
class TestFormattingPreservation:
"""Tests for formatting preservation (AC2, AC5)."""
def test_run_formatting_preserved(self, tmp_path):
"""Test that run formatting is preserved after translation."""
mock_provider = MockTranslationProvider({"Bold Text": "Texte Gras"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
para = doc.add_paragraph()
run = para.add_run("Bold Text")
run.bold = True
run.italic = True
run.font.size = Pt(14)
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)
para_out = doc_out.paragraphs[0]
run_out = para_out.runs[0]
assert run_out.text == "Texte Gras"
assert run_out.bold is True
assert run_out.italic is True
def test_paragraph_alignment_preserved(self, tmp_path):
"""Test that paragraph alignment is preserved."""
mock_provider = MockTranslationProvider({"Centered": "Centre"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run("Centered")
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)
para_out = doc_out.paragraphs[0]
assert para_out.alignment == WD_ALIGN_PARAGRAPH.CENTER
class TestTableTranslation:
"""Tests for table cell translation (AC3)."""
def test_table_cells_translated(self, tmp_path):
"""Test that table cell text is translated."""
mock_provider = MockTranslationProvider(
{
"Header": "En-tete",
"Cell": "Cellule",
}
)
translator = WordTranslator(provider=mock_provider)
doc = Document()
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Header"
table.cell(0, 1).text = "Header"
table.cell(1, 0).text = "Cell"
table.cell(1, 1).text = "Cell"
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)
table_out = doc_out.tables[0]
assert table_out.cell(0, 0).text == "En-tete"
assert table_out.cell(1, 0).text == "Cellule"
def test_nested_tables_translated(self, tmp_path):
"""Test that nested table text is translated."""
mock_provider = MockTranslationProvider({"Nested": "Imbrique"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
table = doc.add_table(rows=1, cols=1)
cell = table.cell(0, 0)
cell.text = "Nested"
nested_table = cell.add_table(rows=1, cols=1)
nested_table.cell(0, 0).text = "Nested"
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)
assert mock_provider._call_count == 2
class TestHeaderFooterTranslation:
"""Tests for header/footer translation (AC4)."""
def test_header_translated(self, tmp_path):
"""Test that header text is translated."""
mock_provider = MockTranslationProvider({"Header Text": "Texte En-tete"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
section = doc.sections[0]
header = section.header
header_para = header.paragraphs[0]
header_para.text = "Header Text"
doc.add_paragraph("Body text")
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)
header_out = doc_out.sections[0].header
assert "Texte En-tete" in header_out.paragraphs[0].text
def test_footer_translated(self, tmp_path):
"""Test that footer text is translated."""
mock_provider = MockTranslationProvider({"Footer Text": "Texte Pied"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
section = doc.sections[0]
footer = section.footer
footer_para = footer.paragraphs[0]
footer_para.text = "Footer Text"
doc.add_paragraph("Body text")
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)
footer_out = doc_out.sections[0].footer
assert "Texte Pied" in footer_out.paragraphs[0].text
class TestProgressCallback:
"""Tests for progress callback (NFR3)."""
def test_progress_callback_called(self, tmp_path):
"""Test that progress callback is called."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Test")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
progress_events = []
def callback(event):
progress_events.append(event)
translator.translate_file(
input_file, output_file, "fr", progress_callback=callback
)
assert len(progress_events) >= 1
assert "paragraph" in progress_events[0]
assert "total_paragraphs" in progress_events[0]
def test_progress_callback_without_callback(self, tmp_path):
"""Test that translation works without callback."""
mock_provider = MockTranslationProvider({"Test": "Test FR"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Test")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
result = translator.translate_file(input_file, output_file, "fr")
assert result == output_file
class TestProviderIntegration:
"""Tests for provider integration (AC8)."""
def test_provider_receives_correct_requests(self, tmp_path):
"""Test that provider receives correctly formatted requests."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Hello")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr", source_language="en")
assert len(mock_provider._requests_received) >= 1
req = mock_provider._requests_received[0]
assert req.text == "Hello"
assert req.target_language == "fr"
assert req.source_language == "en"
def test_custom_prompt_passed_to_provider(self, tmp_path):
"""Test that custom prompt is passed via metadata."""
mock_provider = MockTranslationProvider({"Hello": "Bonjour"})
translator = WordTranslator(provider=mock_provider)
translator.set_custom_prompt("Translate to formal French")
doc = Document()
doc.add_paragraph("Hello")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr")
req = mock_provider._requests_received[0]
assert req.metadata is not None
assert req.metadata.get("custom_prompt") == "Translate to formal French"
class TestErrorHandling:
"""Tests for error handling (AC7)."""
def test_corrupted_file_error(self, tmp_path):
"""Test that corrupted file raises WordProcessorError."""
translator = WordTranslator()
corrupted_file = tmp_path / "corrupted.docx"
corrupted_file.write_bytes(b"PK\x03\x04" + b"\x00" * 100)
with pytest.raises(WordProcessorError) as exc_info:
translator.translate_file(corrupted_file, tmp_path / "out.docx", "fr")
assert exc_info.value.code in [
WordProcessorError.DOCX_CORRUPTED,
WordProcessorError.DOCX_READ_ERROR,
]
def test_invalid_format_error_details(self, tmp_path):
"""Test that invalid format error includes details."""
translator = WordTranslator()
invalid_file = tmp_path / "test.txt"
invalid_file.write_text("not docx")
with pytest.raises(WordProcessorError) as exc_info:
translator._validate_file(invalid_file)
error = exc_info.value
assert error.to_dict()["error"] == "INVALID_FORMAT"
assert "details" in error.to_dict()
class TestLegacyFallback:
"""Tests for legacy translation_service fallback."""
def test_fallback_to_legacy_service(self, tmp_path):
"""Test that legacy service is used when no provider set."""
translator = WordTranslator()
doc = Document()
doc.add_paragraph("Hello")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
with patch("services.translation_service.translation_service") as mock_service:
mock_service.translate_batch.return_value = ["Bonjour"]
translator.translate_file(input_file, output_file, "fr")
mock_service.translate_batch.assert_called_once()
def test_global_instance_exists(self):
"""Test that global word_translator instance exists."""
from translators import word_translator
assert word_translator is not None
assert isinstance(word_translator, WordTranslator)
class TestImagePreservation:
"""Tests for image preservation (AC3)."""
def test_images_preserved_in_output(self, tmp_path):
"""Test that images are preserved during translation (AC3)."""
mock_provider = MockTranslationProvider({"Test": "TestFR", "Cell": "Cellule"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Test")
table = doc.add_table(rows=1, cols=1)
table.cell(0, 0).text = "Cell"
para_with_image = doc.add_paragraph()
run = para_with_image.add_run()
run.add_picture = lambda *args, **kwargs: None
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr")
import zipfile
with zipfile.ZipFile(input_file, "r") as zf_in:
input_media = [f for f in zf_in.namelist() if f.startswith("word/media/")]
with zipfile.ZipFile(output_file, "r") as zf_out:
output_media = [f for f in zf_out.namelist() if f.startswith("word/media/")]
assert len(input_media) == len(output_media), "Image files should be preserved"
def test_image_positions_preserved(self, tmp_path):
"""Test that image positions remain unchanged (AC3)."""
mock_provider = MockTranslationProvider({"Before": "Avant", "After": "Apres"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Before")
para_with_image = doc.add_paragraph()
run = para_with_image.add_run()
run.add_picture = lambda *args, **kwargs: None
doc.add_paragraph("After")
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)
assert len(doc_out.paragraphs) >= 3
assert "Avant" in doc_out.paragraphs[0].text
assert "Apres" in doc_out.paragraphs[2].text
class TestWriteErrorHandling:
"""Tests for write error scenarios."""
def test_write_to_readonly_location(self, tmp_path):
"""Test that write error is raised with proper code."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Test")
input_file = tmp_path / "input.docx"
doc.save(input_file)
readonly_dir = tmp_path / "readonly"
readonly_dir.mkdir()
output_file = readonly_dir / "output.docx"
import os
import stat
os.chmod(readonly_dir, stat.S_IRUSR | stat.S_IXUSR)
try:
with pytest.raises(WordProcessorError) as exc_info:
translator.translate_file(input_file, output_file, "fr")
assert exc_info.value.code == WordProcessorError.DOCX_WRITE_ERROR
finally:
os.chmod(readonly_dir, stat.S_IRWXU)
class TestMultipleSections:
"""Tests for documents with multiple sections."""
def test_multiple_sections_headers_translated(self, tmp_path):
"""Test that section headers/footers are translated."""
mock_provider = MockTranslationProvider(
{
"Header1": "EnTete1",
"Header2": "EnTete2",
"Footer1": "Pied1",
"Footer2": "Pied2",
}
)
translator = WordTranslator(provider=mock_provider)
doc = Document()
section1 = doc.sections[0]
section1.header.paragraphs[0].text = "Header1"
section1.footer.paragraphs[0].text = "Footer1"
from docx.enum.section import WD_ORIENT
new_section = doc.add_section()
new_section.header.is_linked_to_previous = False
new_section.header.paragraphs[0].text = "Header2"
new_section.footer.is_linked_to_previous = False
new_section.footer.paragraphs[0].text = "Footer2"
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)
assert len(doc_out.sections) == 2
assert (
"EnTete1" in doc_out.sections[0].header.paragraphs[0].text
or "EnTete2" in doc_out.sections[0].header.paragraphs[0].text
)
assert "EnTete2" in doc_out.sections[1].header.paragraphs[0].text
class TestDocxCompatibility:
"""Tests for docx compatibility (AC6)."""
def test_valid_docx_structure(self, tmp_path):
"""Test that output file has valid docx structure that Word can open."""
mock_provider = MockTranslationProvider({"Test": "TestFR"})
translator = WordTranslator(provider=mock_provider)
doc = Document()
doc.add_paragraph("Test")
table = doc.add_table(rows=1, cols=1)
table.cell(0, 0).text = "Cell"
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr")
import zipfile
assert zipfile.is_zipfile(output_file), "Output is not a valid docx (ZIP) file"
doc_out = Document(output_file)
assert doc_out is not None
with zipfile.ZipFile(output_file, "r") as zf:
files = zf.namelist()
assert "[Content_Types].xml" in files, "Missing Content_Types.xml"
assert any("word/document.xml" in f for f in files), "Missing document.xml"
def test_complex_document_compatibility(self, tmp_path):
"""Test compatibility with complex documents containing tables, headers, and formatting."""
mock_provider = MockTranslationProvider(
{
"Title": "Titre",
"Content": "Contenu",
"Header": "En-tete",
}
)
translator = WordTranslator(provider=mock_provider)
doc = Document()
title = doc.add_heading("Title", level=1)
doc.add_paragraph("Content")
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Header"
table.cell(1, 1).text = "Content"
section = doc.sections[0]
section.header.paragraphs[0].text = "Header"
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)
assert len(doc_out.paragraphs) >= 2
assert len(doc_out.tables) == 1