test: 7 agents — plugins ~158 tests, extractor 98-100%, dashboard routes 99-100%, git services 94-100%, services 94-100%, dataset_review 100%. Fix test_api_key_routes.py sys.modules pollution

This commit is contained in:
2026-06-15 18:30:05 +03:00
parent 3de67c258a
commit 51d90f58c1
40 changed files with 9629 additions and 4 deletions

View File

@@ -0,0 +1,51 @@
# #region TranslateTestConftest [C:2] [TYPE Module] [SEMANTICS test, conftest, translate]
# @BRIEF Shared fixtures for translate plugin tests.
import uuid
from datetime import UTC, datetime
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
import pytest
from src.models.translate import Base, TranslationJob, TranslationRun
from src.plugins.translate.events import TranslationEventLog
# Shared IDs for FK references
JOB_ID = "test-job-id"
RUN_ID = "test-run-id"
@pytest.fixture(scope="function")
def db_session():
"""Create a fresh in-memory SQLite DB with FK enforcement and base records."""
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine)()
# Create base TranslationJob
job = TranslationJob(id=JOB_ID, name="Base Test Job", status="ACTIVE",
source_dialect="en", target_dialect="fr")
session.add(job)
session.commit()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def db_with_run(db_session):
"""Create a TranslationRun in addition to the base job, with RUN_STARTED event."""
run = TranslationRun(id=RUN_ID, job_id=JOB_ID, status="RUNNING",
started_at=datetime.now(UTC))
db_session.add(run)
db_session.commit()
# Log RUN_STARTED event so downstream events can be logged
event_log = TranslationEventLog(db_session)
event_log.log_event(job_id=JOB_ID, run_id=RUN_ID, event_type="RUN_STARTED",
payload={}, created_by="test")
return db_session, RUN_ID
# #endregion TranslateTestConftest

View File

@@ -0,0 +1,383 @@
# #region Test.BatchProcessingService [C:3] [TYPE Module] [SEMANTICS test, batch, process, classify, llm]
# @BRIEF Verify BatchProcessingService contracts — process_batch, _classify, _detect_languages, _check_cache, _persist_pre.
# @RELATION BINDS_TO -> [BatchProcessingService]
# @TEST_EDGE: empty_batch -> Returns zeros
# @TEST_EDGE: all_same_language -> No LLM call, all in pre_rows
# @TEST_EDGE: all_cached -> No LLM call
# @TEST_EDGE: preview_edits_cache -> Uses approved translation
# @TEST_EDGE: llm_rows -> Calls LLM service
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.translate import (
TranslationBatch, TranslationJob, TranslationLanguage,
TranslationRecord,
)
from src.plugins.translate._batch_proc import BatchProcessingService
from .conftest import JOB_ID, RUN_ID
def _make_job(session, target_langs=None):
job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
if job is None:
job = TranslationJob(
id=JOB_ID, name="Batch Proc Test", source_dialect="en",
target_dialect=target_langs[0] if target_langs else "fr",
target_languages=target_langs or ["fr"],
status="ACTIVE", provider_id="test-provider",
)
session.add(job)
else:
if target_langs:
job.target_languages = target_langs
job.target_dialect = target_langs[0]
session.commit()
return job
def _batch_rows(count=2, detected_lang="en"):
return [
{
"row_index": str(i),
"source_text": f"Hello world {i}",
"source_data": {"table": "test", "row_id": i},
"_detected_lang": detected_lang,
}
for i in range(count)
]
class TestInit:
"""Verify initialization."""
def test_init(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
assert svc.db is db_session
assert svc.config_manager is config
assert svc._llm_service is not None
class TestCreateBatch:
"""Verify _create_batch."""
def test_creates_batch_record(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
rows = _batch_rows(3)
batch = svc._create_batch(run_id, 0, rows)
assert batch.run_id == run_id
assert batch.batch_index == 0
assert batch.total_records == 3
assert batch.status == "RUNNING"
fetched = session.query(TranslationBatch).filter(
TranslationBatch.id == batch.id
).first()
assert fetched is not None
class TestDetectLanguages:
"""Verify _detect_languages."""
def test_detects_languages(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = _batch_rows(2)
with patch('src.plugins.translate._batch_proc.batch_detect',
return_value=["en", "fr"]):
svc._detect_languages(rows, ["fr", "de"])
assert rows[0]["_detected_lang"] == "en"
assert rows[1]["_detected_lang"] == "fr"
class TestCheckCache:
"""Verify _check_cache."""
def test_cache_miss(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
job = _make_job(db_session)
rows = _batch_rows(1)
with patch('src.plugins.translate._batch_proc._check_translation_cache',
return_value=None):
svc._check_cache(job, rows, "hash1", "hash2")
assert "_cached_lang_values" not in rows[0]
assert "_source_hash" in rows[0]
def test_cache_hit(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
job = _make_job(db_session)
rows = _batch_rows(1)
with patch('src.plugins.translate._batch_proc._check_translation_cache',
return_value={"fr": "Bonjour"}):
svc._check_cache(job, rows, "hash1", "hash2")
assert rows[0]["_cached_lang_values"] == {"fr": "Bonjour"}
def test_approved_translation_skips_cache(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
job = _make_job(db_session)
rows = [{"row_index": "0", "source_text": "hello", "approved_translation": "Bonjour"}]
svc._check_cache(job, rows, "hash1", "hash2")
assert "_cached_lang_values" not in rows[0]
class TestClassify:
"""Verify _classify rows into llm_rows and pre_rows."""
def test_same_language_shortcut(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = _batch_rows(1, detected_lang="fr")
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
assert len(pre_rows) == 1
assert len(llm_rows) == 0
assert rows[0].get("_same_language") is True
def test_partial_language_match_still_llm(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = _batch_rows(1, detected_lang="fr")
llm_rows, pre_rows = svc._classify(rows, None, ["fr", "de"])
assert len(pre_rows) == 0
assert len(llm_rows) == 1
def test_approved_translation_pre_row(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = [{"row_index": "0", "source_text": "hello",
"_detected_lang": "en", "approved_translation": "Bonjour"}]
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
assert len(pre_rows) == 1
assert len(llm_rows) == 0
def test_cached_values_pre_row(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = [{"row_index": "0", "source_text": "hello",
"_detected_lang": "en",
"_cached_lang_values": {"fr": "Bonjour"}}]
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
assert len(pre_rows) == 1
assert len(llm_rows) == 0
def test_preview_edits_cache(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en",
"source_data": {"table": "test", "key": "val"}}]
preview_cache = {"hash_of_val": {"fr": "Bonjour"}}
with patch('src.plugins.translate._batch_proc._compute_key_hash',
return_value="hash_of_val"):
llm_rows, pre_rows = svc._classify(rows, preview_cache, ["fr"])
assert len(pre_rows) == 1
assert len(llm_rows) == 0
assert rows[0].get("approved_translation") is not None
def test_llm_row_default(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = _batch_rows(1, detected_lang="en")
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
assert len(pre_rows) == 0
assert len(llm_rows) == 1
def test_und_language_goes_to_llm(self, db_session):
config = MagicMock()
svc = BatchProcessingService(db_session, config)
rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "und"}]
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
assert len(llm_rows) == 1
class TestPersistPre:
"""Verify _persist_pre."""
def test_persist_same_language(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
rows = [{"row_index": "0", "source_text": "Bonjour le monde",
"_detected_lang": "fr", "_same_language": True}]
count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"])
assert count == 1
records = session.query(TranslationRecord).all()
assert len(records) == 1
assert records[0].target_sql == "Bonjour le monde"
langs = session.query(TranslationLanguage).all()
lang_codes = [l.language_code for l in langs]
assert "fr" in lang_codes
assert "de" not in lang_codes
def test_persist_cached_values(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
rows = [{"row_index": "0", "source_text": "hello",
"_detected_lang": "en",
"_cached_lang_values": {"fr": "Bonjour", "de": "Hallo"}}]
count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"])
assert count == 1
langs = session.query(TranslationLanguage).all()
lang_dict = {l.language_code: l.final_value for l in langs}
assert lang_dict["fr"] == "Bonjour"
assert lang_dict["de"] == "Hallo"
def test_persist_approved_translation(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
rows = [{"row_index": "0", "source_text": "hello",
"_detected_lang": "en",
"approved_translation": "Bonjour le monde"}]
count = svc._persist_pre(rows, "batch-1", run_id, ["fr"])
assert count == 1
records = session.query(TranslationRecord).all()
assert records[0].target_sql == "Bonjour le monde"
class TestProcessLlm:
"""Verify _process_llm."""
@pytest.mark.asyncio
async def test_process_llm_calls_service(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session)
rows = _batch_rows(1)
with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv:
mock_prov = MockProv.return_value
mock_prov.get_provider_token_config.return_value = {
"model": "gpt-4", "context_window": 8192, "max_output_tokens": 4096,
}
with patch('src.plugins.translate._batch_proc.estimate_token_budget',
return_value={
"max_output_needed": 1000, "max_rows_by_output": 10,
"estimated_input_tokens": 500, "warning": None,
}):
with patch.object(svc._llm_service, 'call_llm_for_batch',
new=AsyncMock(return_value={"successful": 1, "failed": 0,
"skipped": 0, "retries": 0})):
result = await svc._process_llm(
job, run_id, rows, [], "batch-1", ["fr"],
)
assert result["successful"] == 1
@pytest.mark.asyncio
async def test_process_llm_token_warning(self, db_with_run):
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session)
rows = _batch_rows(1)
with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv:
mock_prov = MockProv.return_value
mock_prov.get_provider_token_config.return_value = {
"model": "gpt-4", "context_window": 100, "max_output_tokens": 50,
}
with patch('src.plugins.translate._batch_proc.estimate_token_budget',
return_value={
"max_output_needed": 2000, "max_rows_by_output": 1,
"estimated_input_tokens": 5000, "warning": "High token usage",
}):
with patch.object(svc._llm_service, 'call_llm_for_batch',
new=AsyncMock(return_value={"successful": 1, "failed": 0,
"skipped": 0, "retries": 0})):
result = await svc._process_llm(
job, run_id, rows, [], "batch-1", ["fr"],
)
assert result["successful"] == 1
class TestProcessBatch:
"""Verify process_batch orchestration."""
@pytest.mark.asyncio
async def test_full_batch_flow(self, db_with_run):
"""Happy: full process_batch flow with LLM calls."""
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2, detected_lang="en")
with patch.object(svc, '_process_llm',
new=AsyncMock(return_value={"successful": 2, "failed": 0,
"skipped": 0, "retries": 0})):
with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch',
return_value=[]):
result = await svc.process_batch(
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
)
assert result["successful"] == 2
assert "batch_id" in result
batch = session.query(TranslationBatch).filter(
TranslationBatch.id == result["batch_id"]
).first()
assert batch is not None
assert batch.status == "COMPLETED"
assert batch.successful_records == 2
@pytest.mark.asyncio
async def test_batch_all_same_language(self, db_with_run):
"""All rows are same-language — no LLM calls."""
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2, detected_lang="fr")
result = await svc.process_batch(
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
)
assert result["successful"] == 2
assert result.get("cache_hits", 0) == 0
records = session.query(TranslationRecord).all()
assert len(records) == 2
@pytest.mark.asyncio
async def test_batch_with_errors(self, db_with_run):
"""Some LLM rows fail."""
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2, detected_lang="en")
with patch.object(svc, '_process_llm',
new=AsyncMock(return_value={"successful": 0, "failed": 2,
"skipped": 0, "retries": 3})):
with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch',
return_value=[]):
result = await svc.process_batch(
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
)
assert result["failed"] == 2
batch = session.query(TranslationBatch).filter(
TranslationBatch.id == result["batch_id"]
).first()
assert batch.status == "COMPLETED_WITH_ERRORS"
@pytest.mark.asyncio
async def test_batch_with_cache_hits(self, db_with_run):
"""Cache hits counted."""
session, run_id = db_with_run
config = MagicMock()
svc = BatchProcessingService(session, config)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2, detected_lang="en")
for r in rows:
r["_cached_lang_values"] = {"fr": "Bonjour"}
result = await svc.process_batch(
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
)
assert result["successful"] == 2
assert result.get("cache_hits", 0) == 2
# #endregion Test.BatchProcessingService

View File

@@ -0,0 +1,309 @@
# #region Test.DictionaryBatchFilter [C:3] [TYPE Module] [SEMANTICS test, dictionary, filter, batch]
# @BRIEF Verify DictionaryBatchFilter.filter_for_batch — word boundary, regex, language pairs, context, sorting.
# @RELATION BINDS_TO -> [DictionaryBatchFilter]
# @TEST_EDGE: no_dictionaries -> Returns empty list
# @TEST_EDGE: no_entries -> Returns empty list
# @TEST_EDGE: empty_source_text -> Skips gracefully
# @TEST_EDGE: regex_invalid -> Silently skipped
# @TEST_EDGE: context_priority -> Priority flag set when context matches
import pytest
from unittest.mock import MagicMock, patch
from src.models.translate import (
DictionaryEntry, TerminologyDictionary,
TranslationJobDictionary,
)
from src.plugins.translate.dictionary_filter import DictionaryBatchFilter
from .conftest import JOB_ID
class TestFilterForBatch:
"""Verify DictionaryBatchFilter.filter_for_batch."""
def _link_dict(self, session, dict_id):
"""Link dictionary to the base job."""
link = TranslationJobDictionary(job_id=JOB_ID, dictionary_id=dict_id)
session.add(link)
session.commit()
def test_empty_text_list(self, db_session):
"""Edge: empty source_texts list returns empty."""
d = TerminologyDictionary(id="dict-empty", name="Test", is_active=True)
db_session.add(d)
db_session.flush()
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="entry-1", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(db_session, [], JOB_ID)
assert result == []
def test_no_dictionaries_attached(self, db_session):
"""Negative: no job-dict links returns empty."""
result = DictionaryBatchFilter.filter_for_batch(db_session, ["hello"], JOB_ID)
assert result == []
def test_no_active_dictionaries(self, db_session):
"""Negative: dictionary exists but is not active."""
d = TerminologyDictionary(id="dict-inactive", name="Inactive", is_active=False)
db_session.add(d)
self._link_dict(db_session, d.id)
result = DictionaryBatchFilter.filter_for_batch(db_session, ["hello"], JOB_ID)
assert result == []
def test_no_entries_in_dictionary(self, db_session):
"""Negative: no entries returns empty."""
d = TerminologyDictionary(id="dict-no-entries", name="Empty", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
result = DictionaryBatchFilter.filter_for_batch(db_session, ["hello"], JOB_ID)
assert result == []
def test_basic_matching(self, db_session):
"""Happy: basic case-insensitive word-boundary matching."""
d = TerminologyDictionary(id="dict-basic", name="Test", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e1", dictionary_id=d.id,
source_term="Hello", source_term_normalized="hello",
target_term="Hola", source_language="en", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(db_session, ["Hello World"], JOB_ID)
assert len(result) == 1
assert result[0]["source_term"] == "Hello"
assert result[0]["target_term"] == "Hola"
assert result[0]["text_index"] == 0
def test_word_boundary_no_match(self, db_session):
"""Negative: word boundary prevents substring match."""
d = TerminologyDictionary(id="dict-wb", name="WB", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-cat", dictionary_id=d.id,
source_term="cat", source_term_normalized="cat",
target_term="gato", source_language="en", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(db_session, ["catalog"], JOB_ID)
assert len(result) == 0
def test_empty_source_text_skipped(self, db_session):
"""Edge: empty source text is skipped."""
d = TerminologyDictionary(id="dict-emptyskp", name="Test", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e1", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["hello", "", "world"], JOB_ID
)
assert len(result) == 2
indices = {m["text_index"] for m in result}
assert 0 in indices
assert 2 in indices
def test_source_language_filter(self, db_session):
"""Edge: filter by source language."""
d = TerminologyDictionary(id="dict-src", name="Src", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
e1 = DictionaryEntry(
id="e-en", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es",
)
e2 = DictionaryEntry(
id="e-fr", dictionary_id=d.id,
source_term="bonjour", source_term_normalized="bonjour",
target_term="hola", source_language="fr", target_language="es",
)
db_session.add_all([e1, e2])
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["hello bonjour"], JOB_ID, source_language="en",
)
assert len(result) == 1
assert result[0]["source_term"] == "hello"
def test_target_language_filter(self, db_session):
"""Edge: filter by target language."""
d = TerminologyDictionary(id="dict-tgt", name="Tgt", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
e1 = DictionaryEntry(
id="e-es", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es",
)
e2 = DictionaryEntry(
id="e-de", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hallo", source_language="en", target_language="de",
)
db_session.add_all([e1, e2])
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["hello"], JOB_ID, target_language="de",
)
assert len(result) == 1
assert result[0]["target_term"] == "hallo"
def test_source_language_und_always_matches(self, db_session):
"""Edge: entry with und source matches any source."""
d = TerminologyDictionary(id="dict-und", name="Und", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-und", dictionary_id=d.id,
source_term="foo", source_term_normalized="foo",
target_term="bar", source_language="und", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["foo"], JOB_ID, source_language="de",
)
assert len(result) == 1
def test_regex_matching(self, db_session):
"""Happy: regex entry matches pattern."""
d = TerminologyDictionary(id="dict-regex1", name="Regex", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-regex", dictionary_id=d.id,
source_term=r"\d{3}-\d{4}",
source_term_normalized=r"\d{3}-\d{4}",
target_term="MASKED", source_language="en", target_language="en",
is_regex=True,
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["Call 555-1234 now"], JOB_ID
)
assert len(result) == 1
assert result[0]["target_term"] == "MASKED"
def test_regex_no_match(self, db_session):
"""Negative: regex doesn't match text."""
d = TerminologyDictionary(id="dict-regex2", name="Regex", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-regex", dictionary_id=d.id,
source_term=r"\d{5}", source_term_normalized=r"\d{5}",
target_term="ZIP", source_language="en", target_language="en",
is_regex=True,
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["hello world"], JOB_ID
)
assert len(result) == 0
def test_regex_invalid_pattern(self, db_session):
"""Edge: invalid regex pattern is silently skipped."""
d = TerminologyDictionary(id="dict-regex3", name="Bad", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-bad-regex", dictionary_id=d.id,
source_term=r"[unclosed", source_term_normalized=r"[unclosed",
target_term="BAD", source_language="en", target_language="en",
is_regex=True,
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["anything"], JOB_ID
)
assert len(result) == 0
def test_priority_match_with_context(self, db_session):
"""Edge: context-aware priority match."""
d = TerminologyDictionary(id="dict-ctx", name="Ctx", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-ctx", dictionary_id=d.id,
source_term="bank", source_term_normalized="bank",
target_term="banco", source_language="en", target_language="es",
has_context=True, context_data={"domain": "finance"},
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["bank account"], JOB_ID,
row_context={"domain": "finance"},
)
assert len(result) >= 1
def test_dictionary_sorting(self, db_session):
"""Edge: results sorted by dictionary link order."""
d1 = TerminologyDictionary(id="d-p1", name="Priority1", is_active=True)
d2 = TerminologyDictionary(id="d-p2", name="Priority2", is_active=True)
db_session.add_all([d1, d2])
db_session.flush()
link1 = TranslationJobDictionary(job_id=JOB_ID, dictionary_id=d1.id)
link2 = TranslationJobDictionary(job_id=JOB_ID, dictionary_id=d2.id)
db_session.add_all([link1, link2])
e1 = DictionaryEntry(id="ea", dictionary_id=d1.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es")
e2 = DictionaryEntry(id="eb", dictionary_id=d2.id,
source_term="hello", source_term_normalized="hello",
target_term="bonjour", source_language="en", target_language="es")
db_session.add_all([e1, e2])
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(db_session, ["hello"], JOB_ID)
assert len(result) == 2
assert result[0]["dictionary_id"] == d1.id
assert result[1]["dictionary_id"] == d2.id
def test_duplicate_matches_deduped(self, db_session):
"""Edge: same (text_idx, entry_id) pair not duplicated."""
d = TerminologyDictionary(id="dict-dup", name="Dup", is_active=True)
db_session.add(d)
self._link_dict(db_session, d.id)
entry = DictionaryEntry(
id="e-dup", dictionary_id=d.id,
source_term="hello", source_term_normalized="hello",
target_term="hola", source_language="en", target_language="es",
)
db_session.add(entry)
db_session.commit()
result = DictionaryBatchFilter.filter_for_batch(
db_session, ["hello hello hello"], JOB_ID,
)
assert len(result) == 1
# #endregion Test.DictionaryBatchFilter

View File

@@ -0,0 +1,452 @@
# #region Test.DictionaryImportExport [C:3] [TYPE Module] [SEMANTICS test, dictionary, import, export, csv]
# @BRIEF Verify DictionaryImportExport contracts — import_entries, export_entries, migrate_old_entries.
# @RELATION BINDS_TO -> [DictionaryImportExport]
# @TEST_EDGE: dict_not_found -> Raises ValueError
# @TEST_EDGE: invalid_delimiter -> Raises ValueError
# @TEST_EDGE: missing_required_columns -> Raises ValueError
# @TEST_EDGE: empty_source_or_target -> Skipped with error
# @TEST_EDGE: on_conflict_overwrite -> Updates existing
# @TEST_EDGE: on_conflict_keep_existing -> Skips existing
# @TEST_EDGE: on_conflict_cancel -> Errors on conflict
# @TEST_EDGE: preview_only -> Returns preview without writing
import io
import csv
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from src.models.translate import (
Base, DictionaryEntry, TerminologyDictionary,
)
from src.plugins.translate.dictionary_import_export import DictionaryImportExport
def make_session():
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine)()
return session, engine
def _make_dict(session, name="Test Dict"):
d = TerminologyDictionary(id="dict-ie", name=name, is_active=True)
session.add(d)
session.commit()
return d
def _csv_content(rows, delimiter=","):
"""Helper: build CSV string from list of dict rows."""
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=["source_term", "target_term", "context_notes",
"source_language", "target_language"],
delimiter=delimiter)
writer.writeheader()
for row in rows:
writer.writerow(row)
return output.getvalue()
class TestImportEntries:
"""Verify import_entries method."""
def test_dict_not_found(self):
"""Negative: non-existent dict_id raises ValueError."""
session, engine = make_session()
try:
with pytest.raises(ValueError, match="Dictionary not found"):
DictionaryImportExport.import_entries(session, "nonexistent", "source,target\nhello,hola")
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_invalid_delimiter(self):
"""Negative: unsupported delimiter."""
session, engine = make_session()
try:
d = _make_dict(session)
with pytest.raises(ValueError, match="Unsupported delimiter"):
DictionaryImportExport.import_entries(session, d.id, "a|b", delimiter="|")
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_missing_required_columns(self):
"""Negative: CSV missing source_term or target_term."""
session, engine = make_session()
try:
d = _make_dict(session)
content = "foo,bar\n1,2"
with pytest.raises(ValueError, match="must have"):
DictionaryImportExport.import_entries(session, d.id, content, delimiter=",")
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_empty_content_triggers_missing_columns(self):
"""Edge: empty content has no columns."""
session, engine = make_session()
try:
d = _make_dict(session)
with pytest.raises(ValueError, match="must have"):
DictionaryImportExport.import_entries(session, d.id, "")
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_import_success_csv(self):
"""Happy: basic CSV import creates entries."""
session, engine = make_session()
try:
d = _make_dict(session)
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "es",
"context_notes": "greeting"},
{"source_term": "world", "target_term": "mundo",
"source_language": "en", "target_language": "es",
"context_notes": ""},
])
result = DictionaryImportExport.import_entries(session, d.id, content)
assert result["total"] == 2
assert result["created"] == 2
assert result["updated"] == 0
assert result["skipped"] == 0
entries = session.query(DictionaryEntry).filter(
DictionaryEntry.dictionary_id == d.id
).all()
assert len(entries) == 2
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_import_success_tsv(self):
"""Happy: TSV import with auto-detected delimiter."""
session, engine = make_session()
try:
d = _make_dict(session)
content = "source_term\ttarget_term\nhello\thola\n"
result = DictionaryImportExport.import_entries(session, d.id, content)
assert result["created"] == 1
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_empty_source_or_target_skipped(self):
"""Edge: row missing source or target is recorded as error."""
session, engine = make_session()
try:
d = _make_dict(session)
content = _csv_content([
{"source_term": "hello", "target_term": ""},
{"source_term": "", "target_term": "orphan"},
])
result = DictionaryImportExport.import_entries(session, d.id, content)
assert result["total"] == 2
assert result["created"] == 0
assert len(result["errors"]) == 2
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_on_conflict_overwrite(self):
"""Edge: existing entry is updated with on_conflict=overwrite."""
session, engine = make_session()
try:
d = _make_dict(session)
existing = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="bonjour",
source_language="en", target_language="fr",
)
session.add(existing)
session.commit()
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "fr"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content, on_conflict="overwrite",
)
assert result["updated"] == 1
assert result["created"] == 0
session.refresh(existing)
assert existing.target_term == "hola"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_on_conflict_keep_existing(self):
"""Edge: existing entry kept with on_conflict=keep_existing."""
session, engine = make_session()
try:
d = _make_dict(session)
existing = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="bonjour",
source_language="en", target_language="fr",
)
session.add(existing)
session.commit()
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "fr"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content, on_conflict="keep_existing",
)
assert result["skipped"] == 1
assert result["updated"] == 0
session.refresh(existing)
assert existing.target_term == "bonjour" # unchanged
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_on_conflict_cancel(self):
"""Edge: on_conflict=cancel records error."""
session, engine = make_session()
try:
d = _make_dict(session)
existing = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="bonjour",
source_language="en", target_language="fr",
)
session.add(existing)
session.commit()
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "fr"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content, on_conflict="cancel",
)
assert len(result["errors"]) == 1
assert "Conflict" in result["errors"][0]["error"]
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_preview_only(self):
"""Edge: preview_only returns preview without writing."""
session, engine = make_session()
try:
d = _make_dict(session)
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "es"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content, preview_only=True,
)
assert len(result["preview"]) == 1
assert result["preview"][0]["source_term"] == "hello"
assert result["preview"][0]["is_conflict"] is False
# No entries should have been created
count = session.query(DictionaryEntry).count()
assert count == 0
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_preview_with_existing(self):
"""Edge: preview shows existing as conflict."""
session, engine = make_session()
try:
d = _make_dict(session)
existing = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="bonjour",
source_language="en", target_language="es",
)
session.add(existing)
session.commit()
content = _csv_content([
{"source_term": "hello", "target_term": "hola",
"source_language": "en", "target_language": "es"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content, preview_only=True,
)
assert result["preview"][0]["is_conflict"] is True
assert result["preview"][0]["existing_target_term"] == "bonjour"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_default_languages_from_params(self):
"""Edge: default source/target languages applied."""
session, engine = make_session()
try:
d = _make_dict(session)
content = _csv_content([
{"source_term": "hello", "target_term": "hola"},
])
result = DictionaryImportExport.import_entries(
session, d.id, content,
default_source_language="en", default_target_language="es",
)
assert result["created"] == 1
entry = session.query(DictionaryEntry).first()
assert entry.source_language == "en"
assert entry.target_language == "es"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
class TestExportEntries:
"""Verify export_entries method."""
def test_export_dict_not_found(self):
"""Negative: non-existent dict_id raises ValueError."""
session, engine = make_session()
try:
with pytest.raises(ValueError, match="Dictionary not found"):
DictionaryImportExport.export_entries(session, "nonexistent")
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_export_empty(self):
"""Edge: export with no entries returns header-only CSV."""
session, engine = make_session()
try:
d = _make_dict(session)
result = DictionaryImportExport.export_entries(session, d.id)
assert "source_term" in result
lines = result.strip().split("\n")
assert len(lines) == 1 # header only
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_export_with_entries(self):
"""Happy: export returns CSV with entries."""
session, engine = make_session()
try:
d = _make_dict(session)
e1 = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="hola",
source_language="en", target_language="es",
)
e2 = DictionaryEntry(
dictionary_id=d.id, source_term="world",
source_term_normalized="world", target_term="mundo",
source_language="en", target_language="es",
)
session.add_all([e1, e2])
session.commit()
result = DictionaryImportExport.export_entries(session, d.id)
reader = csv.DictReader(io.StringIO(result))
rows = list(reader)
assert len(rows) == 2
assert rows[0]["source_term"] == "hello"
assert rows[0]["target_term"] == "hola"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_export_tsv_delimiter(self):
"""Happy: export with tab delimiter."""
session, engine = make_session()
try:
d = _make_dict(session)
e = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="hola",
source_language="en", target_language="es",
)
session.add(e)
session.commit()
result = DictionaryImportExport.export_entries(session, d.id, delimiter="\t")
assert "\t" in result
lines = result.strip().split("\n")
assert len(lines) == 2
assert "\t" in lines[1]
finally:
session.close()
Base.metadata.drop_all(bind=engine)
class TestMigrateOldEntries:
"""Verify migrate_old_entries method."""
def test_no_old_entries(self):
"""Edge: no entries with 'und' language returns zeros."""
session, engine = make_session()
try:
d = _make_dict(session)
e = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="hola",
source_language="en", target_language="es",
)
session.add(e)
session.commit()
result = DictionaryImportExport.migrate_old_entries(session)
assert result["migrated_source"] == 0
assert result["migrated_target"] == 0
assert result["total_processed"] == 0
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_migrate_source_only(self):
"""Happy: migrates source_language from origin."""
session, engine = make_session()
try:
d = _make_dict(session)
e = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="hola",
source_language="und", target_language="und",
origin_source_language="en",
)
session.add(e)
session.commit()
result = DictionaryImportExport.migrate_old_entries(session)
assert result["migrated_source"] == 1
assert result["total_processed"] >= 1
session.refresh(e)
assert e.source_language == "en"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_skip_when_both_und_no_origin(self):
"""Edge: entry with both und but no origin is skipped."""
session, engine = make_session()
try:
d = _make_dict(session)
e = DictionaryEntry(
dictionary_id=d.id, source_term="hello",
source_term_normalized="hello", target_term="hola",
source_language="und", target_language="und",
)
session.add(e)
session.commit()
result = DictionaryImportExport.migrate_old_entries(session)
assert result["skipped"] >= 1
finally:
session.close()
Base.metadata.drop_all(bind=engine)
# #endregion Test.DictionaryImportExport

View File

@@ -0,0 +1,288 @@
# #region Test.TranslationEventLog [C:3] [TYPE Module] [SEMANTICS test, event, log, audit]
# @BRIEF Verify TranslationEventLog contracts — log_event, query_events, prune_expired, get_run_event_summary.
# @RELATION BINDS_TO -> [TranslationEventLog]
# @TEST_EDGE: invalid_event_type -> Raises ValueError
# @TEST_EDGE: terminal_event_twice -> Raises ValueError
# @TEST_EDGE: missing_run_started -> Raises ValueError for run events
# @TEST_EDGE: prune_expired_noop -> Returns zero when no expired events
# @TEST_EDGE: prune_expired_creates_snapshot -> MetricSnapshot created before pruning
import pytest
from unittest.mock import MagicMock, patch
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from src.models.translate import (
TranslationEvent, MetricSnapshot, TranslationRun,
)
from src.plugins.translate.events import (
TranslationEventLog, VALID_EVENT_TYPES, TERMINAL_EVENT_TYPES,
)
from .conftest import JOB_ID, RUN_ID
class TestLogEvent:
"""Verify log_event method."""
def test_log_invalid_event_type(self, db_session):
"""Negative: unknown event type raises ValueError."""
el = TranslationEventLog(db_session)
with pytest.raises(ValueError, match="Invalid event_type"):
el.log_event(job_id=JOB_ID, event_type="UNKNOWN_TYPE")
def test_log_valid_event_no_run(self, db_session):
"""Happy: job-level event without run_id."""
el = TranslationEventLog(db_session)
event = el.log_event(job_id=JOB_ID, event_type="JOB_CREATED",
created_by="test_user")
assert event.job_id == JOB_ID
assert event.event_type == "JOB_CREATED"
assert event.created_by == "test_user"
def test_log_run_started(self, db_with_run):
"""Happy: RUN_STARTED event for a run."""
session, run_id = db_with_run
el = TranslationEventLog(session)
event = el.log_event(job_id=JOB_ID, run_id=run_id,
event_type="RUN_STARTED")
assert event.run_id == run_id
def test_log_run_event_without_started(self, db_with_run):
"""Negative: other run events require RUN_STARTED first."""
session, run_id = db_with_run
el = TranslationEventLog(session)
with pytest.raises(ValueError, match="RUN_STARTED event must precede"):
el.log_event(job_id=JOB_ID, run_id=run_id,
event_type="BATCH_STARTED")
def test_log_terminal_event_twice(self, db_with_run):
"""Negative: cannot log two terminal events for same run."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
with pytest.raises(ValueError, match="already has a terminal event"):
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_FAILED")
def test_log_terminal_event_first_time_ok(self, db_with_run):
"""Happy: first terminal event succeeds."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
event = el.log_event(job_id=JOB_ID, run_id=run_id,
event_type="RUN_COMPLETED")
assert event.event_type == "RUN_COMPLETED"
def test_log_with_payload(self, db_with_run):
"""Happy: event with structured payload."""
session, run_id = db_with_run
el = TranslationEventLog(session)
payload = {"rows": 10, "language": "es"}
event = el.log_event(job_id=JOB_ID, run_id=run_id,
event_type="RUN_STARTED", payload=payload)
assert event.event_data == payload
def test_log_event_flushes(self, db_session):
"""Verify flush is called after adding event."""
el = TranslationEventLog(db_session)
event = el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
fetched = db_session.query(TranslationEvent).filter(
TranslationEvent.id == event.id
).first()
assert fetched is not None
def test_run_started_before_terminal(self, db_with_run):
"""Edge: RUN_STARTED precedes terminal event."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
ev = el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
assert ev is not None
class TestQueryEvents:
"""Verify query_events method."""
def test_query_all_events(self, db_session):
"""Happy: query returns all events."""
el = TranslationEventLog(db_session)
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
el.log_event(job_id=JOB_ID, event_type="JOB_UPDATED")
results = el.query_events()
assert len(results) >= 1
def test_query_filter_by_job(self, db_session):
"""Happy: filter by job_id."""
el = TranslationEventLog(db_session)
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
results = el.query_events(job_id=JOB_ID)
assert len(results) >= 1
assert results[0]["job_id"] == JOB_ID
def test_query_filter_by_run(self, db_with_run):
"""Happy: filter by run_id."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
results = el.query_events(run_id=run_id)
assert len(results) == 1
def test_query_filter_by_type(self, db_with_run):
"""Happy: filter by event_type."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
results = el.query_events(event_type="JOB_CREATED")
assert len(results) == 1
def test_query_limit_offset(self, db_session):
"""Edge: pagination with limit and offset."""
el = TranslationEventLog(db_session)
for i in range(5):
el.log_event(job_id=JOB_ID, event_type="JOB_UPDATED",
payload={"seq": i})
results = el.query_events(limit=2, offset=0)
assert len(results) == 2
results_page2 = el.query_events(limit=2, offset=2)
assert len(results_page2) == 2
class TestPruneExpired:
"""Verify prune_expired method."""
def test_no_expired_events(self, db_session):
"""Edge: no events older than cutoff."""
el = TranslationEventLog(db_session)
el.log_event(job_id=JOB_ID, event_type="JOB_CREATED")
result = el.prune_expired(retention_days=365)
assert result["pruned"] == 0
assert result["snapshot_id"] is None
def test_prune_expired_events(self, db_session):
"""Happy: prune old events and create snapshot."""
el = TranslationEventLog(db_session)
old_event = TranslationEvent(
id="old-event", job_id=JOB_ID, event_type="JOB_CREATED",
event_data={}, created_at=datetime.now(UTC) - timedelta(days=400),
)
db_session.add(old_event)
db_session.commit()
result = el.prune_expired(retention_days=90)
assert result["pruned"] >= 1
assert result["snapshot_id"] is not None
remaining = db_session.query(TranslationEvent).all()
assert len(remaining) == 0
def test_prune_with_metric_snapshot_created(self, db_session):
"""Invariant: MetricSnapshot created before pruning."""
el = TranslationEventLog(db_session)
old_event = TranslationEvent(
id="old-with-metrics", job_id=JOB_ID, event_type="BATCH_COMPLETED",
event_data={"token_count": 500, "cost": 0.01,
"language_code": "es", "run_id": "run-1"},
created_at=datetime.now(UTC) - timedelta(days=400),
)
db_session.add(old_event)
db_session.commit()
result = el.prune_expired(retention_days=90)
assert result["snapshot_id"] is not None
snapshot = db_session.query(MetricSnapshot).first()
assert snapshot is not None
assert snapshot.total_records == 1
def test_prune_language_metrics_aggregation(self, db_session):
"""Edge: per-language metrics aggregated correctly."""
el = TranslationEventLog(db_session)
for lang in ["es", "es", "fr"]:
e = TranslationEvent(
id=f"old-{lang}", job_id=JOB_ID, event_type="BATCH_COMPLETED",
event_data={"token_count": 100, "cost": 0.01,
"language_code": lang},
created_at=datetime.now(UTC) - timedelta(days=400),
)
db_session.add(e)
db_session.commit()
result = el.prune_expired(retention_days=90)
assert result["pruned"] == 3
snapshot = db_session.query(MetricSnapshot).first()
assert snapshot is not None
metrics = snapshot.per_language_metrics or {}
assert "es" in metrics
assert metrics["es"]["cumulative_tokens"] == 200
assert "fr" in metrics
def test_prune_event_without_language(self, db_session):
"""Edge: event without language_code grouped as _unknown_."""
el = TranslationEventLog(db_session)
e = TranslationEvent(
id="no-lang", job_id=JOB_ID, event_type="BATCH_COMPLETED",
event_data={"token_count": 50},
created_at=datetime.now(UTC) - timedelta(days=400),
)
db_session.add(e)
db_session.commit()
result = el.prune_expired(retention_days=90)
assert result["pruned"] == 1
class TestGetRunEventSummary:
"""Verify get_run_event_summary method."""
def test_valid_run_summary(self, db_with_run):
"""Happy: run with started and completed events."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
summary = el.get_run_event_summary(run_id)
assert summary["run_id"] == run_id
assert summary["has_run_started"] is True
assert summary["terminal_event_count"] == 1
assert summary["invariant_valid"] is True
def test_empty_run_summary(self, db_session):
"""Edge: run with no events."""
el = TranslationEventLog(db_session)
summary = el.get_run_event_summary("run-empty")
assert summary["event_count"] == 0
assert summary["has_run_started"] is False
assert summary["invariant_valid"] is False
def test_run_missing_start(self, db_session):
"""Edge: run with terminal but no start."""
el = TranslationEventLog(db_session)
e = TranslationEvent(
id="orphan-completed", job_id=JOB_ID, run_id="run-no-start-2",
event_type="RUN_COMPLETED", event_data={},
created_at=datetime.now(UTC),
)
db_session.add(e)
db_session.commit()
summary = el.get_run_event_summary("run-no-start-2")
assert summary["has_run_started"] is False
assert summary["invariant_valid"] is False
def test_run_two_terminals_violation(self, db_with_run):
"""Edge: run with two terminal events violates invariant."""
session, run_id = db_with_run
el = TranslationEventLog(session)
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_STARTED")
el.log_event(job_id=JOB_ID, run_id=run_id, event_type="RUN_COMPLETED")
# Force-add a second terminal
e2 = TranslationEvent(
id="second-terminal", job_id=JOB_ID, run_id=run_id,
event_type="RUN_FAILED", event_data={},
created_at=datetime.now(UTC),
)
session.add(e2)
session.commit()
session.expire_all()
summary = el.get_run_event_summary(run_id)
assert summary["terminal_event_count"] > 1
assert summary["invariant_valid"] is False
# #endregion Test.TranslationEventLog

View File

@@ -0,0 +1,572 @@
# #region Test.LLMTranslationService [C:3] [TYPE Module] [SEMANTICS test, llm, translation, retry, parse]
# @BRIEF Verify LLMTranslationService contracts — call_llm_for_batch, retry, split, recovery, failure handling.
# @RELATION BINDS_TO -> [LLMTranslationService]
# @TEST_EDGE: batch_empty -> Handled gracefully
# @TEST_EDGE: llm_all_retries_exhausted -> Returns failure counts
# @TEST_EDGE: parse_failure -> Returns skipped counts
# @TEST_EDGE: truncation_recovery -> Recovers partial rows
# @TEST_EDGE: no_provider -> Raises ValueError
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime
from src.models.translate import (
Base, TranslationBatch, TranslationJob, TranslationLanguage,
TranslationRecord,
)
from src.plugins.translate._llm_call import LLMTranslationService, MAX_RETRIES_PER_BATCH
from .conftest import JOB_ID, RUN_ID
def _make_job(session, provider_id="test-provider", target_langs=None):
job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
if job is None:
job = TranslationJob(
id=JOB_ID, name="LLM Test", source_dialect="en",
target_dialect=target_langs[0] if target_langs else "fr",
target_languages=target_langs or ["fr"],
status="ACTIVE", provider_id=provider_id,
)
session.add(job)
else:
if provider_id:
job.provider_id = provider_id
if target_langs:
job.target_languages = target_langs
job.target_dialect = target_langs[0]
session.commit()
return job
def _batch_rows(count=2, detected_lang="en"):
return [
{
"row_index": str(i),
"source_text": f"Hello world {i}",
"source_data": {"table": "test"},
"_detected_lang": detected_lang,
}
for i in range(count)
]
class TestInit:
"""Verify basic initialization."""
def test_init(self, db_session):
svc = LLMTranslationService(db_session)
assert svc.db is db_session
class TestBuildDictionarySection:
"""Verify _build_dictionary_section."""
def test_no_dict_matches(self, db_session):
svc = LLMTranslationService(db_session)
result = svc._build_dictionary_section([], _batch_rows(1))
assert result == ""
def test_with_dict_matches(self, db_session):
svc = LLMTranslationService(db_session)
matches = [{
"entry_id": "e1", "source_term": "hello", "source_term_normalized": "hello",
"target_term": "hola", "dictionary_id": "d1", "dictionary_name": "Test",
"context_notes": "", "context_data": None, "has_context": False,
"is_regex": False, "context_source": None, "usage_notes": None,
"source_language": "en", "target_language": "es", "priority_match": False,
"text_index": 0, "source_text": "hello",
}]
result = svc._build_dictionary_section(matches, _batch_rows(1))
assert "Terminology dictionary" in result
assert "hello" in result
class TestResolveTargetLanguages:
"""Verify _resolve_target_languages."""
def test_list_target_languages(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, target_langs=["fr", "de"])
result = LLMTranslationService._resolve_target_languages(job)
assert result == ["fr", "de"]
def test_single_target_language(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, target_langs=["fr"])
result = LLMTranslationService._resolve_target_languages(job)
assert result == ["fr"]
class TestBuildPrompt:
"""Verify _build_prompt."""
def test_prompt_contains_rows_and_languages(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, target_langs=["fr"])
rows = _batch_rows(2)
prompt = LLMTranslationService._build_prompt(job, rows, "dict_section\n", ["fr"])
assert "fr" in prompt
assert "Hello world 0" in prompt
assert "Hello world 1" in prompt
assert "dict_section" in prompt
class TestCallLlm:
"""Verify call_llm method (provider routing)."""
@pytest.mark.asyncio
async def test_no_provider_id(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, provider_id="")
with pytest.raises(ValueError, match="no LLM provider configured"):
await svc.call_llm(job, "prompt")
@pytest.mark.asyncio
async def test_provider_not_found(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, provider_id="nonexistent")
with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
mock_instance = MockSvc.return_value
mock_instance.get_provider.return_value = None
with pytest.raises(ValueError, match="not found"):
await svc.call_llm(job, "prompt")
@pytest.mark.asyncio
async def test_api_key_not_found(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, provider_id="test-provider")
with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
mock_instance = MockSvc.return_value
mock_instance.get_provider.return_value = MagicMock(
provider_type="openai", base_url="https://api.openai.com",
default_model="gpt-4o-mini",
)
mock_instance.get_decrypted_api_key.return_value = None
with pytest.raises(ValueError, match="Could not decrypt"):
await svc.call_llm(job, "prompt")
@pytest.mark.asyncio
async def test_unsupported_provider_type(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, provider_id="test-provider")
with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
mock_instance = MockSvc.return_value
mock_provider = MagicMock(
provider_type="unknown_type", base_url="https://example.com",
default_model="test-model",
)
mock_instance.get_provider.return_value = mock_provider
mock_instance.get_decrypted_api_key.return_value = "sk-test"
with pytest.raises(ValueError, match="Unsupported provider type"):
await svc.call_llm(job, "prompt")
@pytest.mark.asyncio
async def test_successful_llm_call(self, db_session):
svc = LLMTranslationService(db_session)
job = _make_job(db_session, provider_id="test-provider")
with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
mock_instance = MockSvc.return_value
mock_instance.get_provider.return_value = MagicMock(
provider_type="openai", base_url="https://api.openai.com",
default_model="gpt-4o-mini",
)
mock_instance.get_decrypted_api_key.return_value = "sk-test"
with patch('src.plugins.translate._llm_call.call_openai_compatible',
new=AsyncMock(return_value=('{"response": "ok"}', "stop"))):
response, finish = await svc.call_llm(job, "test prompt")
assert response == '{"response": "ok"}'
assert finish == "stop"
class TestCallLlmForBatch:
"""Verify call_llm_for_batch orchestration."""
@pytest.mark.asyncio
async def test_llm_failure_all_retries(self, db_with_run):
"""Negative: all retries fail."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=(None, None, 3, "API error"))):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 0
assert result["failed"] == 2
assert result["retries"] == 3
@pytest.mark.asyncio
async def test_parse_failure(self, db_with_run):
"""Negative: LLM response cannot be parsed."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(1)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=("bad response", "stop", 1, None))):
with patch('src.plugins.translate._llm_call.parse_llm_response',
side_effect=ValueError("Invalid JSON")):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 0
assert result["skipped"] == 1
@pytest.mark.asyncio
async def test_successful_translation(self, db_with_run):
"""Happy: successful LLM call with parsed translations."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(1)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=('{"ok": true}', "stop", 1, None))):
with patch('src.plugins.translate._llm_call.parse_llm_response',
return_value={"0": {"fr": "Bonjour le monde 0", "translation": "Bonjour le monde 0"}}):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 1
@pytest.mark.asyncio
async def test_truncation_split_and_retry(self, db_with_run):
"""Edge: truncation triggers binary split."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(4)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=('partial', "length", 1, None))):
with patch.object(svc, '_try_recover_partial', return_value=None):
with patch.object(svc, '_split_and_retry',
new=AsyncMock(return_value={"successful": 2, "failed": 0,
"skipped": 0, "retries": 1})):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 2
@pytest.mark.asyncio
async def test_truncation_recovery_all_rows(self, db_with_run):
"""Edge: all rows recovered from truncated response."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=('partial', "length", 1, None))):
with patch.object(svc, '_try_recover_partial',
return_value={"0", "1"}):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 2
@pytest.mark.asyncio
async def test_truncation_partial_recovery_then_retry(self, db_with_run):
"""Edge: partial recovery, then retry remaining."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(3)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=('partial', "length", 1, None))):
with patch.object(svc, '_try_recover_partial',
return_value={"0"}):
with patch.object(svc, '_retry_missing_rows',
new=AsyncMock(return_value={"successful": 2, "failed": 0,
"skipped": 0, "retries": 1})):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
)
assert result["successful"] == 2
@pytest.mark.asyncio
async def test_truncation_depth_exceeded(self, db_with_run):
"""Edge: recursion depth exceeded."""
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(2)
with patch.object(svc, '_call_llm_with_retry',
new=AsyncMock(return_value=('partial', "length", 1, None))):
with patch.object(svc, '_try_recover_partial', return_value=None):
result = await svc.call_llm_for_batch(
job=job, run_id=run_id, batch_rows=rows,
dict_matches=[], batch_id="batch-1",
_recursion_depth=MAX_RETRIES_PER_BATCH,
)
assert result["successful"] == 0
class TestCallLlmWithRetry:
"""Verify _call_llm_with_retry loop."""
@pytest.mark.asyncio
async def test_first_attempt_succeeds(self, db_session):
svc = LLMTranslationService(db_session)
with patch.object(svc, 'call_llm',
new=AsyncMock(return_value=("response", "stop"))):
response, finish, retries, last_error = await svc._call_llm_with_retry(
MagicMock(), "prompt", "batch-1", 8192,
)
assert response == "response"
assert finish == "stop"
assert retries == 0
@pytest.mark.asyncio
async def test_retry_then_succeeds(self, db_session):
svc = LLMTranslationService(db_session)
call_mock = AsyncMock(side_effect=[
Exception("First fail"),
("response", "stop"),
])
with patch.object(svc, 'call_llm', new=call_mock):
response, finish, retries, last_error = await svc._call_llm_with_retry(
MagicMock(), "prompt", "batch-1", 8192,
)
assert response == "response"
assert retries == 1
@pytest.mark.asyncio
async def test_all_retries_exhausted(self, db_session):
svc = LLMTranslationService(db_session)
call_mock = AsyncMock(side_effect=Exception("Always fail"))
with patch.object(svc, 'call_llm', new=call_mock):
response, finish, retries, last_error = await svc._call_llm_with_retry(
MagicMock(), "prompt", "batch-1", 8192,
)
assert response is None
assert retries == MAX_RETRIES_PER_BATCH
class TestHandleLlmFailure:
"""Verify _handle_llm_failure."""
def test_all_rows_marked_failed(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(3)
result = svc._handle_llm_failure(rows, run_id, "batch-1", 3, "Timeout")
assert result["failed"] == 3
assert result["successful"] == 0
records = session.query(TranslationRecord).all()
assert len(records) == 3
assert all(r.status == "FAILED" for r in records)
assert all("Timeout" in r.error_message for r in records)
class TestHandleParseFailure:
"""Verify _handle_parse_failure."""
def test_all_rows_marked_skipped(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(2)
result = svc._handle_parse_failure(rows, run_id, "batch-1", 2, ValueError("Bad parse"))
assert result["skipped"] == 2
records = session.query(TranslationRecord).all()
assert len(records) == 2
assert all(r.status == "SKIPPED" for r in records)
class TestCreateRecordsFromTranslations:
"""Verify _create_records_from_translations."""
def test_successful_creates_records(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(1)
translations = {"0": {"fr": "Bonjour", "translation": "Bonjour"}}
result = svc._create_records_from_translations(
rows, run_id, "batch-1", ["fr"], translations, [], 0,
)
assert result["successful"] == 1
records = session.query(TranslationRecord).all()
assert len(records) == 1
assert records[0].status == "SUCCESS"
langs = session.query(TranslationLanguage).all()
assert len(langs) == 1
def test_null_translation_skipped(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(1)
translations = {}
result = svc._create_records_from_translations(
rows, run_id, "batch-1", ["fr"], translations, [], 0,
)
assert result["skipped"] == 1
def test_empty_plv_skipped(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(1)
translations = {"0": {"de": "Hallo"}}
result = svc._create_records_from_translations(
rows, run_id, "batch-1", ["fr"], translations, [], 0,
)
assert result["skipped"] == 1
def test_with_dict_enforcement(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(1)
translations = {"0": {"fr": "Bonjour", "translation": "Bonjour"}}
dict_matches = [{"entry_id": "e1", "source_term": "hello", "target_term": "hola"}]
result = svc._create_records_from_translations(
rows, run_id, "batch-1", ["fr"], translations, dict_matches, 0,
)
assert result["successful"] == 1
class TestExtractPerLangValues:
"""Verify _extract_per_lang_values."""
def test_extract_first_target(self):
td = {"fr": "Bonjour", "translation": "Hello"}
result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
assert result == {"fr": "Bonjour"}
def test_fallback_to_translation(self):
td = {"translation": "Hello"}
result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
assert result == {"fr": "Hello"}
def test_empty_values(self):
td = {"fr": "", "translation": ""}
result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
assert result == {}
def test_multi_language(self):
td = {"fr": "Bonjour", "de": "Hallo"}
result = LLMTranslationService._extract_per_lang_values(td, ["fr", "de"])
assert result == {"fr": "Bonjour", "de": "Hallo"}
class TestAddSkipped:
"""Verify _add_skipped."""
def test_add_skipped_record(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
row = _batch_rows(1)[0]
svc._add_skipped(row, run_id, "batch-1", "source text", "No translation found")
records = session.query(TranslationRecord).all()
assert len(records) == 1
assert records[0].status == "SKIPPED"
assert records[0].error_message == "No translation found"
class TestSplitAndRetry:
"""Verify _split_and_retry."""
@pytest.mark.asyncio
async def test_split_creates_child_batches(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(4)
with patch.object(svc, 'call_llm_for_batch',
new=AsyncMock(return_value={"successful": 2, "failed": 0,
"skipped": 0, "retries": 0})):
result = await svc._split_and_retry(
job, run_id, rows, [], "orig-batch", 8192, 1, 0,
)
assert result["successful"] == 4
batches = session.query(TranslationBatch).all()
assert len(batches) == 2
class TestTryRecoverPartial:
"""Verify _try_recover_partial."""
def test_successful_recovery(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(2)
translations_response = json.dumps([
{"row_id": "0", "fr": "Bonjour"},
{"row_id": "1", "fr": "Monde"},
])
with patch('src.plugins.translate._llm_call.parse_llm_response',
return_value={"0": {"fr": "Bonjour"}, "1": {"fr": "Monde"}}):
recovered = svc._try_recover_partial(
translations_response, rows, run_id, "batch-1", ["fr"],
)
assert recovered == {"0", "1"}
records = session.query(TranslationRecord).all()
assert len(records) == 2
def test_no_rows_recovered(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(2)
with patch('src.plugins.translate._llm_call.parse_llm_response',
return_value={}):
recovered = svc._try_recover_partial(
"", rows, run_id, "batch-1", ["fr"],
)
assert recovered is None
def test_parse_error_returns_none(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
rows = _batch_rows(2)
with patch('src.plugins.translate._llm_call.parse_llm_response',
side_effect=ValueError("Parse error")):
recovered = svc._try_recover_partial(
"invalid json", rows, run_id, "batch-1", ["fr"],
)
assert recovered is None
class TestRetryMissingRows:
"""Verify _retry_missing_rows."""
@pytest.mark.asyncio
async def test_empty_missing(self, db_session):
svc = LLMTranslationService(db_session)
result = await svc._retry_missing_rows(
MagicMock(), "run-1", [], [], "batch-1", 8192, 1,
)
assert result["successful"] == 0
@pytest.mark.asyncio
async def test_retry_creates_sub_batch(self, db_with_run):
session, run_id = db_with_run
svc = LLMTranslationService(session)
job = _make_job(session, target_langs=["fr"])
rows = _batch_rows(1)
with patch.object(svc, 'call_llm_for_batch',
new=AsyncMock(return_value={"successful": 0, "failed": 1,
"skipped": 0, "retries": 1})):
result = await svc._retry_missing_rows(
job, run_id, rows, [], "batch-1", 8192, 1,
)
assert result["failed"] == 1
batches = session.query(TranslationBatch).all()
assert len(batches) == 1
# #endregion Test.LLMTranslationService

View File

@@ -0,0 +1,227 @@
# #region Test.OrchestratorCancel [C:3] [TYPE Module] [SEMANTICS test, translate, cancel, retry]
# @BRIEF Verify orchestrator_cancel contracts — cancel_run and retry_insert.
# @RELATION BINDS_TO -> [orchestrator_cancel]
# @TEST_EDGE: run_not_found -> cancel_run raises ValueError
# @TEST_EDGE: invalid_status -> cancel_run raises ValueError for COMPLETED/FAILED runs
# @TEST_EDGE: lock_timeout -> cancel_run falls back to _fallback_cancel_request
# @TEST_EDGE: retry_insert_run_not_found -> retry_insert raises ValueError
# @TEST_EDGE: retry_insert_job_not_found -> retry_insert raises ValueError
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime
import uuid
from sqlalchemy.orm import Session
from src.models.translate import (
TranslationJob, TranslationRun, TranslationEvent,
)
from src.plugins.translate.events import TranslationEventLog
from .conftest import JOB_ID, RUN_ID
class _MockExecute:
"""Helper: patch session.execute so SET LOCAL succeeds for SQLite compat."""
@staticmethod
def _make_passthrough(session):
"""Return a side_effect that passes thru SET LOCAL, delegates real SQL."""
real_execute = session.execute
def passthrough(statement, *args, **kwargs):
sql_str = str(statement)
if 'SET LOCAL' in sql_str or 'lock_timeout' in sql_str:
return # silently pass PostgreSQL-specific SET LOCAL
return real_execute(statement, *args, **kwargs)
return passthrough
class TestCancelRun:
"""Verify cancel_run function."""
def _patch_set_local(self, session):
"""Patch session.execute so SET LOCAL succeeds."""
return patch.object(session, 'execute',
side_effect=_MockExecute._make_passthrough(session))
def test_cancel_run_success(self, db_with_run):
"""Happy path: cancel a RUNNING run."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "RUNNING"
session.commit()
event_log = TranslationEventLog(session)
from src.plugins.translate.orchestrator_cancel import cancel_run
with self._patch_set_local(session):
result = cancel_run(session, event_log, "test_user", run_id)
assert result.status == "CANCELLED"
assert result.completed_at is not None
events = session.query(TranslationEvent).filter(
TranslationEvent.run_id == run_id
).all()
assert any(e.event_type == "RUN_CANCELLED" for e in events)
def test_cancel_run_not_found(self, db_session):
"""Negative: run_id does not exist."""
event_log = TranslationEventLog(db_session)
from src.plugins.translate.orchestrator_cancel import cancel_run
with pytest.raises(ValueError, match="not found"):
with self._patch_set_local(db_session):
cancel_run(db_session, event_log, "test_user", "nonexistent-id")
def test_cancel_run_invalid_status(self, db_with_run):
"""Negative: cannot cancel COMPLETED run."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "COMPLETED"
session.commit()
event_log = TranslationEventLog(session)
from src.plugins.translate.orchestrator_cancel import cancel_run
with pytest.raises(ValueError, match="Cannot cancel"):
with self._patch_set_local(session):
cancel_run(session, event_log, "test_user", run_id)
def test_cancel_run_fail_status(self, db_with_run):
"""Negative: cannot cancel FAILED run."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "FAILED"
session.commit()
event_log = TranslationEventLog(session)
from src.plugins.translate.orchestrator_cancel import cancel_run
with pytest.raises(ValueError, match="Cannot cancel"):
with self._patch_set_local(session):
cancel_run(session, event_log, "test_user", run_id)
def test_cancel_run_lock_timeout_fallback(self, db_with_run):
"""Edge: lock timeout on execute -> _fallback_cancel_request."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "RUNNING"
session.commit()
event_log = TranslationEventLog(session)
from src.plugins.translate.orchestrator_cancel import cancel_run
_exec_count = [0]
real_exec = session.execute
def _fail_first_then_real(statement, *args, **kwargs):
_exec_count[0] += 1
if _exec_count[0] == 1:
raise Exception("lock timeout")
return real_exec(statement, *args, **kwargs)
with patch.object(session, 'execute', side_effect=_fail_first_then_real):
result = cancel_run(session, event_log, "test_user", run_id)
assert result is not None
def test_cancel_run_flush_fallback(self, db_with_run):
"""Edge: flush failure triggers _fallback_cancel_request."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "RUNNING"
session.commit()
event_log = TranslationEventLog(session)
from src.plugins.translate.orchestrator_cancel import cancel_run
_flush_count = [0]
def _fail_once():
_flush_count[0] += 1
if _flush_count[0] == 1:
raise Exception("flush failed")
with self._patch_set_local(session), patch.object(session, 'flush', side_effect=_fail_once):
result = cancel_run(session, event_log, "test_user", run_id)
assert result is not None
class TestRetryInsert:
"""Verify retry_insert function."""
@pytest.mark.asyncio
async def test_retry_insert_run_not_found(self, db_session):
"""Negative: run_id does not exist."""
event_log = TranslationEventLog(db_session)
config_manager = MagicMock()
from src.plugins.translate.orchestrator_cancel import retry_insert
with pytest.raises(ValueError, match="not found"):
await retry_insert(db_session, config_manager, event_log, "test_user", "nonexistent-id")
@pytest.mark.asyncio
async def test_retry_insert_job_not_found(self, db_with_run):
"""Negative: run's job does not exist (deleted)."""
session, run_id = db_with_run
session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).delete()
session.commit()
event_log = TranslationEventLog(session)
config_manager = MagicMock()
from src.plugins.translate.orchestrator_cancel import retry_insert
with pytest.raises(ValueError, match="not found"):
await retry_insert(session, config_manager, event_log, "test_user", run_id)
@pytest.mark.asyncio
async def test_retry_insert_success(self, db_with_run):
"""Happy path: retry_insert completes successfully."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "COMPLETED"
session.commit()
event_log = TranslationEventLog(session)
config_manager = MagicMock()
from src.plugins.translate.orchestrator_cancel import retry_insert
with patch('src.plugins.translate.orchestrator_cancel.SQLInsertService') as MockSvc:
mock_svc = MockSvc.return_value
mock_svc.generate_and_insert_sql = AsyncMock(return_value={
"status": "success", "query_id": "q123"
})
result = await retry_insert(session, config_manager, event_log, "test_user", run_id)
assert result.insert_status == "success"
assert result.superset_execution_id == "q123"
events = session.query(TranslationEvent).filter(
TranslationEvent.run_id == run_id
).all()
assert any(e.event_type == "RUN_RETRY_INSERT" for e in events)
@pytest.mark.asyncio
async def test_retry_insert_with_error(self, db_with_run):
"""Edge: insert_result contains error_message."""
session, run_id = db_with_run
run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first()
run.status = "COMPLETED"
session.commit()
event_log = TranslationEventLog(session)
config_manager = MagicMock()
from src.plugins.translate.orchestrator_cancel import retry_insert
with patch('src.plugins.translate.orchestrator_cancel.SQLInsertService') as MockSvc:
mock_svc = MockSvc.return_value
mock_svc.generate_and_insert_sql = AsyncMock(return_value={
"status": "error", "query_id": None,
"error_message": "DB connection failed"
})
result = await retry_insert(session, config_manager, event_log, "test_user", run_id)
assert result.error_message == "DB connection failed"
assert result.superset_execution_id == ""
# #endregion Test.OrchestratorCancel

View File

@@ -1,14 +1,272 @@
# #region Test.PreviewResponseParser [C:3] [TYPE Module] [SEMANTICS test, translate, response, parser, data, extract]
# @BRIEF Tests for preview_response_parser.py — extract_data_rows.
# @BRIEF Tests for preview_response_parser.py — parse_llm_response, extract_data_rows, hashes.
# @RELATION BINDS_TO -> [preview_response_parser]
import hashlib
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from src.plugins.translate.preview_response_parser import extract_data_rows
from src.models.translate import (
Base, TerminologyDictionary, TranslationJob, TranslationJobDictionary, DictionaryEntry,
)
from src.plugins.translate.preview_response_parser import (
compute_config_hash,
compute_dict_snapshot_hash,
extract_data_rows,
parse_llm_response,
)
# #region TestParseLlmResponse [C:2] [TYPE Class]
class TestParseLlmResponse:
"""parse_llm_response — parse LLM JSON response into structured translations dict."""
def test_valid_json_with_rows(self):
"""Happy path: valid JSON with rows."""
response_text = json.dumps({
"rows": [
{"row_id": 1, "detected_source_language": "en",
"translation": "hello"},
{"row_id": 2, "translation": "world"},
]
})
result = parse_llm_response(response_text, expected_count=2)
assert "1" in result
assert "2" in result
assert result["1"]["translation"] == "hello"
assert result["2"]["translation"] == "world"
assert result["2"]["detected_source_language"] == "und"
def test_json_in_markdown_code_block(self):
"""JSON wrapped in markdown code block."""
response_text = "```json\n{\"rows\": [{\"row_id\": \"a1\", \"translation\": \"bonjour\"}]}\n```"
result = parse_llm_response(response_text, expected_count=1)
assert result["a1"]["translation"] == "bonjour"
def test_markdown_block_no_json_tag(self):
"""Markdown code block without json tag."""
response_text = "```\n{\"rows\": [{\"row_id\": \"x\", \"translation\": \"hi\"}]}\n```"
result = parse_llm_response(response_text, expected_count=1)
assert result["x"]["translation"] == "hi"
def test_partial_rows_extracted(self):
"""Recover partial JSON rows when full parse fails."""
response_text = "Here are translations:\n{\"row_id\": 1, \"translation\": \"hello\"}\n{\"row_id\": 2, \"translation\": \"world\"}\n"
result = parse_llm_response(response_text, expected_count=2)
assert result["1"]["translation"] == "hello"
assert result["2"]["translation"] == "world"
def test_partial_rows_mixed_validity(self):
"""Skip invalid rows during partial extraction."""
response_text = "{\"row_id\": 1, \"translation\": \"good\"}\n{invalid json}\n{\"row_id\": 3, \"translation\": \"bad\"}"
result = parse_llm_response(response_text, expected_count=2)
assert "1" in result
assert "3" in result
def test_not_valid_json_raises(self):
"""Completely invalid JSON raises ValueError."""
with pytest.raises(ValueError, match="LLM response was not valid JSON"):
parse_llm_response("not json at all", expected_count=1)
def test_rows_not_a_list_raises(self):
"""JSON where rows is not a list."""
response_text = json.dumps({"rows": "not_a_list"})
with pytest.raises(ValueError, match="missing 'rows' array"):
parse_llm_response(response_text, expected_count=1)
def test_skip_empty_row_id(self):
"""Skip rows with no row_id."""
response_text = json.dumps({
"rows": [
{"translation": "no id"},
{"row_id": 1, "translation": "has id"},
]
})
result = parse_llm_response(response_text, expected_count=2)
assert "1" in result
assert len(result) == 1
def test_with_target_languages(self):
"""Use target_languages to extract per-language translations."""
response_text = json.dumps({
"rows": [
{"row_id": 1, "fr": "bonjour", "de": "hallo"},
{"row_id": 2, "fr": "monde"},
]
})
result = parse_llm_response(response_text, expected_count=2,
target_languages=["fr", "de"])
assert result["1"]["fr"] == "bonjour"
assert result["1"]["de"] == "hallo"
assert result["2"]["fr"] == "monde"
assert "de" not in result["2"]
assert "detected_source_language" in result["1"]
def test_target_languages_empty_value_skips(self):
"""Empty string value in target language is skipped."""
response_text = json.dumps({
"rows": [
{"row_id": 1, "fr": "", "de": "guten tag"},
]
})
result = parse_llm_response(response_text, expected_count=1,
target_languages=["fr", "de"])
assert "fr" not in result["1"]
assert result["1"]["de"] == "guten tag"
def test_no_language_data_and_no_translation_skips(self):
"""Row with no language data and no translation is skipped."""
response_text = json.dumps({
"rows": [
{"row_id": 1, "fr": None},
]
})
result = parse_llm_response(response_text, expected_count=1,
target_languages=["fr"])
assert len(result) == 0
def test_detected_source_language_none(self):
"""When detected_source_language is None, use 'und'."""
response_text = json.dumps({
"rows": [
{"row_id": 1, "translation": "hello"},
]
})
result = parse_llm_response(response_text, expected_count=1)
assert result["1"]["detected_source_language"] == "und"
# #endregion TestParseLlmResponse
# #region TestComputeConfigHash [C:1] [TYPE Class]
class TestComputeConfigHash:
"""compute_config_hash — deterministic hash of job configuration."""
def test_returns_hex_string(self):
"""Returns a 16-char hex string."""
job = MagicMock(spec=TranslationJob)
job.source_dialect = "en"
job.target_dialect = "fr"
job.source_datasource_id = 42
job.translation_column = "text"
job.context_columns = ["col1", "col2"]
job.provider_id = "provider1"
job.batch_size = 100
job.upsert_strategy = "on_conflict_update"
result = compute_config_hash(job)
assert isinstance(result, str)
assert len(result) == 16
assert all(c in "0123456789abcdef" for c in result)
def test_deterministic(self):
"""Same config -> same hash."""
def _make_job():
j = MagicMock(spec=TranslationJob)
j.source_dialect = "en"
j.target_dialect = "fr"
j.source_datasource_id = 42
j.translation_column = "text"
j.context_columns = ["col1", "col2"]
j.provider_id = "provider1"
j.batch_size = 100
j.upsert_strategy = "on_conflict_update"
return j
assert compute_config_hash(_make_job()) == compute_config_hash(_make_job())
def test_different_config_different_hash(self):
"""Different config -> different hash."""
def _make_job(dialect):
j = MagicMock(spec=TranslationJob)
j.source_dialect = dialect
j.target_dialect = "fr"
j.source_datasource_id = 42
j.translation_column = "text"
j.context_columns = []
j.provider_id = "p1"
j.batch_size = 100
j.upsert_strategy = "upsert"
return j
assert compute_config_hash(_make_job("en")) != compute_config_hash(_make_job("de"))
# #endregion TestComputeConfigHash
# #region TestComputeDictSnapshotHash [C:2] [TYPE Class]
class TestComputeDictSnapshotHash:
"""compute_dict_snapshot_hash — hash of dictionary state for snapshot comparison."""
@pytest.fixture
def db_session(self):
"""In-memory SQLite session with translate models."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine)()
yield session
session.close()
def test_no_dict_links_returns_empty_hash(self, db_session):
"""No dictionary links -> hash of empty bytes."""
result = compute_dict_snapshot_hash(db_session, "nonexistent-job")
expected = hashlib.sha256(b"").hexdigest()[:16]
assert result == expected
def test_with_dict_links_no_entries(self, db_session):
"""With dict links but no entries."""
job = TranslationJob(id="j1", name="j1", source_dialect="en", target_dialect="fr", status="ACTIVE")
db_session.add(job)
td = TerminologyDictionary(id="d1", name="dict1")
db_session.add(td)
link = TranslationJobDictionary(id="l1", job_id="j1", dictionary_id="d1")
db_session.add(link)
db_session.commit()
result = compute_dict_snapshot_hash(db_session, "j1")
assert isinstance(result, str)
assert len(result) == 16
def test_with_dict_links_and_entries(self, db_session):
"""With dict links and entries."""
job = TranslationJob(id="j2", name="j2", source_dialect="en", target_dialect="fr", status="ACTIVE")
db_session.add(job)
td = TerminologyDictionary(id="d2", name="dict2")
db_session.add(td)
link = TranslationJobDictionary(id="l2", job_id="j2", dictionary_id="d2")
db_session.add(link)
from datetime import UTC, datetime
entry = DictionaryEntry(id="e1", dictionary_id="d2", source_term="hello",
target_term="bonjour",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC))
db_session.add(entry)
db_session.commit()
result = compute_dict_snapshot_hash(db_session, "j2")
assert isinstance(result, str)
assert len(result) == 16
def test_deterministic(self, db_session):
"""Same state -> same hash."""
job = TranslationJob(id="j3", name="j3", source_dialect="en", target_dialect="fr", status="ACTIVE")
db_session.add(job)
td = TerminologyDictionary(id="d3", name="dict3")
db_session.add(td)
link = TranslationJobDictionary(id="l3", job_id="j3", dictionary_id="d3")
db_session.add(link)
db_session.commit()
h1 = compute_dict_snapshot_hash(db_session, "j3")
h2 = compute_dict_snapshot_hash(db_session, "j3")
assert h1 == h2
# #endregion TestComputeDictSnapshotHash
class TestExtractDataRows:

View File

@@ -0,0 +1,323 @@
# #region Test.RunExecutionService [C:3] [TYPE Module] [SEMANTICS test, translate, run, execution]
# @BRIEF Tests for _run_service.py — RunExecutionService.
# @RELATION BINDS_TO -> [_run_service]
# @TEST_EDGE: filter_new_keys_no_prev_run -> all keys treated as new
# @TEST_EDGE: filter_new_keys_no_key_cols -> all rows pass through
# @TEST_EDGE: load_preview_edits -> edits loaded from session
import pytest
from unittest.mock import MagicMock, patch
from datetime import UTC, datetime
import uuid
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from src.models.translate import (
Base, TranslationBatch, TranslationJob, TranslationRun, TranslationRecord,
TranslationLanguage, TranslationRunLanguageStats,
TranslationPreviewSession, TranslationPreviewRecord,
)
def make_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine)()
return session, engine
class TestRunExecutionService:
"""RunExecutionService — orchestrate full translation run."""
def _make_service(self, db, **kwargs):
from src.plugins.translate._run_service import RunExecutionService
config_manager = kwargs.pop("config_manager", MagicMock())
return RunExecutionService(db, config_manager, **kwargs)
# -- _filter_new_keys --
def test_filter_new_keys_no_prev_run(self):
"""No prior completed run -> all source rows treated as new."""
session, engine = make_session()
try:
job = TranslationJob(id="j1", name="j1", status="ACTIVE",
source_dialect="en", target_dialect="fr")
session.add(job)
session.commit()
svc = self._make_service(session)
source_rows = [{"source_data": {"id": 1}}]
result = svc._filter_new_keys(job, "run-1", source_rows)
assert len(result) == 1
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_filter_new_keys_with_prev_run_filters(self):
"""Prior completed run -> rows with existing keys filtered out."""
session, engine = make_session()
try:
job = TranslationJob(id="j2", name="j2", status="ACTIVE",
source_dialect="en", target_dialect="fr",
target_key_cols=["id"])
session.add(job)
prev_run = TranslationRun(id="prev-run", job_id="j2", status="COMPLETED",
insert_status="succeeded",
started_at=datetime.now(UTC),
created_at=datetime.now(UTC))
session.add(prev_run)
session.flush()
batch = TranslationBatch(id="batch-prev", run_id="prev-run", batch_index=0,
status="COMPLETED", created_at=datetime.now(UTC))
session.add(batch)
session.flush()
# Record with existing key
rec = TranslationRecord(id="rec-1", run_id="prev-run", batch_id="batch-prev",
status="SUCCESS", source_data={"id": 1})
session.add(rec)
session.commit()
svc = self._make_service(session)
source_rows = [
{"source_data": {"id": 1}}, # already exists
{"source_data": {"id": 2}}, # new
]
result = svc._filter_new_keys(job, "curr-run", source_rows)
assert len(result) == 1
assert result[0]["source_data"]["id"] == 2
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_filter_new_keys_no_key_cols(self):
"""No key cols configured -> all rows pass through."""
session, engine = make_session()
try:
job = TranslationJob(id="j3", name="j3", status="ACTIVE",
source_dialect="en", target_dialect="fr",
target_key_cols=None, source_key_cols=None)
session.add(job)
prev_run = TranslationRun(id="prev-run-3", job_id="j3", status="COMPLETED",
insert_status="succeeded",
started_at=datetime.now(UTC),
created_at=datetime.now(UTC))
session.add(prev_run)
session.commit()
svc = self._make_service(session)
source_rows = [{"source_data": {"id": 1}}, {"source_data": {"id": 2}}]
result = svc._filter_new_keys(job, "curr-run-3", source_rows)
assert len(result) == 2
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_filter_new_keys_no_prev_records(self):
"""Prior run has no records -> all rows new."""
session, engine = make_session()
try:
job = TranslationJob(id="j4", name="j4", status="ACTIVE",
source_dialect="en", target_dialect="fr",
target_key_cols=["id"])
session.add(job)
prev_run = TranslationRun(id="prev-run-4", job_id="j4", status="COMPLETED",
insert_status="succeeded",
started_at=datetime.now(UTC),
created_at=datetime.now(UTC))
session.add(prev_run)
session.commit()
svc = self._make_service(session)
source_rows = [{"source_data": {"id": 1}}]
result = svc._filter_new_keys(job, "curr-run-4", source_rows)
assert len(result) == 1
finally:
session.close()
Base.metadata.drop_all(bind=engine)
# -- _load_preview_edits --
def test_load_preview_edits_no_session(self):
"""No applied preview session -> empty cache."""
session, engine = make_session()
try:
job = TranslationJob(id="j5", name="j5", status="ACTIVE",
source_dialect="en", target_dialect="fr")
session.add(job)
session.commit()
svc = self._make_service(session)
svc._load_preview_edits("j5")
assert svc._preview_edits_cache == {}
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_load_preview_edits_with_edits(self):
"""Load preview edits from applied session."""
session, engine = make_session()
try:
job = TranslationJob(id="j6", name="j6", status="ACTIVE",
source_dialect="en", target_dialect="fr")
session.add(job)
ps = TranslationPreviewSession(id="ps-1", job_id="j6", status="APPLIED",
created_at=datetime.now(UTC))
session.add(ps)
# The _load_preview_edits iterates rec.languages which is a Python attribute
# on the model instance set after DB fetch, not a column.
# We'll create a record with source_data, then patch the query to return
# records with languages attached.
from src.models.translate import TranslationPreviewRecord as TPR
rec = TPR(id="prec-1", session_id="ps-1", status="APPROVED",
source_data={"id": 1})
session.add(rec)
session.commit()
svc = self._make_service(session)
# We need to mock the query to return a record with .languages
class MockLangEntry:
def __init__(self, code, status, user_edit=None, final_value=None):
self.language_code = code
self.status = status
self.user_edit = user_edit
self.final_value = final_value
mock_rec = MagicMock(spec=TPR)
mock_rec.source_data = {"id": 1}
mock_rec.languages = [
MockLangEntry("fr", "edited", "bonjour", "bonjour"),
MockLangEntry("de", "approved", "hallo"),
]
with patch.object(session, 'query', return_value=MagicMock()) as mock_query:
mock_filter = MagicMock()
mock_filter.order_by.return_value.first.return_value = ps
mock_query.return_value.filter.return_value = mock_filter
# Mock the records query
mock_filter2 = MagicMock()
mock_filter2.all.return_value = [mock_rec]
mock_query.return_value.filter.return_value = mock_filter
# Make first call return ps, second call return records
mock_query.return_value.filter.return_value.order_by.return_value.first.return_value = ps
# For the records query
mock_records_filter = MagicMock()
mock_records_filter.all.return_value = [mock_rec]
mock_query.return_value.filter.return_value = mock_records_filter
svc._load_preview_edits("j6")
assert svc._preview_edits_cache is not None
finally:
session.close()
Base.metadata.drop_all(bind=engine)
# -- _compute_key_hash --
def test_compute_key_hash(self):
"""_compute_key_hash returns deterministic 16-char hex."""
from src.plugins.translate._run_service import RunExecutionService
h1 = RunExecutionService._compute_key_hash({"a": 1, "b": 2})
h2 = RunExecutionService._compute_key_hash({"a": 1, "b": 2})
h3 = RunExecutionService._compute_key_hash({"a": 1, "b": 3})
assert h1 == h2
assert h1 != h3
assert len(h1) == 16
# -- _update_language_stats_incremental --
def test_update_language_stats_empty(self):
"""No records -> no stats update (no-op)."""
session, engine = make_session()
try:
svc = self._make_service(session)
stats_map = {"fr": TranslationRunLanguageStats(run_id="r1", language_code="fr")}
# No records for run-1, should be a no-op
svc._update_language_stats_incremental("r1", stats_map)
# Just check it doesn't raise
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_update_language_stats_with_records(self):
"""Update stats from records."""
session, engine = make_session()
try:
run = TranslationRun(id="stats-run", job_id="no-job", status="COMPLETED",
started_at=datetime.now(UTC))
session.add(run)
session.flush()
batch = TranslationBatch(id="stats-batch", run_id="stats-run", batch_index=0,
status="COMPLETED", created_at=datetime.now(UTC))
session.add(batch)
session.flush()
rec = TranslationRecord(id="stats-rec", run_id="stats-run", batch_id="stats-batch",
status="SUCCESS")
session.add(rec)
session.flush()
lang = TranslationLanguage(id="l1", record_id="stats-rec",
language_code="fr", status="translated",
translated_value="bonjour")
session.add(lang)
session.commit()
svc = self._make_service(session)
lang_stat = TranslationRunLanguageStats(run_id="stats-run", language_code="fr",
total_rows=0, translated_rows=0)
stats_map = {"fr": lang_stat}
svc._update_language_stats_incremental("stats-run", stats_map)
assert lang_stat.total_rows == 1
assert lang_stat.translated_rows == 1
assert lang_stat.failed_rows == 0
assert lang_stat.skipped_rows == 0
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def test_update_language_stats_multiple_statuses(self):
"""Multiple language entries with different statuses."""
session, engine = make_session()
try:
run = TranslationRun(id="stats-run-2", job_id="no-job", status="COMPLETED",
started_at=datetime.now(UTC))
session.add(run)
session.flush()
batch = TranslationBatch(id="stats-batch-2", run_id="stats-run-2", batch_index=0,
status="COMPLETED", created_at=datetime.now(UTC))
session.add(batch)
session.flush()
# One record per language to avoid unique constraint on (record_id, language_code)
rec_fr = TranslationRecord(id="stats-rec-fr", run_id="stats-run-2",
batch_id="stats-batch-2", status="SUCCESS")
rec_de = TranslationRecord(id="stats-rec-de", run_id="stats-run-2",
batch_id="stats-batch-2", status="SUCCESS")
session.add(rec_fr)
session.add(rec_de)
session.flush()
for code, st in [("fr", "translated"), ("fr", "failed"), ("de", "skipped"), ("de", "translated")]:
rec_id = "stats-rec-fr" if code == "fr" else "stats-rec-de"
session.add(TranslationLanguage(
id=str(uuid.uuid4()), record_id=rec_id,
language_code=code, status=st,
translated_value="x" if st in ("translated",) else None,
))
session.commit()
svc = self._make_service(session)
fr_stat = TranslationRunLanguageStats(run_id="stats-run-2", language_code="fr")
de_stat = TranslationRunLanguageStats(run_id="stats-run-2", language_code="de")
stats_map = {"fr": fr_stat, "de": de_stat}
svc._update_language_stats_incremental("stats-run-2", stats_map)
assert fr_stat.total_rows == 2
assert fr_stat.translated_rows == 1
assert fr_stat.failed_rows == 1
assert de_stat.total_rows == 2
assert de_stat.translated_rows == 1
assert de_stat.skipped_rows == 1
finally:
session.close()
Base.metadata.drop_all(bind=engine)

View File

@@ -0,0 +1,277 @@
# #region Test.RunSourceFetcher [C:3] [TYPE Module] [SEMANTICS test, translate, source, fetch]
# @BRIEF Tests for _run_source.py — fetch_source_rows, _extract_chart_data_rows.
# @RELATION BINDS_TO -> [_run_source]
# @TEST_EDGE: superset_datasource_fetch -> fetch_source_rows returns rows from Superset API
# @TEST_EDGE: empty_datasource -> fallback to preview session
# @TEST_EDGE: no_session -> returns empty list
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from src.models.translate import (
Base, TranslationJob, TranslationPreviewSession, TranslationPreviewRecord,
)
from src.plugins.translate._run_source import _extract_chart_data_rows
def make_session():
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine)()
return session, engine
# #region TestExtractChartDataRows [C:1] [TYPE Class]
class TestExtractChartDataRows:
"""_extract_chart_data_rows — extract data rows from Superset chart data API response."""
def test_result_list_with_data(self):
"""result is a list with item containing data key."""
response = {"result": [{"data": [{"x": 1}, {"x": 2}]}]}
assert _extract_chart_data_rows(response) == [{"x": 1}, {"x": 2}]
def test_result_dict_with_data(self):
"""result is a dict with data key."""
response = {"result": {"data": [{"x": 1}]}}
assert _extract_chart_data_rows(response) == [{"x": 1}]
def test_fallback_to_response_data(self):
"""Fallback to response.data."""
response = {"data": [{"x": 1}]}
assert _extract_chart_data_rows(response) == [{"x": 1}]
def test_result_list_no_data(self):
"""When result is a list with no data, return it."""
response = {"result": [{"x": 1}]}
assert _extract_chart_data_rows(response) == [{"x": 1}]
def test_empty_result_list(self):
"""Empty list returns empty list."""
assert _extract_chart_data_rows({"result": []}) == []
def test_empty_response(self):
"""No recognizable fields returns empty list."""
assert _extract_chart_data_rows({"foo": "bar"}) == []
# #endregion TestExtractChartDataRows
# #region TestFetchSourceRows [C:3] [TYPE Class]
class TestFetchSourceRows:
"""fetch_source_rows — fetch source rows from Superset datasource or preview."""
@pytest.mark.asyncio
async def test_no_job_returns_empty(self):
"""No matching job -> empty list."""
session, engine = make_session()
try:
config_manager = MagicMock()
from src.plugins.translate._run_source import fetch_source_rows
result = await fetch_source_rows(session, config_manager, "nonexistent-job", "run-1")
assert result == []
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_job_without_datasource_preview_session(self):
"""Job with no datasource, but has preview session."""
session, engine = make_session()
try:
job = TranslationJob(id="job-1", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=None)
session.add(job)
sess = TranslationPreviewSession(id="ps-1", job_id="job-1", status="APPLIED",
created_at=datetime.now(UTC))
session.add(sess)
rec = TranslationPreviewRecord(id="rec-1", session_id="ps-1", status="APPROVED",
source_object_id="row1", source_sql="hello",
source_object_name="Row 1")
session.add(rec)
session.commit()
config_manager = MagicMock()
from src.plugins.translate._run_source import fetch_source_rows
result = await fetch_source_rows(session, config_manager, "job-1", "run-1")
assert len(result) == 1
assert result[0]["source_text"] == "hello"
# approved_translation should be the target_sql (None here since status=APPROVED but target_sql not set)
assert result[0]["approved_translation"] is None
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_no_preview_session_returns_empty(self):
"""Job without datasource, no preview session -> empty list."""
session, engine = make_session()
try:
job = TranslationJob(id="job-2", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=None)
session.add(job)
session.commit()
config_manager = MagicMock()
from src.plugins.translate._run_source import fetch_source_rows
result = await fetch_source_rows(session, config_manager, "job-2", "run-2")
assert result == []
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_superset_datasource_success(self):
"""Job with datasource, successful fetch from Superset."""
session, engine = make_session()
try:
job = TranslationJob(id="job-3", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=42, environment_id="env-1",
translation_column="text")
session.add(job)
session.commit()
config_manager = MagicMock()
config_manager.get_environments.return_value = [
MagicMock(id="env-1")
]
mock_client = AsyncMock()
mock_client.get_dataset_detail.return_value = {"id": 42}
mock_client.build_dataset_preview_query_context.return_value = {
"queries": [{}], "form_data": {}
}
mock_client.network.request.return_value = {
"result": [{"data": [{"text": "hello"}, {"text": "world"}]}]
}
from src.plugins.translate._run_source import fetch_source_rows
with patch('src.plugins.translate._run_source.get_superset_client',
AsyncMock(return_value=mock_client)):
result = await fetch_source_rows(session, config_manager, "job-3", "run-3")
assert len(result) == 2
assert result[0]["source_text"] == "hello"
assert result[1]["source_text"] == "world"
assert result[0]["source_data"] == {"text": "hello"}
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_superset_fetch_falls_back_to_preview(self):
"""Superset fetch fails, falls back to preview session."""
session, engine = make_session()
try:
job = TranslationJob(id="job-4", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=42, environment_id="env-1",
translation_column="text")
session.add(job)
sess = TranslationPreviewSession(id="ps-4", job_id="job-4", status="APPLIED",
created_at=datetime.now(UTC))
session.add(sess)
rec = TranslationPreviewRecord(id="rec-4", session_id="ps-4", status="PENDING",
source_object_id="row1", source_sql="fallback text",
source_object_name="Row 1")
session.add(rec)
session.commit()
config_manager = MagicMock()
config_manager.get_environments.return_value = [
MagicMock(id="env-1")
]
mock_client = AsyncMock()
mock_client.get_dataset_detail.side_effect = Exception("API error")
from src.plugins.translate._run_source import fetch_source_rows
with patch('src.plugins.translate._run_source.get_superset_client',
AsyncMock(return_value=mock_client)):
result = await fetch_source_rows(session, config_manager, "job-4", "run-4")
assert len(result) == 1
assert result[0]["source_text"] == "fallback text"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_superset_empty_rows_falls_back(self):
"""Superset returns empty rows, falls back to preview."""
session, engine = make_session()
try:
job = TranslationJob(id="job-5", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=42, environment_id="env-1",
translation_column="text")
session.add(job)
sess = TranslationPreviewSession(id="ps-5", job_id="job-5", status="APPLIED",
created_at=datetime.now(UTC))
session.add(sess)
rec = TranslationPreviewRecord(id="rec-5", session_id="ps-5", status="APPROVED",
source_object_id="row1", source_sql="fb text",
source_object_name="Row 1")
session.add(rec)
session.commit()
config_manager = MagicMock()
config_manager.get_environments.return_value = [
MagicMock(id="env-1")
]
mock_client = AsyncMock()
mock_client.get_dataset_detail.return_value = {"id": 42}
mock_client.build_dataset_preview_query_context.return_value = {
"queries": [{}], "form_data": {}
}
mock_client.network.request.return_value = {"result": [{"data": []}]}
from src.plugins.translate._run_source import fetch_source_rows
with patch('src.plugins.translate._run_source.get_superset_client',
AsyncMock(return_value=mock_client)):
result = await fetch_source_rows(session, config_manager, "job-5", "run-5")
assert len(result) == 1
assert result[0]["source_text"] == "fb text"
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.mark.asyncio
async def test_superset_no_env_config_falls_back(self):
"""No matching environment config -> falls back to preview."""
session, engine = make_session()
try:
job = TranslationJob(id="job-6", name="Test", status="ACTIVE",
source_dialect="en", target_dialect="fr",
source_datasource_id=42, environment_id="nonexistent",
translation_column="text")
session.add(job)
session.commit()
config_manager = MagicMock()
config_manager.get_environments.return_value = [
MagicMock(id="other-env")
]
from src.plugins.translate._run_source import fetch_source_rows
result = await fetch_source_rows(session, config_manager, "job-6", "run-6")
# No preview session, falls back to empty
assert result == []
finally:
session.close()
Base.metadata.drop_all(bind=engine)
# #endregion TestFetchSourceRows
# #endregion Test.RunSourceFetcher