from copy import deepcopy import json import pytest from pydantic import ValidationError from app.domain.schemas.operational_task import InvestigationReportInput, InvestigationReportRevision def rich_report(): return { "title": "演练压力核验", "current_conclusion": "待核验", "conclusion_boundary": "演练记录", "stage": "interim", "outcome": "concluded", "data_gaps": [], "evidence_refs": ["demo-1"], "ui_blocks": [{ "evidence_refs": ["demo-1"], "block": { "version": "answer-ui@1", "block_id": "pressure", "fallback_text": "演练压力 0.27 MPa", "spec": {"root": "chart", "elements": { "chart": {"type": "Chart", "children": [], "props": { "title": "演练压力", "chart_type": "line", "x_data": ["08:00", "09:00"], "series": [{"name": "压力 MPa", "data": [0.3, 0.27]}], "source": "演练记录", }}, }}, }, }], } def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable(): report = InvestigationReportInput.model_validate(rich_report()) snapshot = InvestigationReportRevision( revision=3, execution_generation=2, source_session_id="test", published_at="2026-10-20T08:00:00Z", published_by="test", report=report, ) restored = InvestigationReportRevision.model_validate_json(snapshot.model_dump_json()) assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27] assert (restored.revision, restored.execution_generation) == (3, 2) old = json.loads(snapshot.model_dump_json()) del old["report"]["ui_blocks"] assert InvestigationReportRevision.model_validate(old).report.ui_blocks == [] @pytest.mark.parametrize("phase", [None, "final"]) def test_report_ui_accepts_final_and_legacy_phases(phase): data = rich_report() if phase is not None: data["ui_blocks"][0]["block"]["phase"] = phase report = InvestigationReportInput.model_validate(data) assert report.ui_blocks[0].block.phase == phase serialized = report.model_dump()["ui_blocks"][0]["block"] assert ("phase" in serialized) == (phase is not None) assert InvestigationReportInput.model_validate(report.model_dump()).ui_blocks[0].block.phase == phase @pytest.mark.parametrize("phase", ["process", "unknown", None]) def test_report_ui_rejects_process_and_unknown_phases(phase): data = rich_report() data["ui_blocks"][0]["block"]["phase"] = phase with pytest.raises(ValidationError): InvestigationReportInput.model_validate(data) @pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"]) def test_report_ui_rejects_invalid_or_unbound_content(defect): data = rich_report() entry = data["ui_blocks"][0] spec = entry["block"]["spec"] chart = spec["elements"]["chart"] if defect == "unknown": chart["type"] = "iframe" elif defect == "script": chart["props"]["onClick"] = "execute()" elif defect == "unaligned": chart["props"]["series"][0]["data"] = [1] elif defect == "nan": chart["props"]["series"][0]["data"][0] = float("nan") elif defect == "cycle": spec["elements"]["chart"] = {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": ["chart"]} elif defect == "unreachable": spec["elements"]["unused"] = deepcopy(chart) elif defect == "duplicate": data["ui_blocks"].append(deepcopy(entry)) elif defect == "foreign": entry["evidence_refs"] = ["other-task"] elif defect == "many": data["ui_blocks"] = [{**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": f"b{i}"}} for i in range(9)] elif defect == "oversized": # Individually valid blocks can still exceed the UTF-8 report budget. elements = {"root": {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": [f"n{i}" for i in range(20)]}} elements.update({f"n{i}": {"type": "Text", "props": {"text": "资" * 3900, "tone": "neutral"}, "children": []} for i in range(20)}) entry["block"]["spec"] = {"root": "root", "elements": elements} data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}] with pytest.raises(ValidationError): InvestigationReportInput.model_validate(data) def test_report_ui_accepts_semantic_results_without_allowing_raw_presentation_code(): data = rich_report() spec = data["ui_blocks"][0]["block"]["spec"] spec["root"] = "layout" spec["elements"] = { "layout": {"type": "Stack", "props": {"columns": "one", "gap": "normal", "intent": "report", "density": "compact"}, "children": ["section"]}, "section": {"type": "Section", "props": {"title": "演练结果", "description": None, "role": "result"}, "children": ["result", "note"]}, "result": {"type": "Result", "props": {"title": "演练记录已保存", "summary": "现场处置仍待核验。", "status": "partial", "source": "演练回执"}, "children": []}, "note": {"type": "Text", "props": {"title": "核验说明", "text": "结果仅限演练。", "tone": "neutral"}, "children": []}, } restored = InvestigationReportInput.model_validate(data) assert restored.ui_blocks[0].block.spec.elements["result"].props["status"] == "partial" for extra in ({"style": {"color": "red"}}, {"className": "text-red-500"}, {"css": "display:none"}, {"action": "dispatch"}): invalid = deepcopy(data) invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["props"].update(extra) with pytest.raises(ValidationError): InvestigationReportInput.model_validate(invalid) invalid = deepcopy(data) invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["children"] = ["note"] with pytest.raises(ValidationError): InvestigationReportInput.model_validate(invalid)