# #region Test.ReportNormalizer [C:3] [TYPE Module] [SEMANTICS test,report,normalizer,task] # @BRIEF Tests for services/reports/normalizer.py — status_to_report_status, build_summary, extract_error_context, normalize_task_report. # @RELATION BINDS_TO -> [Services.Normalizer] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch import pytest # #region helper factories [C:1] [TYPE Function] # @BRIEF Helper factory for creating mock tasks. def _make_task(**overrides): from src.core.task_manager.models import Task, TaskStatus, LogEntry defaults = { "id": "task-1", "plugin_id": "superset-migration", "status": TaskStatus.SUCCESS, "params": {}, "result": None, "started_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), "finished_at": datetime(2024, 1, 1, 12, 30, 0, tzinfo=UTC), "logs": [], } defaults.update(overrides) return Task(**defaults) # #endregion helper factories # #region Test.ReportNormalizer.TestStatusToReportStatus [C:2] [TYPE Function] # @BRIEF Test status_to_report_status mapping. class TestStatusToReportStatus: def test_success_maps_to_success(self): from src.services.reports.normalizer import status_to_report_status from src.core.task_manager.models import TaskStatus from src.models.report import ReportStatus assert status_to_report_status(TaskStatus.SUCCESS) == ReportStatus.SUCCESS def test_failed_maps_to_failed(self): from src.services.reports.normalizer import status_to_report_status from src.core.task_manager.models import TaskStatus from src.models.report import ReportStatus assert status_to_report_status(TaskStatus.FAILED) == ReportStatus.FAILED def test_pending_maps_to_in_progress(self): from src.services.reports.normalizer import status_to_report_status from src.core.task_manager.models import TaskStatus from src.models.report import ReportStatus for status in [TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.AWAITING_INPUT, TaskStatus.AWAITING_MAPPING]: assert status_to_report_status(status) == ReportStatus.IN_PROGRESS def test_unknown_string_maps_to_partial(self): from src.services.reports.normalizer import status_to_report_status from src.models.report import ReportStatus assert status_to_report_status("UNKNOWN_STATUS") == ReportStatus.PARTIAL # #endregion Test.ReportNormalizer.TestStatusToReportStatus # #region Test.ReportNormalizer.TestBuildSummary [C:2] [TYPE Function] # @BRIEF Test build_summary extracts summary from task result. class TestBuildSummary: def test_uses_result_summary(self): from src.services.reports.normalizer import build_summary from src.models.report import ReportStatus from src.core.task_manager.models import Task, TaskStatus task = _make_task(result={"summary": "Migration of 5 dashboards completed"}) summary = build_summary(task, ReportStatus.SUCCESS) assert summary == "Migration of 5 dashboards completed" def test_uses_result_message(self): from src.services.reports.normalizer import build_summary from src.models.report import ReportStatus task = _make_task(result={"message": "All dashboards migrated"}) summary = build_summary(task, ReportStatus.SUCCESS) assert summary == "All dashboards migrated" def test_fallback_to_status_based_summary(self): from src.services.reports.normalizer import build_summary from src.models.report import ReportStatus task = _make_task(result=None) assert build_summary(task, ReportStatus.SUCCESS) == "Task completed successfully" assert build_summary(task, ReportStatus.FAILED) == "Task failed" assert build_summary(task, ReportStatus.IN_PROGRESS) == "Task is in progress" assert build_summary(task, ReportStatus.PARTIAL) == "Task completed with partial data" def test_prefers_summary_over_message(self): from src.services.reports.normalizer import build_summary from src.models.report import ReportStatus task = _make_task(result={"summary": "Summary text", "message": "Message text"}) assert build_summary(task, ReportStatus.SUCCESS) == "Summary text" def test_empty_summary_skips(self): from src.services.reports.normalizer import build_summary from src.models.report import ReportStatus task = _make_task(result={"summary": "", "message": "Actual message"}) assert build_summary(task, ReportStatus.SUCCESS) == "Actual message" # #endregion Test.ReportNormalizer.TestBuildSummary # #region Test.ReportNormalizer.TestExtractErrorContext [C:2] [TYPE Function] # @BRIEF Test extract_error_context for failed/partial reports. class TestExtractErrorContext: def test_returns_none_for_success(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task() assert extract_error_context(task, ReportStatus.SUCCESS) is None def test_returns_none_for_in_progress(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task() assert extract_error_context(task, ReportStatus.IN_PROGRESS) is None def test_extracts_from_error_dict(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task(result={"error": {"message": "Connection failed", "code": "ERR_CONNECT", "next_actions": ["Check network", "Retry"]}}) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "Connection failed" assert ctx.code == "ERR_CONNECT" assert ctx.next_actions == ["Check network", "Retry"] def test_falls_back_to_error_message_string(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task(result={"error_message": "Something went wrong"}) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "Something went wrong" def test_falls_back_to_logs(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus from src.core.task_manager.models import LogEntry task = _make_task( result={"other": "data"}, logs=[LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="Critical failure in module X", error="Critical failure in module X")], ) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "Critical failure in module X" def test_default_message_not_provided(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task(result={"unrelated": "data"}) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "Not provided" def test_default_next_actions(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task(result={"error": {"message": "Error without actions"}}) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.next_actions == ["Review task diagnostics", "Retry the operation"] def test_works_for_partial_status(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task(result={"error": {"message": "Partial failure"}}) ctx = extract_error_context(task, ReportStatus.PARTIAL) assert ctx is not None assert ctx.message == "Partial failure" # #endregion Test.ReportNormalizer.TestExtractErrorContext # #region Test.ReportNormalizer.TestNormalizeTaskReport [C:2] [TYPE Function] # @BRIEF Test normalize_task_report produces correct TaskReport. class TestNormalizeTaskReport: def test_returns_task_report(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import TaskReport, ReportStatus, TaskType task = _make_task() report = normalize_task_report(task) assert isinstance(report, TaskReport) assert report.report_id == task.id assert report.task_type == TaskType.MIGRATION assert report.status == ReportStatus.SUCCESS assert report.summary == "Task completed successfully" def test_includes_profile_details(self): from src.services.reports.normalizer import normalize_task_report task = _make_task() report = normalize_task_report(task) assert report.details is not None assert "profile" in report.details assert report.details["profile"]["display_label"] == "Migration" def test_includes_source_ref_from_params(self): from src.services.reports.normalizer import normalize_task_report task = _make_task(params={"environment_id": "env-1", "dashboard_id": "dash-42"}) report = normalize_task_report(task) assert report.source_ref is not None assert report.source_ref["environment_id"] == "env-1" assert report.source_ref["dashboard_id"] == "dash-42" def test_includes_error_context_for_failed(self): from src.services.reports.normalizer import normalize_task_report from src.core.task_manager.models import TaskStatus task = _make_task( status=TaskStatus.FAILED, result={"error": {"message": "Deployment failed", "code": "ERR_DEPLOY"}}, ) report = normalize_task_report(task) assert report.error_context is not None assert report.error_context.message == "Deployment failed" def test_unknown_plugin_type(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import TaskType task = _make_task(plugin_id="unknown-plugin") report = normalize_task_report(task) assert report.task_type == TaskType.UNKNOWN def test_no_updated_at_uses_started(self): from src.services.reports.normalizer import normalize_task_report from datetime import UTC, datetime task = _make_task(finished_at=None) report = normalize_task_report(task) assert report.updated_at == task.started_at def test_no_dates_uses_current_time(self): from src.services.reports.normalizer import normalize_task_report task = _make_task(started_at=None, finished_at=None) report = normalize_task_report(task) assert report.updated_at is not None def test_source_ref_none_when_no_params(self): from src.services.reports.normalizer import normalize_task_report task = _make_task(params={}) report = normalize_task_report(task) assert report.source_ref is None def test_none_result_becomes_note(self): from src.services.reports.normalizer import normalize_task_report task = _make_task(result=None) report = normalize_task_report(task) assert report.details is not None assert report.details["result"] == {"note": "Not provided"} def test_dict_result_included(self): from src.services.reports.normalizer import normalize_task_report task = _make_task(result={"migrated": 5, "errors": 0}) report = normalize_task_report(task) assert report.details is not None assert report.details["result"] == {"migrated": 5, "errors": 0} def test_include_result_false_omits_result(self): """List projection (include_result=False) never embeds task.result.""" from src.services.reports.normalizer import normalize_task_report task = _make_task(result={"migrated": 5, "errors": 0}) report = normalize_task_report(task, include_result=False) assert report.details is not None assert "result" not in report.details assert report.details["profile"]["display_label"] == "Migration" # #endregion Test.ReportNormalizer.TestNormalizeTaskReport # #region Test.ReportNormalizer.TestExtractErrorContextLogFallback [C:2] [TYPE Function] # @BRIEF Test extract_error_context log-scan fallback (reversed logs, case-insensitive levels). class TestExtractErrorContextLogFallback: def test_no_matching_error_log_uses_default(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus from src.core.task_manager.models import LogEntry task = _make_task( result={"other": "data"}, logs=[LogEntry(level="INFO", message="informational only")], ) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "Not provided" def test_error_log_level_is_case_insensitive(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus from src.core.task_manager.models import LogEntry task = _make_task( result={"other": "data"}, logs=[LogEntry(level="Error", trace_id="trace", src="plugin", marker="EXPLORE", intent="cased error", error="cased error")], ) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "cased error" def test_last_error_log_wins_in_reverse_order(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus from src.core.task_manager.models import LogEntry task = _make_task( result={"other": "data"}, logs=[ LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="first error", error="first error"), LogEntry(level="INFO", trace_id="trace", src="plugin", marker="REASON", intent="noise"), LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="last error", error="last error"), ], ) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "last error" def test_blank_error_message_falls_through_to_logs(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus from src.core.task_manager.models import LogEntry task = _make_task( result={"error": {"message": "", "code": "E1"}}, logs=[LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="log fallback hit", error="log fallback hit")], ) ctx = extract_error_context(task, ReportStatus.FAILED) assert ctx is not None assert ctx.message == "log fallback hit" assert ctx.code == "E1" def test_error_object_actions_strip_blank_entries(self): from src.services.reports.normalizer import extract_error_context from src.models.report import ReportStatus task = _make_task( result={"error": {"message": "m", "next_actions": [" ", "Fix it", ""]}} ) ctx = extract_error_context(task, ReportStatus.PARTIAL) assert ctx is not None assert ctx.next_actions == ["Fix it"] # #endregion Test.ReportNormalizer.TestExtractErrorContextLogFallback # #region Test.ReportNormalizer.TestNormalizeTaskReportLlmValidation [C:2] [TYPE Function] # @BRIEF Test llm_dashboard_validation success-with-problems override to FAILED/PARTIAL. class TestNormalizeTaskReportLlmValidation: def _llm_task(self, **result_overrides): from src.core.task_manager.models import TaskStatus result = {"fail_count": 0, "warn_count": 0, "status": "ok"} result.update(result_overrides) return _make_task( plugin_id="llm_dashboard_validation", status=TaskStatus.SUCCESS, result=result, ) def test_fail_count_downgrades_to_failed(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(fail_count=2)) assert report.status == ReportStatus.FAILED def test_raw_status_fail_downgrades_to_failed(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(status="FAIL")) assert report.status == ReportStatus.FAILED def test_warn_count_downgrades_to_partial(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(warn_count=3)) assert report.status == ReportStatus.PARTIAL def test_raw_status_warn_downgrades_to_partial(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(status="WARN")) assert report.status == ReportStatus.PARTIAL def test_benign_result_stays_success(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task()) assert report.status == ReportStatus.SUCCESS def test_non_int_counts_stay_success(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(fail_count="many", warn_count="many")) assert report.status == ReportStatus.SUCCESS def test_none_result_stays_success(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus from src.core.task_manager.models import TaskStatus task = _make_task( plugin_id="llm_dashboard_validation", status=TaskStatus.SUCCESS, result=None, ) report = normalize_task_report(task) assert report.status == ReportStatus.SUCCESS def test_fail_count_takes_priority_over_warn_count(self): from src.services.reports.normalizer import normalize_task_report from src.models.report import ReportStatus report = normalize_task_report(self._llm_task(fail_count=1, warn_count=5)) assert report.status == ReportStatus.FAILED # #endregion Test.ReportNormalizer.TestNormalizeTaskReportLlmValidation # #region Test.ReportNormalizer.TestNormalizeTaskReportSourceRef [C:2] [TYPE Function] # @BRIEF Test normalize_task_report source_ref when params is not a dict. class TestNormalizeTaskReportSourceRef: def test_params_not_dict_yields_none_source_ref(self): from src.services.reports.normalizer import normalize_task_report from src.core.task_manager.models import Task, TaskStatus task = Task.model_construct( id="task-1", plugin_id="superset-migration", status=TaskStatus.SUCCESS, params=None, # bypasses Pydantic validation via model_construct result=None, started_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), finished_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=UTC), logs=[], ) report = normalize_task_report(task) assert report.source_ref is None assert report.report_id == "task-1" # #endregion Test.ReportNormalizer.TestNormalizeTaskReportSourceRef # #endregion Test.ReportNormalizer