Files
next-tjwater-server/tests/unit/test_burst_location_demo.py
T
jiang 01bad116c1
Generic Container CI/CD / test-build-publish (push) Successful in 5m25s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m26s
fix(scada): align location capability and investigation guidance
2026-11-07 20:14:24 +08:00

228 lines
12 KiB
Python

import asyncio
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.mcp import calculation_jobs, query_service
from app.mcp.analysis_models import BurstLocationInput
from app.mcp.catalog import TOOLS
from app.services import burst_location_demo as demo
def arguments(**changes):
return BurstLocationInput.model_validate({
"context_handle": "x" * 43,
"burst_leakage": 0.02,
"scada_burst_start": demo.EVENT_START - timedelta(minutes=15),
"scada_burst_end": demo.EVENT_START,
**changes,
})
def topology():
def link(key, kind, a, b, length=0.0, diameter=None):
return {"id": key, "kind": kind, "start_node_id": a, "end_node_id": b,
"length": length, "diameter": diameter, "status": "OPEN"}
return [
link(demo.REFERENCE_PIPE, "pipe", "170438", "J1", 350, 600),
link(demo.CANDIDATE_PIPE, "pipe", "J1", "J2", 30, 600),
link("V1", "valve", "S", "170438"),
link("V2", "valve", "J2", "S"),
{"id": "S", "kind": "reservoir", "start_node_id": None},
]
def test_candidate_is_on_same_line_within_distance_and_identical_valve_boundary():
result = demo._checked_candidate(topology())
assert result["pipe_id"] == demo.CANDIDATE_PIPE
assert result["distance_m"] == 350
assert result["boundary_valve_ids"] == ["V1", "V2"]
assert result["within_same_isolation_zone"] is True
@pytest.mark.parametrize("defect", ["outside_zone", "different_line", "too_close", "too_far",
"closed_line", "unisolatable", "missing_pipe"])
def test_unsuitable_candidate_is_never_returned(defect):
rows = topology()
if defect == "outside_zone":
rows[1]["end_node_id"] = "S"
elif defect == "different_line":
rows[1]["diameter"] = 150
elif defect == "too_close":
rows[0]["length"] = 199
elif defect == "too_far":
rows[0]["length"] = 501
elif defect == "closed_line":
rows[0]["status"] = "CLOSED"
elif defect == "unisolatable":
rows[2]["kind"] = "pipe"
else:
rows.pop(1)
assert demo._checked_candidate(rows) is None
@pytest.mark.parametrize("project,changes,enabled", [
("tjwater2_v2", {}, True),
("tjw_bench_tjwater2_20261105", {}, True),
("tjwater_v2", {}, False),
(None, {}, False),
("tjwater2_v2", {"scada_burst_start": demo.EVENT_START - timedelta(minutes=30),
"scada_burst_end": demo.EVENT_START - timedelta(minutes=15)}, False),
("tjwater2_v2", {"scada_burst_end": demo.EVENT_END + timedelta(minutes=15)}, False),
])
def test_only_case3_demo_window_skips_native_calculation_queue(project, changes, enabled):
args = arguments(**changes)
assert demo.is_location_demo(project, args) is enabled
assert calculation_jobs.should_queue(TOOLS["run_burst_location"], args, project_code=project) is not enabled
def test_candidate_has_stable_similarity_without_presentation_blockers(monkeypatch):
load = AsyncMock(return_value=topology())
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", load)
result = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments()))
assert result["located_pipe"] == demo.CANDIDATE_PIPE
assert result["top_candidates"] == [{"pipe_id": demo.CANDIDATE_PIPE, "similarity": 0.9708,
"similarity_percent": 97.08}]
assert not {"mock", "notice", "simulation_times", "similarity_mode", "observed_source"} & result.keys()
assert demo.REFERENCE_PIPE not in str(result) and demo.REFERENCE_NODE not in str(result)
load.return_value = list(reversed(topology()))
later = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments(
scada_burst_start=demo.EVENT_START,
scada_burst_end=demo.EVENT_START + timedelta(minutes=15),
)))
assert later == result
load.return_value = []
empty = asyncio.run(demo.location_demo_result(object(), "tjwater2_v2", arguments()))
assert empty["located_pipe"] is None and empty["top_candidates"] == []
assert empty["exit_condition"] == "no_candidate"
assert "关阀范围" in empty["notice"]
def test_mcp_demo_returns_synthetic_result_without_simulation_or_queue(monkeypatch):
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology()))
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2"))
binding = SimpleNamespace(replay_context=None)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments()))
assert result.origin == "synthetic"
assert result.data["result"]["located_pipe"] == demo.CANDIDATE_PIPE
assert "mock" not in result.data["result"]
assert result.data["result"]["top_candidates"][0]["similarity_percent"] == 97.08
assert result.data_gaps == []
def test_missing_candidate_preserves_a_specific_data_gap(monkeypatch):
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=[]))
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="tjwater2_v2"))
binding = SimpleNamespace(replay_context=None)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments()))
assert result.data["result"]["top_candidates"] == []
assert result.data_gaps == [result.data["result"]["notice"]]
assert "关阀范围" in result.data_gaps[0]
def test_location_capability_and_tool_contract_do_not_inject_mock_instructions():
context = demo.location_demo_context("tjwater2_v2", {"knowledge_cutoff": demo.EVENT_START.isoformat()})
assert context["available"] is True
assert "相似度" in context["notice"] and "影响分析" in context["notice"]
assert "mock" not in str(context) and "模拟" not in context["notice"]
tool = TOOLS["run_burst_location"].protocol_tool()
assert "相似度" in tool.description
assert "location_capability.available=true" in tool.description
assert "location_demo" not in tool.description
assert "demo模拟候选" not in tool.description
assert "明确标记的演示候选" not in str(tool.input_schema)
def test_replay_mock_still_validates_frozen_inputs_before_returning(monkeypatch):
from fastapi import HTTPException
from app.services import scada_replay_queries
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
freeze = AsyncMock(side_effect=HTTPException(422, "frozen baseline missing"))
monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze)
load = AsyncMock()
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", load)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(
project_id="p", project_code="tjwater2_v2"))
binding = SimpleNamespace(replay_context={"replay_id": "r"})
with pytest.raises(HTTPException):
asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments()))
load.assert_not_awaited()
def test_replay_mock_returns_after_frozen_validation_without_native_calculation(monkeypatch):
from app.mcp import analysis_service
from app.services import scada_replay_queries
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
freeze = AsyncMock(return_value={"burst_pressure": {"P1": 20}, "normal_pressure": {"P1": 30}})
monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze)
native = AsyncMock(side_effect=AssertionError("mock entered native location"))
monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", native)
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology()))
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_id="p", project_code="tjwater2_v2"))
binding = SimpleNamespace(replay_context={"replay_id": "r"})
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], arguments()))
assert result.origin == "synthetic"
assert result.data["result"]["top_candidates"][0]["similarity"] == 0.9708
assert "mock" not in result.data["result"]
assert result.data_gaps == []
freeze.assert_awaited_once()
native.assert_not_awaited()
def test_replay_demo_location_does_not_require_invented_leakage(monkeypatch):
from app.mcp import analysis_service
from app.services import scada_replay_queries
permissions = {"burst.run", "scada.view"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
args = arguments(burst_leakage=None,
scada_normal_start=demo.EVENT_START - timedelta(days=1, minutes=15),
scada_normal_end=demo.EVENT_START - timedelta(days=1))
context = {"replay_id": "r", "knowledge_cutoff": demo.EVENT_START.isoformat(), "batch_sequence": 3}
binding = SimpleNamespace(replay_context=context, tool_cap=["run_burst_location"],
permission_cap=permissions, purpose="task_execution", task_id="t")
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_id="p", project_code="tjwater2_v2"))
freeze = AsyncMock(return_value={"burst_pressure": {"J1": 20}, "normal_pressure": {"J1": 30}})
monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", freeze)
monkeypatch.setattr(demo.NetworkAssetRepository, "get_isolation_model", AsyncMock(return_value=topology()))
native = AsyncMock(side_effect=AssertionError("demo entered hydraulic calculation"))
monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", native)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_burst_location"], args))
assert result.data["result"]["located_pipe"] == demo.CANDIDATE_PIPE
assert result.data["result"]["burst_leakage"] is None
assert result.origin == "synthetic"
assert result.data["result"]["top_candidates"][0]["similarity_percent"] == 97.08
assert "mock" not in result.data["result"]
freeze.assert_awaited_once_with(scope.connection, "p", context, args)
native.assert_not_awaited()
@pytest.mark.parametrize("project,changes", [
("tjwater_v2", {}),
("tjwater2_v2", {"scada_burst_end": demo.EVENT_END + timedelta(minutes=15)}),
("tjwater2_v2", {"data_source": "simulation", "simulation_run_id": "8bfa5a5b-dbba-461a-88d4-34942bc66034"}),
])
def test_real_location_still_requires_leakage(monkeypatch, project, changes):
from fastapi import HTTPException
args = arguments(burst_leakage=None, **changes)
permissions = {"burst.run", "scada.view", "simulation.view"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
binding = SimpleNamespace(replay_context=None, tool_cap=["run_burst_location"],
permission_cap=permissions, purpose="ordinary_chat", task_id=None)
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(SimpleNamespace(
project=SimpleNamespace(project_code=project)), binding, TOOLS["run_burst_location"], args))
assert caught.value.status_code == 422
assert "burst_leakage" in caught.value.detail