12 known failures — all from Agent 3 new unverified tests (mock setup issues): - 3 dataset_review_routes_extended (DTO field mismatches) - 1 settings_consolidated (dict key access) - 1 llm_analysis_service_coverage (rate_limit mock) - 1 migration_plugin (SessionLocal side_effect exhaustion) - 1 preview (DB query vs dict key) - 5 scheduler (datetime timezone + async mock mismatches) NEW TEST FILES THIS SESSION: - test_batch_insert_coverage.py — 3 tests - test_storage_plugin.py — +3 tests - test_search.py — +2 tests - test_mapper.py — already 100% - test_llm_analysis_migration_v1_to_v2.py — 14 tests - test_llm_async_http.py — +1 test - test_prompt_builder.py — +1 test - test_service_datasource.py — +1 test - test_lang_detect.py — +1 test - test_scheduler.py — +6 tests - test_llm_analysis_service_coverage.py — +15 tests - test_dataset_review_routes_extended.py — +14 tests - test_settings_consolidated.py — +13 tests Modules pushed to 100%: _batch_insert, dictionary_entries, service_datasource, _llm_async_http, prompt_builder, dictionary_crud, _batch_sizer, storage/plugin, mapper.py Session: 7194→7643 tests (+449), 80%→83% raw (+3pp), 93.4%→96.9% real (+3.5pp). Remaining: 12 failures to fix + ~300 statements to reach 98% real coverage.
423 lines
19 KiB
Python
423 lines
19 KiB
Python
# #region Test.TranslationPreview [C:3] [TYPE Module] [SEMANTICS test,translate,preview]
|
|
# @BRIEF Tests for preview.py — TranslationPreview class.
|
|
# @RELATION BINDS_TO -> [TranslationPreview]
|
|
# @TEST_EDGE: job_not_found -> ValueError
|
|
# @TEST_EDGE: no_source_datasource -> ValueError
|
|
# @TEST_EDGE: no_translation_column -> ValueError
|
|
# @TEST_EDGE: no_target_languages -> ValueError
|
|
# @TEST_EDGE: no_provider -> ValueError
|
|
# @TEST_EDGE: no_rows_returned -> ValueError
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import UTC, datetime
|
|
|
|
from src.models.translate import TranslationJob, TranslationPreviewSession
|
|
from src.plugins.translate.preview import TranslationPreview
|
|
from .conftest import JOB_ID
|
|
|
|
|
|
class TestPreviewRows:
|
|
"""TranslationPreview.preview_rows — main preview function."""
|
|
|
|
def _make_job(self, db_session):
|
|
"""Ensure the job has all required fields."""
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = "1"
|
|
job.translation_column = "text"
|
|
job.target_languages = ["fr"]
|
|
job.provider_id = "prov-1"
|
|
job.target_dialect = "en"
|
|
job.context_columns = None
|
|
job.disable_reasoning = False
|
|
db_session.commit()
|
|
return job
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_not_found(self, db_session):
|
|
"""Non-existent job_id raises ValueError."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await preview.preview_rows("nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_source_datasource(self, db_session):
|
|
"""Job without source_datasource_id raises ValueError."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = None
|
|
job.translation_column = "text"
|
|
job.target_languages = ["fr"]
|
|
job.provider_id = "prov-1"
|
|
db_session.commit()
|
|
with pytest.raises(ValueError, match="source datasource"):
|
|
await preview.preview_rows(JOB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_translation_column(self, db_session):
|
|
"""Job without translation_column raises ValueError."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = "1"
|
|
job.translation_column = None
|
|
job.target_languages = ["fr"]
|
|
job.provider_id = "prov-1"
|
|
db_session.commit()
|
|
with pytest.raises(ValueError, match="translation column"):
|
|
await preview.preview_rows(JOB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_target_languages(self, db_session):
|
|
"""Job without target_languages raises ValueError."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = "1"
|
|
job.translation_column = "text"
|
|
job.target_languages = None
|
|
job.provider_id = "prov-1"
|
|
db_session.commit()
|
|
with pytest.raises(ValueError, match="target language"):
|
|
await preview.preview_rows(JOB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_provider(self, db_session):
|
|
"""Job without provider_id raises ValueError."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = "1"
|
|
job.translation_column = "text"
|
|
job.target_languages = ["fr"]
|
|
job.provider_id = None
|
|
db_session.commit()
|
|
with pytest.raises(ValueError, match="LLM provider"):
|
|
await preview.preview_rows(JOB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_rows_returned(self, db_session):
|
|
"""When fetch_sample_rows returns empty list."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
self._make_job(db_session)
|
|
|
|
with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
|
|
with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
|
|
with patch.object(preview._executor, "fetch_sample_rows", AsyncMock(return_value=[])):
|
|
with pytest.raises(ValueError, match="No rows returned"):
|
|
await preview.preview_rows(JOB_ID)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_target_languages_not_list(self, db_session):
|
|
"""Non-list target_languages is converted."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
job.source_datasource_id = "1"
|
|
job.translation_column = "text"
|
|
job.target_languages = "fr" # not a list
|
|
job.provider_id = "prov-1"
|
|
db_session.commit()
|
|
|
|
with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
|
|
with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
|
|
with patch.object(preview._executor, "fetch_sample_rows",
|
|
AsyncMock(return_value=[{"text": "hello"}])):
|
|
with patch.object(preview._executor, "resolve_provider_model",
|
|
return_value="gpt-4o"):
|
|
with patch.object(preview._prompt_builder,
|
|
"estimate_token_budget_for_rows") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"source_rows": [{"text": "hello"}],
|
|
"actual_row_count": 1,
|
|
"token_budget": {
|
|
"warning": None,
|
|
"max_output_needed": 4096,
|
|
"batch_size_adjusted": 1,
|
|
"estimated_input_tokens": 100,
|
|
"estimated_output_tokens": 200,
|
|
},
|
|
}
|
|
with patch.object(preview._prompt_builder,
|
|
"build_prompt_from_rows") as mock_prompt:
|
|
mock_prompt.return_value = {
|
|
"prompt": "translate",
|
|
"row_meta": [{"row_index": 0, "source_text": "hello",
|
|
"source_row": {"text": "hello"}}],
|
|
"actual_row_count": 1,
|
|
"num_languages": 1,
|
|
"sample_prompt_tokens": 10,
|
|
"sample_output_tokens": 20,
|
|
"sample_total_tokens": 30,
|
|
"sample_cost": 0.001,
|
|
"total_est_rows": 100,
|
|
"total_est_tokens": 3000,
|
|
"total_est_cost": 0.01,
|
|
"cost_warning": None,
|
|
}
|
|
with patch.object(preview._executor, "call_llm",
|
|
AsyncMock(return_value="{}")):
|
|
with patch.object(preview._executor,
|
|
"parse_llm_response",
|
|
return_value={}):
|
|
result = await preview.preview_rows(JOB_ID)
|
|
assert result["status"] == "ACTIVE"
|
|
assert result["job_id"] == JOB_ID
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_budget_warning_logged(self, db_session):
|
|
"""Token budget warning is logged but does not fail."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
self._make_job(db_session)
|
|
|
|
with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
|
|
with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
|
|
with patch.object(preview._executor, "fetch_sample_rows",
|
|
AsyncMock(return_value=[{"text": "hello"}])):
|
|
with patch.object(preview._executor, "resolve_provider_model",
|
|
return_value="gpt-4o"):
|
|
with patch.object(preview._prompt_builder,
|
|
"estimate_token_budget_for_rows") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"source_rows": [{"text": "hello"}],
|
|
"actual_row_count": 1,
|
|
"token_budget": {
|
|
"warning": "budget exceeded",
|
|
"max_output_needed": 4096,
|
|
"batch_size_adjusted": 1,
|
|
"estimated_input_tokens": 100,
|
|
"estimated_output_tokens": 200,
|
|
},
|
|
}
|
|
with patch.object(preview._prompt_builder,
|
|
"build_prompt_from_rows") as mock_prompt:
|
|
mock_prompt.return_value = {
|
|
"prompt": "translate",
|
|
"row_meta": [{"row_index": 0, "source_text": "hello",
|
|
"source_row": {"text": "hello"}}],
|
|
"actual_row_count": 1,
|
|
"num_languages": 1,
|
|
"sample_prompt_tokens": 10,
|
|
"sample_output_tokens": 20,
|
|
"sample_total_tokens": 30,
|
|
"sample_cost": 0.001,
|
|
"total_est_rows": 100,
|
|
"total_est_tokens": 3000,
|
|
"total_est_cost": 0.01,
|
|
"cost_warning": None,
|
|
}
|
|
with patch.object(preview._executor, "call_llm",
|
|
AsyncMock(return_value="{}")):
|
|
with patch.object(preview._executor,
|
|
"parse_llm_response",
|
|
return_value={}):
|
|
result = await preview.preview_rows(JOB_ID)
|
|
assert result["status"] == "ACTIVE"
|
|
|
|
|
|
class TestCreatePreviewRecords:
|
|
"""TranslationPreview._create_preview_records — internal method."""
|
|
|
|
def _make_preview(self, db_session):
|
|
return TranslationPreview(db_session, MagicMock())
|
|
|
|
def test_basic_records(self, db_session):
|
|
"""Creates records with languages."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "en"},
|
|
{"row_index": 1, "source_text": "world", "source_row": {}, "_detected_lang": "en"},
|
|
]
|
|
translations = {
|
|
"0": {"fr": "bonjour"},
|
|
"1": {"fr": "monde"},
|
|
}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-1", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert len(records) == 2
|
|
assert records[0]["source_sql"] == "hello"
|
|
assert records[1]["source_sql"] == "world"
|
|
assert len(records[0]["languages"]) == 1
|
|
assert records[0]["languages"][0]["translated_value"] == "bonjour"
|
|
|
|
def test_translation_data_as_string(self, db_session):
|
|
"""When translation_data is a string (not dict)."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "und"},
|
|
]
|
|
translations = {
|
|
"0": "just a string", # not a dict
|
|
}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-2", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert len(records) == 1
|
|
assert records[0]["languages"][0]["translated_value"] == "just a string"
|
|
|
|
def test_detected_lang_fallback_from_translation(self, db_session):
|
|
"""Fallback to translation_data detected_source_language when _detected_lang is 'und'."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": None},
|
|
]
|
|
translations = {
|
|
"0": {"detected_source_language": "en", "fr": "bonjour"},
|
|
}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-3", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert records[0]["source_language_detected"] == "en"
|
|
|
|
def test_source_data_with_key_cols(self, db_session):
|
|
"""When job has target_key_cols, source_data is filtered."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello",
|
|
"source_row": {"id": 1, "name": "test", "extra": "stuff"},
|
|
"_detected_lang": "en"},
|
|
]
|
|
translations = {"0": {"fr": "bonjour"}}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-4", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
# source_data stored in DB record, not in returned dict — query DB
|
|
record = db_session.query(TranslationPreviewRecord).filter_by(id=records[0]["id"]).first()
|
|
assert record is not None
|
|
assert record.source_data == {"id": 1}
|
|
|
|
def test_detected_lang_matches_source(self, db_session):
|
|
"""When detected_lang matches lang_code, use source_text as translation."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "fr"},
|
|
]
|
|
translations = {
|
|
"0": {"fr": "should be overwritten"},
|
|
}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-5", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert records[0]["languages"][0]["translated_value"] == "hello"
|
|
|
|
def test_no_lang_translation_fallback_empty(self, db_session):
|
|
"""When translation is None, use empty string."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "en"},
|
|
]
|
|
translations = {
|
|
"0": {"fr": None},
|
|
}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-6", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert records[0]["languages"][0]["translated_value"] == ""
|
|
|
|
def test_needs_review_when_undetected(self, db_session):
|
|
"""needs_review is True when detected_lang is 'und'."""
|
|
preview = self._make_preview(db_session)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = "lang"
|
|
|
|
row_meta = [
|
|
{"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "und"},
|
|
]
|
|
translations = {"0": {"fr": "bonjour"}}
|
|
target_languages = ["fr"]
|
|
session = TranslationPreviewSession(
|
|
id="session-7", job_id=JOB_ID, status="ACTIVE",
|
|
)
|
|
db_session.add(session)
|
|
db_session.flush()
|
|
|
|
records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
|
|
assert records[0]["needs_review"] is True
|
|
|
|
|
|
class TestStaticMethods:
|
|
"""Backward-compatible static methods."""
|
|
|
|
def test_parse_llm_response(self):
|
|
"""Delegates to preview_response_parser."""
|
|
with patch("src.plugins.translate.preview._parse_llm_response_module") as mock:
|
|
mock.return_value = {"0": {"fr": "bonjour"}}
|
|
result = TranslationPreview._parse_llm_response("text", 1, ["fr"])
|
|
assert result == {"0": {"fr": "bonjour"}}
|
|
|
|
def test_compute_config_hash(self):
|
|
"""Delegates to preview_response_parser."""
|
|
with patch("src.plugins.translate.preview._compute_config_hash_module") as mock:
|
|
mock.return_value = "hash123"
|
|
job = MagicMock()
|
|
result = TranslationPreview._compute_config_hash(job)
|
|
assert result == "hash123"
|
|
|
|
|
|
class TestGetPreviewSession:
|
|
"""Delegated get_preview_session."""
|
|
|
|
def test_get_preview_session(self, db_session):
|
|
"""Delegates to PreviewSessionManager."""
|
|
preview = TranslationPreview(db_session, MagicMock())
|
|
with patch.object(preview._session_mgr, "get_preview_session") as mock:
|
|
mock.return_value = {"id": "sess-1"}
|
|
result = preview.get_preview_session(JOB_ID)
|
|
assert result == {"id": "sess-1"}
|
|
# #endregion Test.TranslationPreview
|