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.
74 lines
3.8 KiB
Python
74 lines
3.8 KiB
Python
import asyncio
|
|
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 query_service
|
|
from app.mcp.analysis_models import ContaminantSourceTraceInput
|
|
from app.mcp.calculation_jobs import should_queue
|
|
from app.mcp.catalog import TOOLS
|
|
from app.services import contaminant_source_trace as trace
|
|
|
|
|
|
def arguments(**extra):
|
|
return ContaminantSourceTraceInput(context_handle="a" * 43,
|
|
**({"candidate_node_ids": ["J2", "J1", "missing"],
|
|
"start": "2026-07-16T08:00:00+08:00", "end": "2026-07-16T08:15:00+08:00"} | extra))
|
|
|
|
|
|
@pytest.mark.parametrize("nodes", [[{"id": "J2"}, {"id": "J1"}], [{"id": "J1"}, {"id": "J2"}]])
|
|
def test_trace_is_immediate_project_scoped_stable_and_explicit_about_algorithm(monkeypatch, nodes):
|
|
read = AsyncMock(return_value=nodes)
|
|
monkeypatch.setattr(trace, "read_rows", read)
|
|
connection = object()
|
|
args = arguments()
|
|
result = asyncio.run(trace.trace_contaminant_source(connection, args))
|
|
assert result["located_node"] == "J1"
|
|
assert result["missing_node_ids"] == ["missing"]
|
|
assert result["top_candidates"] == [{"node_id": "J1"}]
|
|
assert result["algorithm_status"] == "provisional"
|
|
assert result["selection_basis"] == "lexicographically_first_existing_candidate_node"
|
|
assert "相似度" not in result and "confidence" not in result
|
|
assert read.await_args.args[0] is connection
|
|
assert "network.nodes" in read.await_args.args[1] and "ANY(%s)" in read.await_args.args[1]
|
|
assert read.await_args.args[2] == (args.candidate_node_ids,)
|
|
assert not should_queue(TOOLS["run_contaminant_source_trace"], args)
|
|
|
|
|
|
@pytest.mark.parametrize("change", [{"candidate_node_ids": []}, {"candidate_node_ids": ["J1", "J1"]},
|
|
{"candidate_node_ids": [str(index) for index in range(21)]},
|
|
{"start": "2026-07-16T08:00:00"}, {"end": "2026-07-16T08:00:00+08:00"},
|
|
{"end": "2026-07-18T08:00:00+08:00"}])
|
|
def test_trace_rejects_unbounded_or_ambiguous_inputs(change):
|
|
with pytest.raises(ValidationError):
|
|
arguments(**change)
|
|
|
|
|
|
@pytest.mark.parametrize("nodes", [[], [{"id": "J1"}]])
|
|
def test_dispatch_preserves_provenance_and_reports_only_actual_node_gaps(monkeypatch, nodes):
|
|
monkeypatch.setattr(trace, "read_rows", AsyncMock(return_value=nodes))
|
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
|
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_role="member"))
|
|
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
|
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], replay_context=None)
|
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
|
assert result.origin == "synthetic" and result.semantics == "analysis"
|
|
assert (result.data["result"]["located_node"] is None) == (not nodes)
|
|
assert bool(result.data_gaps) == (not nodes)
|
|
assert "context_handle" not in result.data["parameters"]
|
|
|
|
|
|
def test_replay_trace_cannot_escape_frozen_window(monkeypatch):
|
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
|
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
|
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
|
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[],
|
|
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
|
with pytest.raises(HTTPException) as caught:
|
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
|
assert caught.value.status_code == 422
|