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"