feat(investigations): refine report authoring and replay flushing
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.mcp.analysis_models import FlushingInput
|
||||
from app.mcp.extended_models import SavedScenarioInput
|
||||
from app.mcp.mutation_service import saved_scenario
|
||||
from app.mcp.scenario_service import calculate_scenario, flushing_valve_controls
|
||||
|
||||
|
||||
def parameters(**changes):
|
||||
return {"context_handle": "a" * 43, "start_time": datetime(2026, 7, 16, tzinfo=timezone.utc),
|
||||
"duration": 1800, "output_node_ids": ["J1"], "drainage_node_id": "J2",
|
||||
"flushing_flow_m3h": 12, **changes}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"closed_valves": ["V1"], "opened_valves": ["V1"]},
|
||||
{"closed_valves": ["V1", "V1"]},
|
||||
{"opened_valves": ["V1", "V1"]},
|
||||
])
|
||||
def test_conflicting_or_duplicate_valves_are_rejected_for_preview_and_saved_scenarios(changes):
|
||||
values = parameters(**changes)
|
||||
with pytest.raises(ValidationError):
|
||||
FlushingInput.model_validate(values)
|
||||
with pytest.raises(ValidationError):
|
||||
SavedScenarioInput.model_validate({"context_handle": values.pop("context_handle"),
|
||||
"request_id": uuid4(), "name": "冲洗", "scenario": "flushing", "parameters": values})
|
||||
|
||||
|
||||
def test_preview_and_saved_flushing_apply_identical_open_and_closed_states(monkeypatch):
|
||||
from app.services import simulation_scenarios
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(simulation_scenarios, "flushing_analysis", lambda **kw: calls.append(kw) or {
|
||||
"node_results": [{"node": "J1", "result": []}], "link_results": []})
|
||||
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"})
|
||||
args = FlushingInput.model_validate(parameters(opened_valves=["V-in"], closed_valves=["V-user"]))
|
||||
output = calculate_scenario("analyze_flushing", args.model_dump(exclude={"context_handle", "timeout_seconds"}),
|
||||
network="owned", username="actor")
|
||||
values = SavedScenarioInput.model_validate({"context_handle": args.context_handle,
|
||||
"request_id": uuid4(), "name": "分区冲洗", "scenario": "flushing",
|
||||
"parameters": args.model_dump(exclude={"context_handle", "timeout_seconds"})})
|
||||
saved_scenario("owned", "actor", values)
|
||||
for call in calls:
|
||||
assert call["valve_control"] == {"V-in": {"status": "OPEN"}, "V-user": {"status": "CLOSED"}}
|
||||
assert "modify_valve_opening" not in call
|
||||
assert call["drainage_node_ID"] == "J2" and call["flushing_flow"] == 12
|
||||
assert calls[0]["persist_results"] is False and calls[1]["persist_results"] is True
|
||||
assert output["flushing_plan"] == {
|
||||
"opened_valves": ["V-in"], "closed_valves": ["V-user"], "drainage_node_id": "J2",
|
||||
"flushing_flow_m3h": 12, "estimated_discharge_volume_m3": 6,
|
||||
"state_basis": "independent_current_model_scenario", "result_scope": "hydraulic_only",
|
||||
"boundary_source": "model_with_start_time_scada",
|
||||
"field_execution_verified": False, "water_quality_clearance_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_closed_only_inputs_remain_valid():
|
||||
args = FlushingInput.model_validate(parameters(closed_valves=["V1"]))
|
||||
assert args.opened_valves == []
|
||||
assert flushing_valve_controls(args.model_dump()) == {"V1": {"status": "CLOSED"}}
|
||||
|
||||
|
||||
def test_explicit_opening_reopens_a_closed_valve_without_overwriting_its_setting(monkeypatch):
|
||||
from app.services import simulation
|
||||
|
||||
model = {"V-in": {"link": "V-in", "status": "CLOSED", "setting": 15},
|
||||
"V-user": {"link": "V-user", "status": "ACTIVE", "setting": 23}}
|
||||
monkeypatch.setattr(simulation, "get_status", lambda project, valve: dict(model[valve]))
|
||||
monkeypatch.setattr(simulation, "set_status", lambda project, changes: model.update({
|
||||
row["link"]: row for row in changes.operations}))
|
||||
simulation._apply_valve_control("temporary", flushing_valve_controls({
|
||||
"opened_valves": ["V-in"], "closed_valves": ["V-user"]}))
|
||||
assert model["V-in"] == {"link": "V-in", "status": "OPEN", "setting": 15}
|
||||
assert model["V-user"] == {"link": "V-user", "status": "CLOSED", "setting": 23}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""New publications are readable; historical reports keep their original evidence."""
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.schemas.operational_task import (
|
||||
InvestigationProgress,
|
||||
InvestigationProgressInput,
|
||||
InvestigationReport,
|
||||
InvestigationReportInput,
|
||||
)
|
||||
|
||||
|
||||
def report():
|
||||
return {
|
||||
"title": "压力异常恢复复核报告",
|
||||
"current_conclusion": "恢复复核通过。7 月 16 日 13:15,13 台复核设备均处于历史正常范围。",
|
||||
"conclusion_boundary": "依据完工后的一个采样时刻判断恢复,长期稳定性继续由日常监测跟踪。",
|
||||
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||
"evidence_refs": ["recovery:sha256:stored-evidence"],
|
||||
"recommendations": ["继续监测压力与流量。"], "pending_items": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"恢复复核 status=passed,13 台设备 normal。",
|
||||
"第 3 轮 execution_generation=3,handling_context.phase=verification_ready。",
|
||||
"完工后 required_samples=1,interval_seconds=900,verified_through=13:15。",
|
||||
"已读取 recovery:sha256:01234567,复核通过。",
|
||||
"Skill scada-anomaly v1.0.16 已按 Server 分发执行。",
|
||||
"query_scada_readings 已执行,query_complete=true。",
|
||||
"关联工单 60000000-0000-4000-8000-000000000002 已完工。",
|
||||
"恢复采样时间为 2026-07-16T05:15:00Z。",
|
||||
"恢复采样时间为 2026-07-16 13:15(UTC+8)。",
|
||||
"恢复采样时间为 2026-07-16 13:15+08:00。",
|
||||
"**恢复复核通过**,其余设备正常。",
|
||||
"<strong>恢复复核通过</strong>。",
|
||||
])
|
||||
def test_new_report_and_progress_reject_execution_details_and_raw_display_markup(text):
|
||||
legacy = {**report(), "current_conclusion": text}
|
||||
assert InvestigationReport.model_validate(legacy).current_conclusion == text
|
||||
assert InvestigationProgress.model_validate({"summary": text}).summary == text
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(legacy)
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationProgressInput.model_validate({"summary": text})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "limit"), [
|
||||
("title", 80), ("current_conclusion", 300), ("conclusion_boundary", 240),
|
||||
])
|
||||
def test_new_report_bounds_summary_fields_without_truncating_history(field, limit):
|
||||
valid = {**report(), field: "测" * limit}
|
||||
assert getattr(InvestigationReportInput.model_validate(valid), field) == valid[field]
|
||||
too_long = {**valid, field: "测" * (limit + 1)}
|
||||
assert getattr(InvestigationReport.model_validate(too_long), field) == too_long[field]
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(too_long)
|
||||
|
||||
|
||||
def test_progress_summary_is_short_and_keeps_technical_references_structured():
|
||||
progress = {"summary": "测" * 300, "graph_update": {"nodes": [{
|
||||
"node_id": "recovery", "label": "恢复监测复核通过", "status": "recorded",
|
||||
"evidence_refs": ["recovery:sha256:stored-evidence"],
|
||||
}]}}
|
||||
assert InvestigationProgressInput.model_validate(progress).summary == progress["summary"]
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationProgressInput.model_validate({**progress, "summary": "测" * 301})
|
||||
|
||||
|
||||
def test_asset_ids_measurements_and_evidence_linked_tables_remain_publishable():
|
||||
value = report()
|
||||
value["current_conclusion"] = "05:00-08:00 的候选管段 515870(DN600)相似度 97.08%,尚待现场核验。压力测点 170486 回升至 26.12 m。"
|
||||
value["ui_blocks"] = [{"evidence_refs": value["evidence_refs"], "block": {
|
||||
"version": "answer-ui@1", "block_id": "recovery-table", "phase": "final",
|
||||
"fallback_text": "压力测点 170486 恢复至 26.12 m。",
|
||||
"spec": {"root": "table", "elements": {"table": {
|
||||
"type": "Table", "children": [], "props": {
|
||||
"title": "恢复监测明细", "columns": ["测点", "修复后", "正常范围"],
|
||||
"rows": [["170486", "26.12 m", "26.02 至 26.30 m"]],
|
||||
},
|
||||
}}},
|
||||
}}]
|
||||
assert InvestigationReportInput.model_validate(value).ui_blocks
|
||||
leaked = deepcopy(value)
|
||||
leaked["ui_blocks"][0]["block"]["spec"]["elements"]["table"]["props"]["rows"][0][2] = "status=passed"
|
||||
assert InvestigationReport.model_validate(leaked).ui_blocks
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(leaked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["recommendations", "pending_items"])
|
||||
def test_follow_up_is_business_prose(field):
|
||||
value = {**report(), field: ["等待 handling_context.phase=waiting_scada。"]}
|
||||
with pytest.raises(ValueError):
|
||||
InvestigationReportInput.model_validate(value)
|
||||
@@ -70,6 +70,17 @@ def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the
|
||||
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()
|
||||
@@ -102,6 +113,7 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch
|
||||
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 = []
|
||||
|
||||
@@ -2363,3 +2363,77 @@ async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation
|
||||
await getattr(other, method)(task["task_id"], command)
|
||||
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
|
||||
assert repository.task == before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("stage", ["asset_localization", "source_control", "flushing_plan", "clearance_verification"])
|
||||
async def test_presentation_narration_is_evidence_backed_saved_and_replayed_without_mutating_task(stage):
|
||||
now = datetime(2026, 11, 14, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = TaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
evidence, _ = await service.record_data_snapshot(task['task_id'], TaskDataSnapshot(
|
||||
source='query_scada_readings', source_ref='observations', queried_at=now,
|
||||
origin='synthetic', data={'items': []}, truncated=False,
|
||||
))
|
||||
narration = {'stage': stage, 'summary': '本阶段保存的候选方案及核验依据。',
|
||||
'evidence_refs': [evidence.evidence_id], 'next_step': '提出现场工单草稿,并行开展影响分析。'}
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||
command={'action': 'locate_features', 'layer': 'pipes', 'feature_ids': ['P1']}, narration=narration,
|
||||
purpose='核对候选管段与异常测点的空间关系,供后续影响分析。',
|
||||
)
|
||||
result = await service.publish_presentation_event(task['task_id'], command)
|
||||
assert repository.events[-1]['metadata']['narration'] == narration
|
||||
assert repository.events[-1]['metadata']['purpose'] == command.purpose
|
||||
assert await service.publish_presentation_event(task['task_id'], command) == result
|
||||
page = await service.presentation_history(task['task_id'])
|
||||
assert page.items[-1].narration.model_dump(exclude_none=True) == narration
|
||||
assert page.items[-1].purpose == command.purpose
|
||||
assert repository.task['revision'] == task['revision']
|
||||
with pytest.raises(DomainError) as conflict:
|
||||
await service.publish_presentation_event(task['task_id'], command.model_copy(update={
|
||||
'narration': command.narration.model_copy(update={'summary': '不同判断'}),
|
||||
}))
|
||||
assert conflict.value.code == 'IDEMPOTENCY_CONFLICT'
|
||||
foreign = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run['run_id'], source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'}, narration={**narration, 'evidence_refs': ['other-task']},
|
||||
)
|
||||
before = len(repository.events)
|
||||
with pytest.raises(DomainError) as rejected:
|
||||
await service.publish_presentation_event(task['task_id'], foreign)
|
||||
assert rejected.value.code == 'EVIDENCE_NOT_FOUND'
|
||||
assert len(repository.events) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [
|
||||
{'stage': 'invented_stage'}, {'summary': 'x' * 161}, {'summary': ' '},
|
||||
{'evidence_refs': []}, {'evidence_refs': ['e1', 'e1']}, {'next_step': 'x' * 121},
|
||||
])
|
||||
def test_presentation_narration_rejects_unbounded_or_unsubstantiated_fields(changes):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'},
|
||||
narration={'stage': 'evidence_verification', 'summary': '测点异常待核验',
|
||||
'evidence_refs': ['e1'], **changes},
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_presentation_idempotency_body_does_not_gain_an_empty_narration_field():
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'clear_highlights'},
|
||||
)
|
||||
assert 'narration' not in command.model_dump(mode='json')
|
||||
assert 'purpose' not in command.model_dump(mode='json')
|
||||
|
||||
@pytest.mark.parametrize('purpose', ['', ' ', 'x' * 161, 123])
|
||||
def test_presentation_purpose_rejects_invalid_intent(purpose):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=uuid4(), source_session_id='session-1',
|
||||
command={'action': 'zoom_to_map', 'center': [121, 31], 'zoom': 15}, purpose=purpose,
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulat
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={**report,
|
||||
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
|
||||
"pending_items": [f"等待现场工单反馈,action_id={action.action_id}"]}))
|
||||
"pending_items": ["等待现场核查和采样处置工单反馈"]}))
|
||||
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
|
||||
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Presentation prose is validated on writes without rejecting historical scenes."""
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskPresentationHistoryItem
|
||||
|
||||
|
||||
def presentation():
|
||||
return {
|
||||
"request_id": uuid4(), "run_id": uuid4(), "source_session_id": "fixture-session",
|
||||
"purpose": "核对压力变化与异常时刻的对应关系。",
|
||||
"narration": {"stage": "evidence_verification", "summary": "压力已回升,恢复情况仍需结合现场反馈复核。",
|
||||
"next_step": "核对完工后的恢复监测。", "evidence_refs": ["recovery:sha256:fixture"]},
|
||||
"command": {"action": "open_chart", "chart_id": "pressure", "evidence_refs": ["recovery:sha256:fixture"],
|
||||
"chart": {"title": "170486 压力恢复趋势", "chart_type": "line", "x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)", "x_data": ["13:00", "13:15"],
|
||||
"series": [{"name": "170486 当前压力", "data": [20.09, 26.12],
|
||||
"sample_times": ["2026-07-16T05:00:00Z", "2026-07-16T05:15:00Z"]}]}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"status=passed,required_samples=1。", "Skill scada-anomaly v1.0.16 已执行。",
|
||||
"已读取 recovery:sha256:01234567。", "恢复时刻 2026-07-16T05:15:00Z。",
|
||||
"恢复时刻 13:15(UTC+8)。", "**恢复复核通过**。",
|
||||
])
|
||||
def test_presentation_rejects_execution_text_in_purpose_finding_and_follow_up(text):
|
||||
value = presentation()
|
||||
for field in ("purpose", "summary", "next_step"):
|
||||
changed = deepcopy(value)
|
||||
if field == "purpose":
|
||||
changed[field] = text
|
||||
else:
|
||||
changed["narration"][field] = text
|
||||
history = TaskPresentationHistoryItem(
|
||||
event_seq=1, run_id=changed["run_id"], execution_generation=1,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=timezone.utc),
|
||||
command=changed["command"], purpose=changed["purpose"], narration=changed["narration"],
|
||||
)
|
||||
assert history.purpose == changed["purpose"]
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(changed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["title", "x_axis_name", "y_axis_name", "x_data", "series"])
|
||||
def test_new_chart_visible_labels_follow_the_same_prose_contract(field):
|
||||
value = presentation()
|
||||
chart = value["command"]["chart"]
|
||||
if field == "x_data":
|
||||
chart[field][0] = "2026-07-16T05:00:00Z"
|
||||
elif field == "series":
|
||||
chart[field][0]["name"] = "execution_generation=3"
|
||||
else:
|
||||
chart[field] = "压力复核 status=passed"
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(value)
|
||||
|
||||
|
||||
def test_display_validation_preserves_real_timestamps_identifiers_and_values():
|
||||
value = presentation()
|
||||
parsed = TaskPresentationEventCommand.model_validate(value).model_dump(mode="json")
|
||||
assert parsed["command"] == value["command"]
|
||||
assert parsed["narration"]["evidence_refs"] == value["narration"]["evidence_refs"]
|
||||
value["command"] = {"action": "set_overlay", "overlay_id": "estimated-impact",
|
||||
"title": "候选管段预计影响范围", "mode": "geometry", "evidence_id": "mcp:fixture"}
|
||||
assert TaskPresentationEventCommand.model_validate(value).command.title == "候选管段预计影响范围"
|
||||
value["command"]["title"] = "候选影响范围(UTC+8)"
|
||||
with pytest.raises(ValueError):
|
||||
TaskPresentationEventCommand.model_validate(value)
|
||||
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.mcp import analysis_service, query_service
|
||||
from app.mcp.analysis_models import FlushingInput
|
||||
from app.mcp.calculation_jobs import should_queue
|
||||
from app.mcp.catalog import TOOLS
|
||||
|
||||
|
||||
def parameters(**changes):
|
||||
return FlushingInput.model_validate({"context_handle": "a" * 43,
|
||||
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
||||
"output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 12,
|
||||
"opened_valves": ["V-in"], "closed_valves": ["V-user"], **changes})
|
||||
|
||||
|
||||
def context(monkeypatch):
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
|
||||
|
||||
@asynccontextmanager
|
||||
async def routing():
|
||||
yield
|
||||
|
||||
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
|
||||
project=SimpleNamespace(project_code="owned", project_role="member"), user=SimpleNamespace(username="actor"),
|
||||
simulation_routing=routing)
|
||||
binding = SimpleNamespace(tool_cap=["analyze_flushing"], permission_cap=["simulation.run", "scada.view"],
|
||||
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
|
||||
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||
return scope, binding
|
||||
|
||||
|
||||
def test_replay_flushing_uses_the_model_only_adapter_and_retains_prediction_boundary(monkeypatch):
|
||||
scope, binding = context(monkeypatch)
|
||||
worker = AsyncMock(return_value={"flushing_plan": {"water_quality_clearance_verified": False}})
|
||||
monkeypatch.setattr(analysis_service, "calculate_replay_flushing", worker)
|
||||
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["analyze_flushing"], parameters()))
|
||||
assert result.origin == "synthetic" and "预测" in result.data_gaps[0]
|
||||
assert result.data["result"]["flushing_plan"]["water_quality_clearance_verified"] is False
|
||||
assert result.data["parameters"]["opened_valves"] == ["V-in"]
|
||||
scope.connection.commit.assert_awaited_once()
|
||||
worker.assert_awaited_once()
|
||||
assert worker.await_args.kwargs == {"network": "owned", "username": "actor"}
|
||||
assert should_queue(TOOLS["analyze_flushing"], parameters())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
|
||||
def test_replay_flushing_preserves_time_permissions_and_reader_boundaries(monkeypatch, problem):
|
||||
scope, binding = context(monkeypatch)
|
||||
args = parameters()
|
||||
if problem == "future_start": args = parameters(start_time="2026-07-16T08:15:00+08:00")
|
||||
if problem == "reader": binding.purpose = "task_reader"
|
||||
if problem == "permission": binding.permission_cap.remove("scada.view")
|
||||
if problem == "tool_cap": binding.tool_cap.clear()
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["analyze_flushing"], args))
|
||||
assert caught.value.status_code == (422 if problem == "future_start" else 403)
|
||||
|
||||
|
||||
def test_model_only_flushing_and_native_runner_both_receive_empty_scada_mappings(monkeypatch):
|
||||
from app.services import simulation_scenarios as scenarios
|
||||
|
||||
captures = []
|
||||
native = scenarios.flushing_analysis.__wrapped__
|
||||
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"})
|
||||
|
||||
def isolated(**kwargs):
|
||||
captures.append(kwargs)
|
||||
return native(**kwargs, _temporary_project="temporary")
|
||||
|
||||
monkeypatch.setattr(scenarios, "flushing_analysis", isolated)
|
||||
monkeypatch.setattr(scenarios, "is_junction", lambda *_: True)
|
||||
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
|
||||
monkeypatch.setattr(scenarios, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "00:15:00"})
|
||||
monkeypatch.setattr(scenarios, "get_demand", lambda *_: {"demands": []})
|
||||
for name in ("add_pattern", "set_demand", "set_option"):
|
||||
monkeypatch.setattr(scenarios, name, lambda *_: None)
|
||||
monkeypatch.setattr(scenarios.simulation, "run_simulation", lambda **kw: captures.append(kw) or {
|
||||
"node_results": [], "link_results": []})
|
||||
output = asyncio.run(analysis_service.calculate_replay_flushing(parameters(), network="owned", username="actor"))
|
||||
assert len(captures) == 2
|
||||
assert captures[0]["scada_mappings"] is captures[1]["scada_mappings"]
|
||||
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
|
||||
assert captures[1]["name"] == "temporary" and captures[1]["persist_results"] is False
|
||||
assert output["flushing_plan"]["boundary_source"] == "model_only_no_live_scada"
|
||||
@@ -603,15 +603,20 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
|
||||
|
||||
command = create_task.await_args.args[0]
|
||||
assert command.workflow == "scada_anomaly"
|
||||
assert command.title == "SCADA 异常调查 · 2026-10-08 08:15"
|
||||
assert "2 个压力测点下降" in command.summary
|
||||
assert "1 个出厂边界流量测点增加" in command.summary
|
||||
assert "爆管嫌疑" in command.summary
|
||||
assert command.source.replay_context.batch_sequence == 8
|
||||
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
|
||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||
assert document["monitoring_incident"]["alert"]["condition_id"] == f"operational-task:{monitoring['task_id']}"
|
||||
assert document["monitoring_incident"]["alert"]["description"] == "已启动调查,详细诊断见调查报告。"
|
||||
if weighted:
|
||||
assert "-0.0843" in command.summary
|
||||
assert "综合评分 0.90" in command.summary
|
||||
assert "流量 10%" in command.summary
|
||||
assert "-0.0843" not in command.summary
|
||||
assert "综合评分" not in command.summary
|
||||
assert "10%" not in command.summary
|
||||
assert monitoring["weighted"]["score"] == 0.9
|
||||
|
||||
|
||||
def test_monitoring_summary_counts_signals_without_copying_reading_details(monkeypatch):
|
||||
@@ -916,8 +921,10 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
|
||||
command = create_task.await_args.args[0]
|
||||
assert command.workflow == "scada_anomaly"
|
||||
assert command.source.event_kind == "scada_anomaly"
|
||||
assert "任一指标异常即启动调查" in command.summary
|
||||
assert "爆管嫌疑门槛 0.80" in command.summary
|
||||
assert "已启动调查" in command.summary
|
||||
assert "异常原因及影响范围待核查" in command.summary
|
||||
assert "爆管嫌疑门槛" not in command.summary
|
||||
assert monitoring["weighted"]["score"] == 0.1
|
||||
assert monitoring["task_id"] == str(create_task.return_value.task_id)
|
||||
assert monitoring["incident"]["action"] == "raised"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user