# #region Test.PreviewExecutor [C:3] [TYPE Module] [SEMANTICS test,preview,executor,llm] # @BRIEF Tests for preview_executor.py — PreviewExecutor class. # @RELATION BINDS_TO -> [PreviewExecutor] # @TEST_EDGE: no_env_config -> ValueError # @TEST_EDGE: fetch_failure -> ValueError # @TEST_EDGE: no_provider_id -> ValueError # @TEST_EDGE: unsupported_provider -> ValueError import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import pytest from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from src.models.translate import TranslationJob from src.plugins.translate.preview_executor import PreviewExecutor from .conftest import JOB_ID class TestFetchSampleRows: """PreviewExecutor.fetch_sample_rows — fetch from Superset.""" def _make_job(self): job = MagicMock(spec=TranslationJob) job.source_datasource_id = "1" job.environment_id = "env-1" job.source_dialect = None return job @pytest.mark.asyncio async def test_success(self, db_session): """Successful fetch returns extracted rows.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() job.environment_id = None job.source_dialect = None env_config = MagicMock() env_config.id = "env-1" config_manager = MagicMock() config_manager.get_environments.return_value = [env_config] executor.config_manager = config_manager with patch("src.plugins.translate.preview_executor.get_superset_client") as mock_get: mock_client = MagicMock() mock_get.return_value = mock_client mock_client.get_dataset_detail = AsyncMock(return_value={"id": 1}) mock_client.build_dataset_preview_query_context = MagicMock(return_value={ "queries": [{}], "form_data": {}, }) mock_client.network.request = AsyncMock() mock_client.network.request.return_value = {"result": []} with patch("src.plugins.translate.preview_executor._extract_data_rows", return_value=[{"text": "hello"}]): rows = await executor.fetch_sample_rows(job) assert len(rows) == 1 @pytest.mark.asyncio async def test_no_env_config_fallback(self, db_session): """Falls back to first env config when env_id not found.""" env_config = MagicMock() env_config.id = "env-other" config_manager = MagicMock() config_manager.get_environments.return_value = [env_config] executor = PreviewExecutor(db_session, config_manager) job = self._make_job() job.environment_id = "env-nonexistent" job.source_dialect = None with patch("src.plugins.translate.preview_executor.get_superset_client") as mock_get: mock_client = MagicMock() mock_get.return_value = mock_client mock_client.get_dataset_detail = AsyncMock(return_value={"id": 1}) mock_client.build_dataset_preview_query_context = MagicMock(return_value={ "queries": [{}], "form_data": {}, }) mock_client.network.request = AsyncMock() mock_client.network.request.return_value = {"result": []} with patch("src.plugins.translate.preview_executor._extract_data_rows", return_value=[{"text": "hello"}]): rows = await executor.fetch_sample_rows(job) assert len(rows) == 1 @pytest.mark.asyncio async def test_no_environments_raises(self, db_session): """No environments configured raises ValueError.""" config_manager = MagicMock() config_manager.get_environments.return_value = [] executor = PreviewExecutor(db_session, config_manager) job = self._make_job() with pytest.raises(ValueError, match="No Superset environments"): await executor.fetch_sample_rows(job) @pytest.mark.asyncio async def test_network_error_raises(self, db_session): """Network error raises ValueError.""" env_config = MagicMock() env_config.id = "env-1" config_manager = MagicMock() config_manager.get_environments.return_value = [env_config] executor = PreviewExecutor(db_session, config_manager) job = self._make_job() with patch("src.plugins.translate.preview_executor.get_superset_client") as mock_get: mock_client = MagicMock() mock_get.return_value = mock_client mock_client.get_dataset_detail = AsyncMock(return_value={"id": 1}) mock_client.build_dataset_preview_query_context = MagicMock(return_value={"queries": [{}], "form_data": {}}) mock_client.network.request = AsyncMock(side_effect=Exception("Network error")) with pytest.raises(ValueError, match="Failed to fetch sample data"): await executor.fetch_sample_rows(job) @pytest.mark.asyncio async def test_query_context_structure(self, db_session): """Verify query context modifications (row_limit, result_type, etc.).""" env_config = MagicMock() env_config.id = "env-1" config_manager = MagicMock() config_manager.get_environments.return_value = [env_config] executor = PreviewExecutor(db_session, config_manager) job = self._make_job() with patch("src.plugins.translate.preview_executor.get_superset_client") as mock_get: mock_client = MagicMock() mock_get.return_value = mock_client mock_client.get_dataset_detail = AsyncMock(return_value={"id": 1}) mock_client.build_dataset_preview_query_context = MagicMock(return_value={ "queries": [{"result_type": "full", "columns": ["col1"], "metrics": ["count"]}], "form_data": {"query_mode": "aggregate"}, }) mock_client.network.request = AsyncMock() mock_client.network.request.return_value = {"result": []} with patch("src.plugins.translate.preview_executor._extract_data_rows", return_value=[{"text": "hello"}]): rows = await executor.fetch_sample_rows(job, sample_size=5) assert len(rows) == 1 # Verify query context was modified call_kwargs = mock_client.network.request.call_args[1] posted_data = call_kwargs["data"] import json parsed = json.loads(posted_data) assert parsed["queries"][0]["row_limit"] == 5 assert "result_type" not in parsed["queries"][0] assert "columns" not in parsed["queries"][0] assert parsed["queries"][0]["metrics"] == [] assert parsed["result_type"] == "samples" assert "query_mode" not in parsed["form_data"] class TestCallLLM: """PreviewExecutor.call_llm — Call LLM provider.""" def _make_job(self): job = MagicMock(spec=TranslationJob) job.provider_id = "prov-1" job.disable_reasoning = False return job def _make_provider(self, base_url="https://api.openai.com", provider_type="openai", default_model="gpt-4o-mini"): provider = MagicMock() provider.base_url = base_url provider.provider_type = provider_type provider.default_model = default_model return provider @pytest.mark.asyncio async def test_success(self, db_session): """Successful LLM call returns content.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = self._make_provider() mock_instance.get_decrypted_api_key.return_value = "sk-test" with patch("src.plugins.translate.preview_executor.call_openai_compatible", AsyncMock(return_value=("translated text", "stop"))): result = await executor.call_llm(job, "translate this", 4096) assert result == "translated text" @pytest.mark.asyncio async def test_no_provider_id(self, db_session): """No provider_id raises ValueError.""" executor = PreviewExecutor(db_session, MagicMock()) job = MagicMock(spec=TranslationJob) job.provider_id = None with pytest.raises(ValueError, match="no LLM provider"): await executor.call_llm(job, "text") @pytest.mark.asyncio async def test_provider_not_found(self, db_session): """Provider not found in DB raises ValueError.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = None with pytest.raises(ValueError, match="not found"): await executor.call_llm(job, "text") @pytest.mark.asyncio async def test_no_api_key(self, db_session): """No decrypted API key raises ValueError.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = self._make_provider() mock_instance.get_decrypted_api_key.return_value = None with pytest.raises(ValueError, match="Could not decrypt API key"): await executor.call_llm(job, "text") @pytest.mark.asyncio async def test_unsupported_provider_type(self, db_session): """Unsupported provider type raises ValueError.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = self._make_provider( provider_type="anthropic" ) mock_instance.get_decrypted_api_key.return_value = "sk-test" with pytest.raises(ValueError, match="Unsupported provider type"): await executor.call_llm(job, "text") @pytest.mark.asyncio async def test_empty_content_retry(self, db_session): """Empty content triggers retry with doubled max_tokens.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = self._make_provider() mock_instance.get_decrypted_api_key.return_value = "sk-test" # First call raises ValueError("empty content"), second succeeds with patch("src.plugins.translate.preview_executor.call_openai_compatible", AsyncMock(side_effect=[ ValueError("empty content"), ("retried content", "stop"), ])): result = await executor.call_llm(job, "translate this", 4096) assert result == "retried content" @pytest.mark.asyncio async def test_empty_content_retry_exhausted(self, db_session): """If retry also returns empty content, raises.""" executor = PreviewExecutor(db_session, MagicMock()) job = self._make_job() with patch("src.services.llm_provider.LLMProviderService") as mock_svc: mock_instance = MagicMock() mock_svc.return_value = mock_instance mock_instance.get_provider.return_value = self._make_provider() mock_instance.get_decrypted_api_key.return_value = "sk-test" with patch("src.plugins.translate.preview_executor.call_openai_compatible", AsyncMock(side_effect=[ValueError("empty content"), ValueError("another error")])): with pytest.raises(ValueError, match="another error"): await executor.call_llm(job, "text") class TestDelegationHelpers: """PreviewExecutor delegation to preview_response_parser.""" def test_parse_llm_response(self, db_session): """Delegates to module function.""" executor = PreviewExecutor(db_session, MagicMock()) with patch("src.plugins.translate.preview_executor._parse_llm_response") as mock: mock.return_value = {"0": {"fr": "bonjour"}} result = executor.parse_llm_response("text", 1, ["fr"]) assert result == {"0": {"fr": "bonjour"}} def test_resolve_provider_model(self, db_session): """Delegates to module function.""" executor = PreviewExecutor(db_session, MagicMock()) job = MagicMock() with patch("src.plugins.translate.preview_executor._resolve_provider_model") as mock: mock.return_value = "gpt-4o" result = executor.resolve_provider_model(job) assert result == "gpt-4o" def test_compute_config_hash(self, db_session): """Delegates to module function.""" with patch("src.plugins.translate.preview_executor._compute_config_hash") as mock: mock.return_value = "hash123" job = MagicMock() result = PreviewExecutor.compute_config_hash(job) assert result == "hash123" def test_compute_dict_snapshot_hash(self, db_session): """Delegates to module function.""" executor = PreviewExecutor(db_session, MagicMock()) with patch("src.plugins.translate.preview_executor._compute_dict_snapshot_hash") as mock: mock.return_value = "dict-hash" result = executor.compute_dict_snapshot_hash("job-1") assert result == "dict-hash" # #endregion Test.PreviewExecutor