fix(investigation): allow autonomous model baseline recovery
Generic Container CI/CD / test-build-publish (push) Failing after 2m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m11s

This commit is contained in:
2026-11-15 12:37:08 +08:00
parent 58a907bfbe
commit 747e9e53e0
22 changed files with 324 additions and 96 deletions
+171
View File
@@ -0,0 +1,171 @@
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.mcp import query_service
from app.mcp.analysis_models import ScenarioInput
from app.mcp.catalog import TOOLS
from app.services import simulation
def parameters(**changes):
return ScenarioInput.model_validate({
"context_handle": "a" * 43, "start_time": "2026-07-16T08:00:00+08:00",
"duration": 60, "output_node_ids": ["J1"], **changes,
})
def context(monkeypatch):
permissions = {"simulation.run", "simulation.view", "scada.view", "webgis.view", "optimization.run"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
@asynccontextmanager
async def routing():
yield
scope = SimpleNamespace(
connection=SimpleNamespace(commit=AsyncMock()), simulation_routing=routing,
project=SimpleNamespace(project_code="owned", project_role="member"),
user=SimpleNamespace(username="actor"),
)
binding = SimpleNamespace(
tool_cap=["run_simulation"], permission_cap=sorted(permissions),
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_coordinator_can_rebuild_online_baseline_without_live_scada(monkeypatch):
scope, binding = context(monkeypatch)
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
live_mappings = Mock(side_effect=AssertionError("Replay must not load live SCADA mappings"))
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", live_mappings)
native = Mock(return_value={
"units": {"pressure": "MTR"},
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
monkeypatch.setattr(simulation, "run_simulation", native)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_simulation"], parameters()))
assert result.origin == "synthetic"
assert any("不读取实时SCADA" in gap for gap in result.data_gaps)
assert result.data["result"]["persisted_baseline"] is True
assert result.data["result"]["node_results"][0]["result"] == [{"pressure": 30}]
native.assert_called_once()
call = native.call_args.kwargs
assert call["name"] == "owned" and call["simulation_type"] == "realtime"
assert call["modify_pattern_start_time"] == "2026-07-16T08:00:00+08:00"
assert all(not values for values in vars(call["scada_mappings"]).values())
live_mappings.assert_not_called()
@pytest.mark.parametrize("problem", ["future_start", "reader", "permission", "tool_cap"])
def test_replay_online_baseline_preserves_existing_authorization(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("simulation.run")
if problem == "tool_cap":
binding.tool_cap.clear()
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_simulation"], args))
assert caught.value.status_code == (422 if problem == "future_start" else 403)
@pytest.mark.parametrize("name", ["analyze_valve_closure", "analyze_pressure_regulation"])
def test_replay_can_compare_hydraulic_alternatives_with_model_only_boundaries(monkeypatch, name):
from app.infra.db.postgresql.scada import ScadaElementMappings
from app.services import simulation_scenarios as scenarios
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
captures = []
monkeypatch.setattr(scenarios, "get_option", lambda _: {"UNITS": "LPS"})
monkeypatch.setattr(scenarios, "set_option", lambda *_: None)
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id",
lambda _: ScadaElementMappings({}, {}, {"P1": "device"}, {}, {}, {}, {}))
monkeypatch.setattr(simulation, "run_simulation", lambda **kw: captures.append(kw) or {
"node_results": [{"node": "J1", "result": [{"pressure": 30}]}],
})
native_name = "valve_close_analysis" if name == "analyze_valve_closure" else "pressure_regulation"
native = getattr(scenarios, native_name).__wrapped__
monkeypatch.setattr(scenarios, native_name,
lambda **kw: native(**kw, _temporary_project="tjw_tmp_owned"))
args = TOOLS[name].model.model_validate({
**parameters().model_dump(),
**({"valves": ["V1"]} if name == "analyze_valve_closure" else {"pump_control": {"P1": [0]}}),
})
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS[name], args))
assert result.origin == "synthetic"
assert result.data["result"]["persisted_baseline"] is False
assert len(captures) == 1
assert captures[0]["name"] == "tjw_tmp_owned" and captures[0]["persist_results"] is False
assert all(not values for values in vars(captures[0]["scada_mappings"]).values())
if name == "analyze_valve_closure":
assert captures[0]["modify_valve_opening"] == {"V1": 0.0}
else:
assert captures[0]["modify_fixed_pump_pattern"] == {"P1": [0]}
@pytest.mark.parametrize("name", [name for name, tool in TOOLS.items()
if tool.semantics == "asset" and not tool.mutating])
def test_replay_allows_authorized_asset_queries_for_input_discovery(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
# Authorization needs only the schema kind; execution validates each tool's inputs.
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
binding.permission_cap = []
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace(kind="node")))
assert caught.value.status_code == 403
@pytest.mark.parametrize("name,fields", [
("query_model_baseline_series", {"element_type": "junction", "element_ids": ["J1"],
"start": "2026-07-16T07:45:00+08:00", "end": "2026-07-16T08:00:00+08:00"}),
("get_model_baseline_at_time", {"element_type": "junction", "element_id": "J1", "time": "2026-07-16T08:00:00+08:00"}),
("query_model_baseline_field", {"element_type": "junction", "field": "pressure", "time": "2026-07-16T08:00:00+08:00"}),
])
def test_replay_can_verify_baseline_without_reading_future_values(monkeypatch, name, fields):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
args = TOOLS[name].model.model_validate({"context_handle": "a" * 43, **fields})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args))
future = parameters(start_time="2026-07-16T08:15:00+08:00").start_time
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], args.model_copy(
update={"end" if "end" in fields else "time": future},
)))
assert caught.value.status_code == 422
@pytest.mark.parametrize("name", ["calculate_demand_allocation", "optimize_sensor_placement"])
def test_replay_can_choose_current_model_design_calculations(monkeypatch, name):
scope, binding = context(monkeypatch)
binding.tool_cap = [name]
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
binding.purpose = "task_reader"
with pytest.raises(HTTPException) as caught:
asyncio.run(query_service.authorize_query(scope, binding, TOOLS[name], SimpleNamespace()))
assert caught.value.status_code == 403
def test_replay_can_explore_contamination_without_requiring_map_generation(monkeypatch):
scope, binding = context(monkeypatch)
binding.tool_cap = ["run_contaminant_simulation"]
args = TOOLS["run_contaminant_simulation"].model.model_validate({
**parameters().model_dump(), "source": "J1", "concentration": 1,
"include_impact_coverage": False,
})
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], args))