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