Files
ss-tools/backend/tests/plugins/translate/test_preview_response_parser.py

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 == []