from datetime import datetime, timezone from uuid import uuid4 import pytest from pydantic import ValidationError from app.mcp.analysis_models import FlushingInput from app.mcp.extended_models import SavedScenarioInput from app.mcp.mutation_service import saved_scenario from app.mcp.scenario_service import calculate_scenario, flushing_valve_controls def parameters(**changes): return {"context_handle": "a" * 43, "start_time": datetime(2026, 7, 16, tzinfo=timezone.utc), "duration": 1800, "output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 12, **changes} @pytest.mark.parametrize("changes", [ {"closed_valves": ["V1"], "opened_valves": ["V1"]}, {"closed_valves": ["V1", "V1"]}, {"opened_valves": ["V1", "V1"]}, ]) def test_conflicting_or_duplicate_valves_are_rejected_for_preview_and_saved_scenarios(changes): values = parameters(**changes) with pytest.raises(ValidationError): FlushingInput.model_validate(values) with pytest.raises(ValidationError): SavedScenarioInput.model_validate({"context_handle": values.pop("context_handle"), "request_id": uuid4(), "name": "冲洗", "scenario": "flushing", "parameters": values}) def test_preview_and_saved_flushing_apply_identical_open_and_closed_states(monkeypatch): from app.services import simulation_scenarios calls = [] monkeypatch.setattr(simulation_scenarios, "flushing_analysis", lambda **kw: calls.append(kw) or { "node_results": [{"node": "J1", "result": []}], "link_results": []}) monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "LPS"}) args = FlushingInput.model_validate(parameters(opened_valves=["V-in"], closed_valves=["V-user"])) output = calculate_scenario("analyze_flushing", args.model_dump(exclude={"context_handle", "timeout_seconds"}), network="owned", username="actor") values = SavedScenarioInput.model_validate({"context_handle": args.context_handle, "request_id": uuid4(), "name": "分区冲洗", "scenario": "flushing", "parameters": args.model_dump(exclude={"context_handle", "timeout_seconds"})}) saved_scenario("owned", "actor", values) for call in calls: assert call["valve_control"] == {"V-in": {"status": "OPEN"}, "V-user": {"status": "CLOSED"}} assert "modify_valve_opening" not in call assert call["drainage_node_ID"] == "J2" and call["flushing_flow"] == 12 assert calls[0]["persist_results"] is False and calls[1]["persist_results"] is True assert output["flushing_plan"] == { "opened_valves": ["V-in"], "closed_valves": ["V-user"], "drainage_node_id": "J2", "flushing_flow_m3h": 12, "estimated_discharge_volume_m3": 6, "state_basis": "independent_current_model_scenario", "result_scope": "hydraulic_only", "boundary_source": "model_with_start_time_scada", "field_execution_verified": False, "water_quality_clearance_verified": False, } def test_legacy_closed_only_inputs_remain_valid(): args = FlushingInput.model_validate(parameters(closed_valves=["V1"])) assert args.opened_valves == [] assert flushing_valve_controls(args.model_dump()) == {"V1": {"status": "CLOSED"}} def test_explicit_opening_reopens_a_closed_valve_without_overwriting_its_setting(monkeypatch): from app.services import simulation model = {"V-in": {"link": "V-in", "status": "CLOSED", "setting": 15}, "V-user": {"link": "V-user", "status": "ACTIVE", "setting": 23}} monkeypatch.setattr(simulation, "get_status", lambda project, valve: dict(model[valve])) monkeypatch.setattr(simulation, "set_status", lambda project, changes: model.update({ row["link"]: row for row in changes.operations})) simulation._apply_valve_control("temporary", flushing_valve_controls({ "opened_valves": ["V-in"], "closed_valves": ["V-user"]})) assert model["V-in"] == {"link": "V-in", "status": "OPEN", "setting": 15} assert model["V-user"] == {"link": "V-user", "status": "CLOSED", "setting": 23}