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