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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user