92 lines
4.6 KiB
Python
92 lines
4.6 KiB
Python
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"
|