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:
0
tests/test_translators/__init__.py
Normal file
0
tests/test_translators/__init__.py
Normal file
788
tests/test_translators/test_excel_translator.py
Normal file
788
tests/test_translators/test_excel_translator.py
Normal 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"
|
||||
805
tests/test_translators/test_pptx_translator.py
Normal file
805
tests/test_translators/test_pptx_translator.py
Normal 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
|
||||
741
tests/test_translators/test_word_translator.py
Normal file
741
tests/test_translators/test_word_translator.py
Normal 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
|
||||
Reference in New Issue
Block a user