353 lines
13 KiB
Python
353 lines
13 KiB
Python
# #region Test.PreviewResponseParser [C:3] [TYPE Module] [SEMANTICS test, translate, response, parser, data, extract]
|
|
# @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.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:
|
|
"""extract_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": [{"col1": "val1"}, {"col1": "val2"}]}
|
|
]
|
|
}
|
|
result = extract_data_rows(response)
|
|
assert len(result) == 2
|
|
assert result[0]["col1"] == "val1"
|
|
|
|
def test_result_dict_with_data(self):
|
|
"""result is a dict with data key."""
|
|
response = {
|
|
"result": {"data": [{"col1": "val1"}]}
|
|
}
|
|
result = extract_data_rows(response)
|
|
assert len(result) == 1
|
|
|
|
def test_fallback_to_response_data(self):
|
|
"""Fallback to response.data when result has no data."""
|
|
response = {
|
|
"data": [{"col1": "val1"}]
|
|
}
|
|
result = extract_data_rows(response)
|
|
assert len(result) == 1
|
|
|
|
def test_result_list_returned_directly(self):
|
|
"""When result is a list with no data key, return it."""
|
|
response = {
|
|
"result": [{"col1": "val1"}, {"col1": "val2"}]
|
|
}
|
|
result = extract_data_rows(response)
|
|
assert len(result) == 2
|
|
|
|
def test_empty_result_list(self):
|
|
"""Empty result list returns empty list."""
|
|
response = {"result": []}
|
|
result = extract_data_rows(response)
|
|
assert result == []
|
|
|
|
def test_empty_data_list(self):
|
|
"""Empty data list falls back to returning result list."""
|
|
response = {
|
|
"result": [{"data": []}]
|
|
}
|
|
result = extract_data_rows(response)
|
|
# Returns the result list as fallback
|
|
assert result == [{"data": []}]
|
|
|
|
def test_no_result_or_data_key(self):
|
|
"""No result or data key returns empty list."""
|
|
response = {"other": "value"}
|
|
result = extract_data_rows(response)
|
|
assert result == []
|
|
|
|
def test_result_not_list_or_dict(self):
|
|
"""Result is neither list nor dict."""
|
|
response = {"result": "string_value"}
|
|
result = extract_data_rows(response)
|
|
assert result == []
|
|
|
|
def test_result_list_item_no_data(self):
|
|
"""Result list item has no data key, falls back to result list."""
|
|
response = {
|
|
"result": [{"other": "value"}]
|
|
}
|
|
result = extract_data_rows(response)
|
|
# Returns the result list as fallback
|
|
assert result == [{"other": "value"}]
|
|
|
|
def test_result_dict_data_empty_list(self):
|
|
"""Result dict data is empty list."""
|
|
response = {
|
|
"result": {"data": []}
|
|
}
|
|
result = extract_data_rows(response)
|
|
assert result == []
|