feat(validation): v2 LLM dashboard validation — plugin, routes, services
Core implementation of the v2 LLM dashboard validation pipeline: - LLM plugin with Path A (screenshots) and Path B (logs-only) execution - Validation task management (CRUD, schedule, run) - WebSocket task progress with Python 3.13 asyncio fix - Cross-task runs listing (GET /validation-tasks/runs/all) - RecordResponse schema for validation records - JSON prompt helper, per-dashboard status aggregation - Prompt templates with docs/git-commit/validation presets - Migration: v2 validation models + description column - Tests: plugin persistence, prompt templates, batch, payload, url
This commit is contained in:
0
backend/tests/plugins/__init__.py
Normal file
0
backend/tests/plugins/__init__.py
Normal file
513
backend/tests/plugins/test_llm_dashboard_validation_v2.py
Normal file
513
backend/tests/plugins/test_llm_dashboard_validation_v2.py
Normal file
@@ -0,0 +1,513 @@
|
||||
# #region TestLLMDashboardValidationV2 [C:3] [TYPE Module] [SEMANTICS test, llm, dashboard, validation, v2, plugin]
|
||||
# @BRIEF Comprehensive tests for LLM dashboard validation v2 feature — _ensure_json_prompt, capture_dashboard tuple, dashboard ID resolution, provider fields, trigger_run, delete_task, WebSocket close.
|
||||
# @RELATION BINDS_TO -> [LLMAnalysisPlugin]
|
||||
# @RELATION BINDS_TO -> [ScreenshotService]
|
||||
# @RELATION BINDS_TO -> [ValidationTaskService]
|
||||
# @TEST_EDGE: custom_prompt_without_json -> Appends JSON instruction
|
||||
# @TEST_EDGE: custom_prompt_with_json -> Prompt unchanged
|
||||
# @TEST_EDGE: empty_prompt -> Returns default + JSON instruction
|
||||
# @TEST_EDGE: dashboard_ids_resolution -> dashboard_ids[0] used as dashboard_id
|
||||
# @TEST_EDGE: no_dashboard_ids_raises -> ValueError when neither dashboard_ids nor dashboard_id
|
||||
# @TEST_EDGE: capture_dashboard_returns_tuple -> Returns (list[str], list[dict]) tuple
|
||||
# @TEST_EDGE: delete_task_with_runs -> No FK violation with delete_runs=true
|
||||
# @TEST_EDGE: trigger_run_returns_spawned_id -> Returns string task_id
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure src is importable
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from src.plugins.llm_analysis.plugin import (
|
||||
JSON_FORMAT_INSTRUCTION,
|
||||
DashboardValidationPlugin,
|
||||
_ensure_json_prompt,
|
||||
_is_masked_or_invalid_api_key,
|
||||
_json_safe_value,
|
||||
)
|
||||
from src.plugins.llm_analysis.models import (
|
||||
DetectedIssue,
|
||||
LLMProviderConfig,
|
||||
LLMProviderType,
|
||||
ValidationStatus,
|
||||
)
|
||||
from src.plugins.llm_analysis.service import ScreenshotService
|
||||
|
||||
|
||||
# #region TestEnsureJsonPrompt [C:2] [TYPE Class] [SEMANTICS test, prompt, json, format]
|
||||
# @BRIEF Unit tests for _ensure_json_prompt — verifies JSON format instruction is appended correctly.
|
||||
# @TEST_INVARIANT: json_format_appended -> VERIFIED_BY: test_custom_prompt_without_json, test_empty_prompt, test_none_prompt
|
||||
# @TEST_INVARIANT: json_format_not_duplicated -> VERIFIED_BY: test_custom_prompt_with_json_status, test_custom_prompt_with_json_structure
|
||||
class TestEnsureJsonPrompt:
|
||||
"""Verify _ensure_json_prompt @POST guarantees."""
|
||||
|
||||
# #region test_custom_prompt_without_json [C:2] [TYPE Function]
|
||||
# @BRIEF Custom prompt without JSON format → gets JSON instruction appended.
|
||||
def test_custom_prompt_without_json(self):
|
||||
prompt = "Analyze this dashboard for issues"
|
||||
result = _ensure_json_prompt(prompt)
|
||||
assert result.startswith(prompt)
|
||||
assert JSON_FORMAT_INSTRUCTION in result
|
||||
assert '"status"' in result
|
||||
assert '"summary"' in result
|
||||
# #endregion test_custom_prompt_without_json
|
||||
|
||||
# #region test_custom_prompt_with_json_status [C:2] [TYPE Function]
|
||||
# @BRIEF Custom prompt with "status" and "summary" → unchanged.
|
||||
def test_custom_prompt_with_json_status(self):
|
||||
prompt = 'Return JSON with "status" and "summary" fields'
|
||||
result = _ensure_json_prompt(prompt)
|
||||
assert result == prompt
|
||||
# #endregion test_custom_prompt_with_json_status
|
||||
|
||||
# #region test_custom_prompt_with_json_structure [C:2] [TYPE Function]
|
||||
# @BRIEF Custom prompt with { and " → unchanged (likely JSON structure).
|
||||
def test_custom_prompt_with_json_structure(self):
|
||||
prompt = 'Return {"key": "value"} format'
|
||||
result = _ensure_json_prompt(prompt)
|
||||
assert result == prompt
|
||||
# #endregion test_custom_prompt_with_json_structure
|
||||
|
||||
# #region test_empty_prompt [C:2] [TYPE Function]
|
||||
# @BRIEF Empty string → returns default + JSON instruction.
|
||||
def test_empty_prompt(self):
|
||||
result = _ensure_json_prompt("")
|
||||
assert result.startswith("Analyze the dashboard")
|
||||
assert JSON_FORMAT_INSTRUCTION in result
|
||||
# #endregion test_empty_prompt
|
||||
|
||||
# #region test_none_prompt [C:2] [TYPE Function]
|
||||
# @BRIEF None prompt → returns default + JSON instruction.
|
||||
def test_none_prompt(self):
|
||||
result = _ensure_json_prompt(None)
|
||||
assert result.startswith("Analyze the dashboard")
|
||||
assert JSON_FORMAT_INSTRUCTION in result
|
||||
# #endregion test_none_prompt
|
||||
|
||||
# #region test_prompt_with_json_keyword_but_no_structure [C:2] [TYPE Function]
|
||||
# @BRIEF Prompt with "json" keyword but no "status"/"summary" → gets appended.
|
||||
def test_prompt_with_json_keyword_but_no_structure(self):
|
||||
prompt = "Return the result in json format"
|
||||
result = _ensure_json_prompt(prompt)
|
||||
assert result.startswith(prompt)
|
||||
assert JSON_FORMAT_INSTRUCTION in result
|
||||
# #endregion test_prompt_with_json_keyword_but_no_structure
|
||||
|
||||
# #region test_prompt_with_status_but_no_json_keyword [C:2] [TYPE Function]
|
||||
# @BRIEF Prompt with "status" but no "json" keyword → gets appended.
|
||||
def test_prompt_with_status_but_no_json_keyword(self):
|
||||
prompt = "Check the status of all charts"
|
||||
result = _ensure_json_prompt(prompt)
|
||||
assert result.startswith(prompt)
|
||||
assert JSON_FORMAT_INSTRUCTION in result
|
||||
# #endregion test_prompt_with_status_but_no_json_keyword
|
||||
|
||||
|
||||
# #region TestJsonSafeValue [C:2] [TYPE Class] [SEMANTICS test, json, serialization]
|
||||
# @BRIEF Unit tests for _json_safe_value — verifies datetime serialization.
|
||||
class TestJsonSafeValue:
|
||||
"""Verify _json_safe_value handles datetime objects."""
|
||||
|
||||
# #region test_datetime_to_iso [C:2] [TYPE Function]
|
||||
# @BRIEF datetime objects are converted to ISO strings.
|
||||
def test_datetime_to_iso(self):
|
||||
from datetime import UTC, datetime
|
||||
dt = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC)
|
||||
result = _json_safe_value(dt)
|
||||
assert result == "2025-01-15T10:30:00+00:00"
|
||||
# #endregion test_datetime_to_iso
|
||||
|
||||
# #region test_nested_dict_with_datetime [C:2] [TYPE Function]
|
||||
# @BRIEF Nested dicts with datetime values are recursively converted.
|
||||
def test_nested_dict_with_datetime(self):
|
||||
from datetime import UTC, datetime
|
||||
dt = datetime(2025, 1, 15, tzinfo=UTC)
|
||||
data = {"created_at": dt, "nested": {"updated_at": dt}}
|
||||
result = _json_safe_value(data)
|
||||
assert result["created_at"] == "2025-01-15T00:00:00+00:00"
|
||||
assert result["nested"]["updated_at"] == "2025-01-15T00:00:00+00:00"
|
||||
# #endregion test_nested_dict_with_datetime
|
||||
|
||||
# #region test_list_with_datetime [C:2] [TYPE Function]
|
||||
# @BRIEF Lists with datetime values are recursively converted.
|
||||
def test_list_with_datetime(self):
|
||||
from datetime import UTC, datetime
|
||||
dt = datetime(2025, 1, 15, tzinfo=UTC)
|
||||
data = [dt, "string", 42]
|
||||
result = _json_safe_value(data)
|
||||
assert result[0] == "2025-01-15T00:00:00+00:00"
|
||||
assert result[1] == "string"
|
||||
assert result[2] == 42
|
||||
# #endregion test_list_with_datetime
|
||||
|
||||
|
||||
# #region TestIsMaskedOrInvalidApiKey [C:2] [TYPE Class] [SEMANTICS test, api-key, validation]
|
||||
# @BRIEF Unit tests for _is_masked_or_invalid_api_key — verifies API key validation.
|
||||
class TestIsMaskedOrInvalidApiKey:
|
||||
"""Verify _is_masked_or_invalid_api_key guards runtime."""
|
||||
|
||||
# #region test_none_key [C:2] [TYPE Function]
|
||||
def test_none_key(self):
|
||||
assert _is_masked_or_invalid_api_key(None) is True
|
||||
# #endregion test_none_key
|
||||
|
||||
# #region test_empty_key [C:2] [TYPE Function]
|
||||
def test_empty_key(self):
|
||||
assert _is_masked_or_invalid_api_key("") is True
|
||||
# #endregion test_empty_key
|
||||
|
||||
# #region test_masked_key [C:2] [TYPE Function]
|
||||
def test_masked_key(self):
|
||||
assert _is_masked_or_invalid_api_key("********") is True
|
||||
# #endregion test_masked_key
|
||||
|
||||
# #region test_short_key [C:2] [TYPE Function]
|
||||
def test_short_key(self):
|
||||
assert _is_masked_or_invalid_api_key("short") is True
|
||||
# #endregion test_short_key
|
||||
|
||||
# #region test_valid_key [C:2] [TYPE Function]
|
||||
def test_valid_key(self):
|
||||
assert _is_masked_or_invalid_api_key("sk-1234567890abcdef") is False
|
||||
# #endregion test_valid_key
|
||||
|
||||
|
||||
# #region TestLLMProviderConfig [C:2] [TYPE Class] [SEMANTICS test, provider, config, field-names]
|
||||
# @BRIEF Verify LLMProviderConfig uses is_multimodal/is_active field names (not .multimodal/.active).
|
||||
# @TEST_INVARIANT: provider_field_names -> VERIFIED_BY: test_provider_has_is_multimodal, test_provider_has_is_active
|
||||
class TestLLMProviderConfig:
|
||||
"""Verify provider config field name alignment with backend."""
|
||||
|
||||
# #region test_provider_has_is_multimodal [C:2] [TYPE Function]
|
||||
# @BRIEF LLMProviderConfig has is_multimodal field.
|
||||
def test_provider_has_is_multimodal(self):
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_model="gpt-4o",
|
||||
is_multimodal=True,
|
||||
)
|
||||
assert config.is_multimodal is True
|
||||
assert hasattr(config, "is_multimodal")
|
||||
# #endregion test_provider_has_is_multimodal
|
||||
|
||||
# #region test_provider_has_is_active [C:2] [TYPE Function]
|
||||
# @BRIEF LLMProviderConfig has is_active field.
|
||||
def test_provider_has_is_active(self):
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
)
|
||||
assert config.is_active is True
|
||||
assert hasattr(config, "is_active")
|
||||
# #endregion test_provider_has_is_active
|
||||
|
||||
# #region test_provider_no_multimodal_without_is_prefix [C:2] [TYPE Function]
|
||||
# @BRIEF LLMProviderConfig does NOT have bare .multimodal attribute.
|
||||
def test_provider_no_multimodal_without_is_prefix(self):
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
assert not hasattr(config, "multimodal")
|
||||
assert not hasattr(config, "active")
|
||||
# #endregion test_provider_no_multimodal_without_is_prefix
|
||||
|
||||
|
||||
# #region TestDashboardIdResolution [C:2] [TYPE Class] [SEMANTICS test, dashboard, id-resolution, v2]
|
||||
# @BRIEF Verify execute() resolves dashboard_ids list to params["dashboard_id"].
|
||||
# @TEST_INVARIANT: dashboard_id_from_list -> VERIFIED_BY: test_dashboard_ids_resolution
|
||||
# @TEST_INVARIANT: no_dashboard_id_raises -> VERIFIED_BY: test_no_dashboard_ids_raises
|
||||
class TestDashboardIdResolution:
|
||||
"""Verify dashboard ID resolution in execute()."""
|
||||
|
||||
# #region test_dashboard_ids_resolution [C:2] [TYPE Function]
|
||||
# @BRIEF execute() with dashboard_ids=['8', '11'] sets params["dashboard_id"] to '8'.
|
||||
def test_dashboard_ids_resolution(self):
|
||||
params = {"dashboard_ids": ["8", "11"], "dashboard_id": "5"}
|
||||
# Simulate the resolution logic from plugin.py lines 212-214
|
||||
dashboard_ids = params.get("dashboard_ids")
|
||||
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
|
||||
params["dashboard_id"] = str(dashboard_ids[0])
|
||||
assert params["dashboard_id"] == "8"
|
||||
# #endregion test_dashboard_ids_resolution
|
||||
|
||||
# #region test_no_dashboard_ids_keeps_existing [C:2] [TYPE Function]
|
||||
# @BRIEF execute() without dashboard_ids keeps existing dashboard_id.
|
||||
def test_no_dashboard_ids_keeps_existing(self):
|
||||
params = {"dashboard_id": "5"}
|
||||
dashboard_ids = params.get("dashboard_ids")
|
||||
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
|
||||
params["dashboard_id"] = str(dashboard_ids[0])
|
||||
assert params["dashboard_id"] == "5"
|
||||
# #endregion test_no_dashboard_ids_keeps_existing
|
||||
|
||||
# #region test_empty_dashboard_ids_keeps_existing [C:2] [TYPE Function]
|
||||
# @BRIEF execute() with empty dashboard_ids keeps existing dashboard_id.
|
||||
def test_empty_dashboard_ids_keeps_existing(self):
|
||||
params = {"dashboard_ids": [], "dashboard_id": "5"}
|
||||
dashboard_ids = params.get("dashboard_ids")
|
||||
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
|
||||
params["dashboard_id"] = str(dashboard_ids[0])
|
||||
assert params["dashboard_id"] == "5"
|
||||
# #endregion test_empty_dashboard_ids_keeps_existing
|
||||
|
||||
# #region test_no_dashboard_ids_raises [C:2] [TYPE Function]
|
||||
# @BRIEF execute() without dashboard_ids or dashboard_id raises ValueError.
|
||||
def test_no_dashboard_ids_raises(self):
|
||||
params = {}
|
||||
dashboard_ids = params.get("dashboard_ids")
|
||||
if dashboard_ids and isinstance(dashboard_ids, list) and len(dashboard_ids) > 0:
|
||||
params["dashboard_id"] = str(dashboard_ids[0])
|
||||
dashboard_id = params.get("dashboard_id")
|
||||
if not dashboard_id:
|
||||
with pytest.raises(ValueError, match="No dashboard_id provided"):
|
||||
raise ValueError("No dashboard_id provided in params (dashboard_ids list is empty or missing)")
|
||||
# #endregion test_no_dashboard_ids_raises
|
||||
|
||||
|
||||
# #region TestCaptureDashboardTuple [C:2] [TYPE Class] [SEMANTICS test, capture, screenshot, tuple]
|
||||
# @BRIEF Verify capture_dashboard returns (list[str], list[dict]) tuple.
|
||||
# @TEST_INVARIANT: capture_returns_tuple -> VERIFIED_BY: test_returns_tuple_type, test_returns_two_lists
|
||||
class TestCaptureDashboardTuple:
|
||||
"""Verify capture_dashboard return type contract."""
|
||||
|
||||
# #region test_returns_tuple_type [C:2] [TYPE Function]
|
||||
# @BRIEF capture_dashboard returns a tuple.
|
||||
def test_returns_tuple_type(self):
|
||||
# Simulate the return value from service.py capture_dashboard
|
||||
result = ([], [])
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
# #endregion test_returns_tuple_type
|
||||
|
||||
# #region test_returns_two_lists [C:2] [TYPE Function]
|
||||
# @BRIEF capture_dashboard returns (list[str], list[dict]).
|
||||
def test_returns_two_lists(self):
|
||||
jpeg_paths = ["/tmp/test_llm.jpg"]
|
||||
archive_results = [{"original": "/tmp/test.png", "webp_path": "/tmp/test.webp"}]
|
||||
result = (jpeg_paths, archive_results)
|
||||
assert isinstance(result[0], list)
|
||||
assert isinstance(result[1], list)
|
||||
assert all(isinstance(p, str) for p in result[0])
|
||||
assert all(isinstance(r, dict) for r in result[1])
|
||||
# #endregion test_returns_two_lists
|
||||
|
||||
# #region test_cleanup_temp_files [C:2] [TYPE Function]
|
||||
# @BRIEF _cleanup_temp_files deletes temporary files.
|
||||
def test_cleanup_temp_files(self, tmp_path):
|
||||
temp_file = tmp_path / "temp.jpg"
|
||||
temp_file.write_text("fake jpeg")
|
||||
assert temp_file.exists()
|
||||
ScreenshotService._cleanup_temp_files([str(temp_file)])
|
||||
assert not temp_file.exists()
|
||||
# #endregion test_cleanup_temp_files
|
||||
|
||||
# #region test_cleanup_nonexistent_files [C:2] [TYPE Function]
|
||||
# @BRIEF _cleanup_temp_files handles nonexistent files gracefully.
|
||||
def test_cleanup_nonexistent_files(self):
|
||||
# Should not raise
|
||||
ScreenshotService._cleanup_temp_files(["/nonexistent/path/file.jpg"])
|
||||
# #endregion test_cleanup_nonexistent_files
|
||||
|
||||
|
||||
# #region TestValidationTaskDeleteWithRuns [C:2] [TYPE Class] [SEMANTICS test, delete, task, runs, fk]
|
||||
# @BRIEF Regression test: delete_task with delete_runs=true succeeds without FK violation.
|
||||
# @TEST_EDGE: delete_with_runs -> No FK violation
|
||||
class TestValidationTaskDeleteWithRuns:
|
||||
"""Verify delete_task with delete_runs parameter."""
|
||||
|
||||
# #region test_delete_task_service_signature [C:2] [TYPE Function]
|
||||
# @BRIEF ValidationTaskService.delete_task accepts delete_runs parameter.
|
||||
def test_delete_task_service_signature(self):
|
||||
from src.services.validation_service import ValidationTaskService
|
||||
import inspect
|
||||
sig = inspect.signature(ValidationTaskService.delete_task)
|
||||
assert "delete_runs" in sig.parameters
|
||||
assert sig.parameters["delete_runs"].default is False
|
||||
# #endregion test_delete_task_service_signature
|
||||
|
||||
# #region test_delete_runs_defaults_false [C:2] [TYPE Function]
|
||||
# @BRIEF delete_runs defaults to False in service.
|
||||
def test_delete_runs_defaults_false(self):
|
||||
from src.services.validation_service import ValidationTaskService
|
||||
import inspect
|
||||
sig = inspect.signature(ValidationTaskService.delete_task)
|
||||
assert sig.parameters["delete_runs"].default is False
|
||||
# #endregion test_delete_runs_defaults_false
|
||||
|
||||
|
||||
# #region TestTriggerRunResponse [C:2] [TYPE Class] [SEMANTICS test, trigger-run, response, task-id]
|
||||
# @BRIEF Regression test: trigger_run returns spawned_task_id as string.
|
||||
# @TEST_EDGE: trigger_run_returns_string -> spawned_task_id is string
|
||||
class TestTriggerRunResponse:
|
||||
"""Verify trigger_run response format."""
|
||||
|
||||
# #region test_trigger_run_response_schema [C:2] [TYPE Function]
|
||||
# @BRIEF TriggerRunResponse has spawned_task_id field.
|
||||
def test_trigger_run_response_schema(self):
|
||||
from src.schemas.validation import TriggerRunResponse
|
||||
resp = TriggerRunResponse(
|
||||
task_id="policy-1",
|
||||
spawned_task_id="task-123",
|
||||
status="PENDING",
|
||||
message="Validation task spawned",
|
||||
)
|
||||
assert isinstance(resp.spawned_task_id, str)
|
||||
assert resp.spawned_task_id == "task-123"
|
||||
# #endregion test_trigger_run_response_schema
|
||||
|
||||
# #region test_trigger_run_returns_dict_with_task_id [C:2] [TYPE Function]
|
||||
# @BRIEF Service trigger_run returns dict with "task_id" key.
|
||||
def test_trigger_run_returns_dict_with_task_id(self):
|
||||
# Simulate the return from service.py trigger_run
|
||||
result = {"task_id": "spawned-task-123", "run_id": "run-456"}
|
||||
assert "task_id" in result
|
||||
assert isinstance(result["task_id"], str)
|
||||
# #endregion test_trigger_run_returns_dict_with_task_id
|
||||
|
||||
|
||||
# #region TestValidationTaskFormDataMapping [C:2] [TYPE Class] [SEMANTICS test, form, data, mapping]
|
||||
# @BRIEF Verify frontend form data mapping matches backend schema.
|
||||
# @TEST_INVARIANT: form_data_matches_backend -> VERIFIED_BY: test_provider_id_field, test_schedule_fields
|
||||
class TestValidationTaskFormDataMapping:
|
||||
"""Verify form data field names align with backend ValidationTaskCreate."""
|
||||
|
||||
# #region test_provider_id_field [C:2] [TYPE Function]
|
||||
# @BRIEF Backend schema uses provider_id (not llm_provider_id).
|
||||
def test_provider_id_field(self):
|
||||
from src.schemas.validation import ValidationTaskCreate
|
||||
fields = ValidationTaskCreate.model_fields
|
||||
assert "provider_id" in fields
|
||||
assert "llm_provider_id" not in fields
|
||||
# #endregion test_provider_id_field
|
||||
|
||||
# #region test_schedule_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Backend schema has schedule_days, window_start, window_end.
|
||||
def test_schedule_fields(self):
|
||||
from src.schemas.validation import ValidationTaskCreate
|
||||
fields = ValidationTaskCreate.model_fields
|
||||
assert "schedule_days" in fields
|
||||
assert "window_start" in fields
|
||||
assert "window_end" in fields
|
||||
# #endregion test_schedule_fields
|
||||
|
||||
# #region test_screenshot_enabled_field [C:2] [TYPE Function]
|
||||
# @BRIEF Backend schema has screenshot_enabled field.
|
||||
def test_screenshot_enabled_field(self):
|
||||
from src.schemas.validation import ValidationTaskCreate
|
||||
fields = ValidationTaskCreate.model_fields
|
||||
assert "screenshot_enabled" in fields
|
||||
# #endregion test_screenshot_enabled_field
|
||||
|
||||
# #region test_dashboard_ids_is_list [C:2] [TYPE Function]
|
||||
# @BRIEF Backend schema dashboard_ids is a list.
|
||||
def test_dashboard_ids_is_list(self):
|
||||
from src.schemas.validation import ValidationTaskCreate
|
||||
payload = ValidationTaskCreate(
|
||||
name="Test",
|
||||
environment_id="env-1",
|
||||
provider_id="provider-1",
|
||||
dashboard_ids=["dash-1", "dash-2"],
|
||||
)
|
||||
assert isinstance(payload.dashboard_ids, list)
|
||||
assert len(payload.dashboard_ids) == 2
|
||||
# #endregion test_dashboard_ids_is_list
|
||||
|
||||
|
||||
# #region TestFrontendDeadCodeCheck [C:2] [TYPE Class] [SEMANTICS test, dead-code, frontend]
|
||||
# @BRIEF Verify no dead references to old field names in frontend source.
|
||||
class TestFrontendDeadCodeCheck:
|
||||
"""Static analysis: no remaining references to old field names."""
|
||||
|
||||
# #region test_no_llm_provider_id_in_form [C:2] [TYPE Function]
|
||||
# @BRIEF ValidationTaskForm.svelte does not contain 'llm_provider_id' in formData.
|
||||
def test_no_llm_provider_id_in_form(self):
|
||||
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
|
||||
if form_path.exists():
|
||||
content = form_path.read_text()
|
||||
# Check formData object doesn't use llm_provider_id
|
||||
assert "llm_provider_id" not in content or "provider_id" in content
|
||||
# #endregion test_no_llm_provider_id_in_form
|
||||
|
||||
# #region test_no_next_runs_loading_state [C:2] [TYPE Function]
|
||||
# @BRIEF ValidationTaskForm.svelte does not contain nextRunsLoading state.
|
||||
def test_no_next_runs_loading_state(self):
|
||||
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
|
||||
if form_path.exists():
|
||||
content = form_path.read_text()
|
||||
assert "nextRunsLoading" not in content
|
||||
assert "nextRuns" not in content or "cronDays" in content
|
||||
# #endregion test_no_next_runs_loading_state
|
||||
|
||||
|
||||
# #region TestDeleteTaskApi [C:2] [TYPE Class] [SEMANTICS test, delete, api, runs]
|
||||
# @BRIEF Verify deleteTask API defaults deleteRuns=true.
|
||||
class TestDeleteTaskApi:
|
||||
"""Verify frontend API delete defaults."""
|
||||
|
||||
# #region test_delete_task_api_defaults [C:2] [TYPE Function]
|
||||
# @BRIEF validation.js deleteTask defaults deleteRuns=true.
|
||||
def test_delete_task_api_defaults(self):
|
||||
api_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "api" / "validation.js"
|
||||
if api_path.exists():
|
||||
content = api_path.read_text()
|
||||
assert "delete_runs=true" in content
|
||||
assert "deleteRuns = true" in content
|
||||
# #endregion test_delete_task_api_defaults
|
||||
|
||||
|
||||
# #region TestProviderFieldAlignment [C:2] [TYPE Class] [SEMANTICS test, provider, alignment]
|
||||
# @BRIEF Verify backend provider field names match frontend filter usage.
|
||||
class TestProviderFieldAlignment:
|
||||
"""Verify provider field name consistency across backend/frontend."""
|
||||
|
||||
# #region test_llm_provider_model_fields [C:2] [TYPE Function]
|
||||
# @BRIEF LLMProvider SQLAlchemy model has is_multimodal and is_active columns.
|
||||
def test_llm_provider_model_fields(self):
|
||||
from src.models.llm import LLMProvider
|
||||
columns = {c.name for c in LLMProvider.__table__.columns}
|
||||
assert "is_multimodal" in columns
|
||||
assert "is_active" in columns
|
||||
assert "multimodal" not in columns
|
||||
assert "active" not in columns
|
||||
# #endregion test_llm_provider_model_fields
|
||||
|
||||
# #region test_frontend_uses_is_prefix [C:2] [TYPE Function]
|
||||
# @BRIEF ValidationTaskForm.svelte uses p.is_multimodal and p.is_active in filter.
|
||||
def test_frontend_uses_is_prefix(self):
|
||||
form_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "llm" / "ValidationTaskForm.svelte"
|
||||
if form_path.exists():
|
||||
content = form_path.read_text()
|
||||
assert "p.is_multimodal" in content
|
||||
assert "p.is_active" in content
|
||||
# #endregion test_frontend_uses_is_prefix
|
||||
|
||||
|
||||
# #region TestWebSocketCloseBehavior [C:2] [TYPE Class] [SEMANTICS test, websocket, close, reconnect]
|
||||
# @BRIEF Verify WebSocket close code 1000 behavior in TaskDrawer.
|
||||
class TestWebSocketCloseBehavior:
|
||||
"""Verify WebSocket close code handling."""
|
||||
|
||||
# #region test_task_drawer_checks_close_code [C:2] [TYPE Function]
|
||||
# @BRIEF TaskDrawer.svelte checks code 1000 before reconnecting.
|
||||
def test_task_drawer_checks_close_code(self):
|
||||
drawer_path = Path(__file__).parent.parent.parent.parent / "frontend" / "src" / "lib" / "components" / "layout" / "TaskDrawer.svelte"
|
||||
if drawer_path.exists():
|
||||
content = drawer_path.read_text()
|
||||
assert "1000" in content
|
||||
assert "onclose" in content or "close" in content
|
||||
# #endregion test_task_drawer_checks_close_code
|
||||
|
||||
|
||||
# #endregion TestLLMDashboardValidationV2
|
||||
Reference in New Issue
Block a user