Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
69 lines
3.3 KiB
Python
69 lines
3.3 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 pydantic import ValidationError
|
|
|
|
from app.mcp import analysis_service, query_service
|
|
from app.mcp.analysis_models import ContaminantSimulationInput
|
|
from app.mcp.calculation_jobs import should_queue
|
|
from app.mcp.catalog import TOOLS
|
|
|
|
|
|
def args(**extra):
|
|
return ContaminantSimulationInput(context_handle="a" * 43, **({
|
|
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
|
"output_node_ids": ["Q1"], "source": "Q1", "concentration": 1,
|
|
"include_impact_coverage": True, "impact_threshold_mg_l": 0.01,
|
|
} | extra))
|
|
|
|
|
|
@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")},
|
|
{"include_impact_coverage": "true"}])
|
|
def test_impact_inputs_reject_invalid_thresholds_and_flags(extra):
|
|
with pytest.raises(ValidationError): args(**extra)
|
|
|
|
|
|
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="project", project_role="member"), user=SimpleNamespace(username="actor"),
|
|
simulation_routing=routing)
|
|
binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], 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_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch):
|
|
scope, binding = context(monkeypatch)
|
|
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
|
|
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
|
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args()))
|
|
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
|
|
assert "预测" in result.data_gaps[0]
|
|
scope.connection.commit.assert_awaited_once()
|
|
worker.assert_awaited_once()
|
|
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
|
|
assert should_queue(TOOLS["run_contaminant_simulation"], args())
|
|
|
|
|
|
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"])
|
|
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
|
|
scope, binding = context(monkeypatch)
|
|
parameters = args()
|
|
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
|
|
if problem == "no_impact": parameters = args(include_impact_coverage=False)
|
|
if problem == "reader": binding.purpose = "task_reader"
|
|
if problem == "permission": binding.permission_cap.remove("scada.view")
|
|
with pytest.raises(HTTPException) as caught:
|
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
|
|
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403)
|