Files
next-tjwater-server/tests/unit/test_mcp_work_order_skills.py
T
jiang b45c5c6f46
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s
feat(investigations): refine report authoring and replay flushing
2026-11-14 23:00:15 +08:00

160 lines
8.8 KiB
Python

import asyncio
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
from app.mcp.work_order_skills import read_work_order_skill
@pytest.mark.parametrize("workflow,name", [
("burst_report", "burst-event-handling"),
("water_quality_complaint", "water-quality-complaint"),
("general", "appointment-follow-up"),
("scada_anomaly", "scada-anomaly"),
("water_pollution", "water-pollution-event"),
])
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
descriptor = read_work_order_skill(workflow)
body = read_work_order_skill(workflow, include_content=True)
assert descriptor == {key: value for key, value in body.items() if key != "instructions"}
assert body["name"] == name and body["version"]
assert len(body["content_sha256"]) == 64 and body["instructions"]
assert "instructions" not in descriptor
# MCP task context has no evidence resource fallback: the complete instructions
# and result envelope must fit its inline response budget.
assert len(json.dumps(body, ensure_ascii=False).encode()) < 10000
@pytest.mark.parametrize("workflow", [None, "unknown", "../../secrets", "burst-report"])
def test_unknown_workflow_never_falls_back_or_reads_an_arbitrary_path(workflow):
assert read_work_order_skill(workflow)["available"] is False
def test_work_order_handling_and_legacy_scada_replay_receive_separate_skills():
handling = read_work_order_skill("burst_report", include_content=True)
replay = read_work_order_skill("burst_report", replay_context={"replay_id": "r"}, include_content=True)
assert handling["name"] == "burst-event-handling"
assert replay["name"] == "scada-anomaly"
assert replay["workflow"] == "burst_report"
assert "影响说明" in handling["instructions"]
assert "supply_affected_nodes" in handling["instructions"]
assert "run_burst_location" not in handling["instructions"]
assert "run_burst_location" in replay["instructions"]
def test_scada_instructions_advance_candidate_analysis_independently_of_source_labels():
skill = read_work_order_skill("scada_anomaly", include_content=True)
text = skill["instructions"]
assert "location_capability" in text
assert "证据来源字段用于记录数据取得方式,不构成停止分析的条件" in text
assert "隔离边界、节点覆盖与影响分析,完成报告后再收尾" in text
assert "现场工单与设备操作遵守服务端动作权限" in text
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the_investigation():
skill = read_work_order_skill("water_pollution", include_content=True)
text = skill["instructions"]
assert text.index("propose_task_action") < text.index("run_contaminant_simulation")
assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text
assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text
assert "模型曲线不填观测专用 chart.scada" in text
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text
def test_pollution_workflow_distinguishes_protection_flushing_and_clearance():
skill = read_work_order_skill("water_pollution", include_content=True)
text = skill["instructions"]
assert skill["version"] == "1.2.0"
assert text.index("## 4. 源头控制与用户隔离") < text.index("## 5. 污染物清除与分步冲洗")
assert "opened_valves" in text and "closed_valves" in text
assert "不继承上次模拟" in text and "排放去向" in text
assert all(stage in text for stage in ("source_control", "flushing_plan", "clearance_verification"))
assert "不能证明污染物已清除" in text and "结果未通过时保持 interim" in text
@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"])
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
task_id, project_id = uuid4(), uuid4()
reader = AsyncMock(return_value={"task": {"workflow": workflow}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
scope = SimpleNamespace(connection=object(), project=object())
binding = BoundContext(
context_handle="a" * 43, project_id=project_id, subject="u", client_id="host",
native_session_id="s", purpose="task_execution", task_id=task_id,
tool_cap=["get_task_context"], permission_cap=["operational_task.view"],
expires_at=datetime.now(timezone.utc),
)
def query(section):
return asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
TaskContextInput(context_handle=binding.context_handle, section=section)))
summary = query("summary")
body = query("skill")
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
assert summary.data["display_time_zone"] == "Asia/Shanghai"
assert body.data["skill"]["workflow"] == workflow
for result in (summary, body):
assert ("source_trace_capability" in result.data) == (workflow == "water_pollution")
if workflow == "water_pollution":
assert result.data["source_trace_capability"]["tool"] == "run_contaminant_source_trace"
capability = result.data["source_trace_capability"]
assert capability["work_order_wait_blocks_analysis"] is False
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
assert body.data["skill"]["instructions"] and not body.truncated
assert len(json.dumps(body.model_dump(mode="json"), ensure_ascii=False).encode()) < 10000
assert body.evidence_id is None and body.semantics == "task"
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
binding.permission_cap = []
with pytest.raises(HTTPException) as caught:
query("skill")
assert caught.value.status_code == 403 and reader.await_count == 2
@pytest.mark.parametrize("extra", [{"workflow": "general"}, {"data_path": ["instructions"]}, {"offset": 1}])
def test_skill_input_cannot_select_another_workflow_or_partial_instructions(extra):
with pytest.raises(ValueError):
TaskContextInput(context_handle="a" * 43, section="skill", **extra)
def test_replay_context_advertises_demo_location_without_revealing_truth(monkeypatch):
from app.services import burst_location_demo as demo
monkeypatch.setattr(query_service.reads, "task_context_summary", AsyncMock(
return_value={"task": {"workflow": "scada_anomaly"}}))
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"operational_task.view"})
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2"))
binding = BoundContext(
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
native_session_id="s", purpose="task_execution", task_id=uuid4(),
tool_cap=["get_task_context", "run_burst_location"], permission_cap=["operational_task.view"],
expires_at=datetime.now(timezone.utc),
replay_context={"replay_id": "r", "knowledge_cutoff": demo.EVENT_START.isoformat(), "batch_sequence": 3},
)
for section in ("summary", "skill"):
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
TaskContextInput(context_handle=binding.context_handle, section=section)))
capability = result.data["location_capability"]
assert "location_demo" not in result.data
assert capability["available"] is True
assert capability["tool"] == "run_burst_location"
assert capability["burst_leakage_required"] is False
assert datetime.fromisoformat(capability["scada_burst_end"]) <= demo.EVENT_START
assert all(key not in json.dumps(capability) for key in (demo.REFERENCE_PIPE, demo.REFERENCE_NODE, demo.CANDIDATE_PIPE))
binding.replay_context["knowledge_cutoff"] = "2026-07-15T23:45:00Z"
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
TaskContextInput(context_handle=binding.context_handle)))
assert result.data["location_capability"]["available"] is False