feat(investigations): refine report authoring and replay flushing
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s

This commit is contained in:
2026-11-14 23:00:15 +08:00
parent 2e4f6cb93b
commit b45c5c6f46
27 changed files with 1125 additions and 94 deletions
+91
View File
@@ -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"