feat: extend operational investigation workflows
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 63af998045
commit 05183aff93
82 changed files with 4644 additions and 330 deletions
@@ -0,0 +1,63 @@
"""Queue lifecycle SQL contracts; real races are covered by the opt-in PG suite."""
import asyncio
from contextlib import asynccontextmanager
import pytest
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
from app.mcp.errors import CalculationCapacityExceeded
class Connection:
def __init__(self, rows=()):
self.rows = iter(rows)
self.statements = []
self.in_transaction = False
@asynccontextmanager
async def transaction(self):
self.in_transaction = True
try:
yield
finally:
self.in_transaction = False
async def execute(self, sql, params):
self.statements.append((sql, params, self.in_transaction))
row = next(self.rows, None) if sql.lstrip().startswith(('SELECT job_id', 'SELECT count', 'INSERT')) else None
class Cursor:
async def fetchone(self):
return row
async def fetchall(self):
return []
return Cursor()
def test_status_projection_excludes_result_and_expires_without_worker():
connection = Connection()
repo = CalculationJobRepository(connection, 'project')
asyncio.run(repo.get('job'))
assert 'QUEUE_TIMEOUT' in connection.statements[0][0]
assert 'SET result=NULL' in connection.statements[1][0]
assert ',result FROM' not in connection.statements[2][0]
asyncio.run(repo.get('job', include_result=True))
assert ',result FROM' in connection.statements[-1][0]
def test_duplicate_submission_bypasses_full_queue_inside_transaction():
existing = {'tool': 'tool', 'parameters': {'timeout_seconds': 3600}, 'binding': {}, 'job_id': 'existing'}
connection = Connection([existing])
row = asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
assert row['job_id'] == 'existing'
assert 'pg_advisory_xact_lock' in connection.statements[0][0]
assert all(statement[2] for statement in connection.statements)
assert not any('count(*)' in sql for sql, _, _ in connection.statements)
def test_capacity_rejects_new_job_before_insert():
connection = Connection([None, {'active': 100}])
with pytest.raises(CalculationCapacityExceeded):
asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
assert not any('INSERT' in sql for sql, _, _ in connection.statements)
@@ -0,0 +1,82 @@
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.domain.schemas.operational_task import (
AgentArtifactsCommand,
InvestigationDiagramNodeRef,
InvestigationProgress,
InvestigationReportInput,
)
def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
diagram = {
"title": "等待系统结果",
"mermaid": "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET",
"node_refs": [
{"node_id": "DONE", "label": "查询完成", "status": "completed"},
{"node_id": "WAIT", "label": "等待系统结果", "status": "waiting"},
{"node_id": "UNKNOWN", "label": "未知", "status": None},
{"node_id": "UNSET", "label": "未确认"},
],
}
progress = InvestigationProgress(summary="等待系统结果", diagram=diagram)
report = InvestigationReportInput(
title="报告", current_conclusion="已确认当前结果", conclusion_boundary="仅限查询范围",
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
)
assert report.diagram == progress.diagram
assert [node.status for node in report.diagram.node_refs] == ["completed", "waiting", None, None]
assert "status" not in report.diagram.node_refs[-1].model_fields_set
def test_unknown_node_state_is_rejected():
with pytest.raises(ValidationError):
InvestigationDiagramNodeRef(node_id="A", label="查询", status="blocked")
@pytest.mark.parametrize("artifact", ["progress", "report"])
def test_artifact_write_rejects_node_refs_missing_from_mermaid(artifact):
diagram = {
"title": "当前路线",
"mermaid": "flowchart TD\nA[绑定任务] --> B[核查证据]",
"node_refs": [
{"node_id": "ctx", "label": "绑定任务", "status": "completed"},
{"node_id": "B", "label": "核查证据", "status": "running"},
],
}
payload = (
{"summary": "正在核查", "diagram": diagram}
if artifact == "progress"
else {
"title": "调查报告",
"current_conclusion": "已确认当前范围",
"conclusion_boundary": "仅限已核查资料",
"stage": "final",
"outcome": "concluded",
"data_gaps": [],
"diagram": diagram,
}
)
with pytest.raises(ValidationError, match="ctx"):
AgentArtifactsCommand.model_validate({
"request_id": uuid4(),
"source_session_id": "session-1",
artifact: payload,
})
def test_historical_progress_keeps_read_compatibility_for_undeclared_node_refs():
progress = InvestigationProgress.model_validate({
"summary": "旧进度",
"diagram": {
"title": "旧图",
"mermaid": "flowchart TD\nA[绑定任务]",
"node_refs": [{"node_id": "ctx", "label": "绑定任务", "status": "completed"}],
},
})
assert progress.diagram.node_refs[0].node_id == "ctx"
+23 -4
View File
@@ -218,7 +218,7 @@ def test_time_sources_and_finite_inputs_are_explicit():
("run_simulation", {"duration": 0}),
("run_simulation", {"duration": 86401}),
("run_simulation", {"timeout_seconds": 0}),
("run_simulation", {"timeout_seconds": 86401}),
("run_simulation", {"timeout_seconds": 241}),
("run_simulation", {"timeout_seconds": 30.5}),
("run_simulation", {"timeout_seconds": True}),
("run_simulation", {"output_node_ids": []}),
@@ -316,9 +316,9 @@ def test_burst_location_optional_inputs_still_reject_partial_or_invalid_values(o
@pytest.mark.parametrize("name", INPUTS)
def test_calculations_accept_agent_execution_budget_separately_from_duration(name):
args = arguments(name, timeout_seconds=900)
assert args.timeout_seconds == 900
def test_calculations_accept_http_response_budget_separately_from_duration(name):
args = arguments(name, timeout_seconds=180)
assert args.timeout_seconds == 180
if hasattr(args, "duration"):
assert args.duration == INPUTS[name].get("duration", 900)
@@ -350,6 +350,25 @@ def test_adapter_uses_existing_service_without_scheme_or_shared_output(monkeypat
assert call["n_workers"] == 1
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
def test_isolation_adapter_preserves_explicit_element_type(monkeypatch, element_type):
calls = []
monkeypatch.setattr(
"app.services.valve_isolation.analyze_valve_isolation",
lambda **kwargs: calls.append(kwargs) or {"isolatable": True},
)
args = arguments("analyze_valve_isolation", accident_element_type=element_type)
asyncio.run(analysis_service.calculate(
"analyze_valve_isolation", args, network="trusted-project", username="actor",
))
assert calls[0]["accident_element_type"] == element_type
assert calls[0]["network"] == "trusted-project"
with pytest.raises(ValidationError):
arguments("analyze_valve_isolation", accident_element_type="unknown")
@pytest.mark.parametrize("name", ["run_burst_location", "run_simulation"])
def test_dispatch_ends_read_transaction_and_preserves_analysis_provenance(monkeypatch, name):
order = []
+67
View File
@@ -137,3 +137,70 @@ def test_evidence_receipt_commits_in_root_operational_transaction(monkeypatch):
assert attached.evidence_id == 'saved'
assert events == ['commit-reads', 'begin-root-operational', 'commit-evidence']
receipt_repo.save_receipt.assert_awaited_once()
@pytest.mark.parametrize('job_budget', [3600, 7200])
def test_http_timeout_does_not_limit_queued_execution(monkeypatch, job_budget):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service
from app.mcp.catalog import TOOLS
tool = TOOLS['run_simulation']
args = tool.model.model_validate({
'context_handle': 'x' * 32, 'timeout_seconds': 1,
'start_time': '2026-10-08T12:00:00+08:00', 'duration': 3600,
'output_node_ids': ['J1'],
})
repo = SimpleNamespace(enqueue=AsyncMock(return_value={
'job_id': uuid4(), 'status': 'queued', 'tool': tool.name,
}))
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
monkeypatch.setattr(query_service, 'authorize_query', AsyncMock())
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_id=uuid4(), user_id=uuid4()))
result = asyncio.run(jobs.submit_calculation(
scope, binding(), tool, args, 'host-call', job_timeout_seconds=job_budget,
))
assert result.data['status'] == 'queued'
assert repo.enqueue.await_args.kwargs['timeout_seconds'] == job_budget
assert 'timeout_seconds' not in repo.enqueue.await_args.kwargs['parameters']
def test_expired_result_is_safe_explicit_error(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
original = binding()
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded',
'result': None, 'result_expires_at': datetime(2020, 1, 1, tzinfo=timezone.utc)}
repo = SimpleNamespace(get=AsyncMock(return_value=row))
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
attach = AsyncMock()
monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach)
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
with pytest.raises(HTTPException) as error:
asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])))
assert error.value.status_code == 410
assert jobs.public_job(row)['result_available'] is False
assert repo.get.await_args.kwargs['include_result'] is True
attach.assert_not_awaited()
def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service
from app.mcp.catalog import TOOLS
original = binding()
owner = uuid4()
row = {'binding': durable_binding(original), 'tool': 'run_simulation',
'owner_user_id': owner, 'parameters': {'timeout_seconds': 3600,
'start_time': '2026-10-29T00:00:00Z', 'output_node_ids': ['1']}}
authorize = AsyncMock()
monkeypatch.setattr(query_service, 'authorize_query', authorize)
scope = SimpleNamespace(project=SimpleNamespace(project_id=original.project_id, user_id=owner))
_, tool, args = asyncio.run(jobs.authorize_original(scope, row))
assert tool is TOOLS['run_simulation']
assert args.timeout_seconds <= 240
assert row['parameters']['timeout_seconds'] == 3600
authorize.assert_awaited_once()
+8 -2
View File
@@ -23,9 +23,15 @@ def test_explicit_private_development_issuer():
assert local_settings().issuer_uri == ISSUER
def test_calculation_limit_covers_default_without_extending_ordinary_queries():
def test_execution_limit_is_separate_from_http_request_budget():
config = local_settings(max_calculation_timeout_seconds=120)
assert config.query_timeout_seconds == 30
assert config.query_timeout_seconds == 120
assert config.max_request_timeout_seconds == 240
assert local_settings().max_calculation_timeout_seconds == 3600
with pytest.raises(ValidationError):
local_settings(query_timeout_seconds=241)
with pytest.raises(ValidationError):
local_settings(max_request_timeout_seconds=241)
with pytest.raises(ValidationError):
local_settings(max_calculation_timeout_seconds=119)
+1 -1
View File
@@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 67
assert len(TOOLS) == 71
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
+48 -3
View File
@@ -53,11 +53,12 @@ def test_calculation_submission_returns_receipt_without_running_native_code(runt
assert response["result"]["structuredContent"]["semantics"] == "task"
app.executor.assert_not_awaited()
assert submit.await_args.args[3].timeout_seconds == 2
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
app, client, calls, _ = runtime
app.settings.max_calculation_timeout_seconds = 120
app.settings.max_request_timeout_seconds = 120
tools = request(client).json()["result"]["tools"]
schema = next(tool["inputSchema"] for tool in tools if tool["name"] == "run_simulation")
assert schema["properties"]["timeout_seconds"]["maximum"] == 120
@@ -83,8 +84,19 @@ def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch
assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_CONTEXT_STALE"
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
def test_expired_calculation_result_has_distinct_mcp_error(runtime):
app, client, _, _ = runtime
app.executor = AsyncMock(side_effect=HTTPException(410, "Calculation result retention period has expired"))
response = request(client, "tools/call", {"name": "get_calculation_result", "arguments": {
"context_handle": binding().context_handle, "job_id": str(uuid4()),
}}).json()
assert response["result"]["structuredContent"]["error"]["code"] == "RESULT_EXPIRED"
@pytest.mark.parametrize("maximum,expected", [(240, 120), (120, 120)])
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch, maximum, expected):
app, client, calls, _ = runtime
app.settings.max_request_timeout_seconds = maximum
from app.mcp import calculation_jobs
executor = AsyncMock(return_value=calculation_jobs.envelope("run_burst_location", {"job_id": str(uuid4()), "status": "queued"}))
monkeypatch.setattr(calculation_jobs, "submit_calculation", executor)
@@ -92,6 +104,7 @@ def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeyp
schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"]
if tool["name"] == "run_burst_location")
assert "pressure_scada_ids" not in schema["required"]
assert schema["properties"]["timeout_seconds"]["default"] == expected
response = request(client, "tools/call", {"name": "run_burst_location", "arguments": {
"context_handle": binding().context_handle, "burst_leakage": 0.0274,
"scada_burst_start": "2026-10-08T12:00:00+08:00",
@@ -99,6 +112,7 @@ def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeyp
}}).json()
assert response["result"].get("isError") is not True
assert calls == []
assert executor.await_args.args[3].timeout_seconds == expected
def settings():
@@ -286,6 +300,37 @@ def binding(task=False):
return value
@pytest.mark.parametrize("seconds", [30, 120, 240])
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
app, client, _, _ = runtime
app.settings.query_timeout_seconds = seconds
for tool in request(client).json()["result"]["tools"]:
assert tool["_meta"]["io.tjwater/request-timeout"] == {
"defaultSeconds": seconds, "maxSeconds": 240,
}
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
if timeout:
assert timeout["default"] == seconds
def test_omitted_calculation_http_budget_uses_server_configuration(runtime, monkeypatch):
from app.mcp import calculation_jobs
app, client, _, _ = runtime
app.settings.query_timeout_seconds = 240
monkeypatch.setattr(application, "authorize_query", AsyncMock())
submit = AsyncMock(return_value=calculation_jobs.envelope(
"run_simulation", {"job_id": str(uuid4()), "status": "queued"},
))
monkeypatch.setattr(calculation_jobs, "submit_calculation", submit)
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
assert response["result"].get("isError") is not True
assert submit.await_args.args[3].timeout_seconds == 240
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
def test_modern_discovery_catalog_and_metadata(runtime):
_, client, _, _ = runtime
result = request(client, "server/discover").json()
@@ -298,7 +343,7 @@ def test_modern_discovery_catalog_and_metadata(runtime):
assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating)
if timeout:
assert timeout["default"] == 120
assert timeout["maximum"] == 3600
assert timeout["maximum"] == 240
assert listed["ttlMs"] == 300000 and listed["cacheScope"] == "public"
assert all(
tool["inputSchema"]["additionalProperties"] is False for tool in listed["tools"]
@@ -48,12 +48,13 @@ async def test_create_work_order_confirms_creation_not_field_completion():
calls.append(request)
return httpx.Response(201, json={"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id), "created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic", "status": "open"})
"origin": "system_data", "status": "open"})
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
result = await adapter.dispatch(**data)
assert result.status == "succeeded"
assert result.receipt.external_status == "open"
assert "现场工作进度" in result.detail
assert result.detail == "外围工单已创建,现场工作进度由外围系统继续同步"
assert calls[0].url.path == "/api/sample/v1/work-orders"
assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id)
@@ -66,7 +67,7 @@ async def test_work_order_receipt_accepts_awaiting_reply_status():
"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic",
"origin": "system_data",
"status": "awaiting_reply",
})
adapter = HttpOperationalActionDispatcher(
@@ -90,7 +91,7 @@ async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event():
"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic",
"origin": "system_data",
"status": "open",
})
@@ -111,7 +112,7 @@ async def test_lost_create_response_recovers_same_request_without_second_write()
raise httpx.ReadTimeout("lost", request=request)
return httpx.Response(200, json={"request_id": request.url.path.split("/")[-1],
"result": {"work_order_id": str(uuid4()), "project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}})
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"}})
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
assert result.status == "succeeded"
assert methods == ["POST", "GET"]
@@ -122,7 +123,7 @@ async def test_unconfirmed_receipt_remains_unknown_not_failed_or_succeeded():
data = inputs()
def handler(request):
return httpx.Response(200, json={"work_order_id": str(uuid4()), "project_id": str(uuid4()),
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"})
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"})
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
assert result.status == "unknown"
assert result.receipt is None
@@ -54,3 +54,35 @@ def test_overlay_rejects_missing_or_invalid_recorded_spatial_data(data):
def test_presentation_input_rejects_free_form_or_invalid_commands(command):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
@pytest.mark.parametrize("command", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]},
{"action": "clear_scada_analysis"},
])
def test_unified_presentation_commands_preserve_flat_shape(command):
event = TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
assert event.command.model_dump(mode="json") == command
@pytest.mark.parametrize("command", [
{"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"]},
{"action": "locate_features", "layer": "pipes", "feature_ids": []},
{"action": "zoom_to_map", "center": [181, 31], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 91], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 31, 0], "zoom": 1},
{"action": "zoom_to_map", "center": [True, 31], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 31], "zoom": 25},
{"action": "zoom_to_map", "center": [121, 31], "zoom": -1},
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "urgent"}]},
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "high"}] * 2},
{"action": "render_scada_analysis", "items": [{"device_id": str(i), "level": "low"} for i in range(101)]},
{"action": "open_chart", "chart_id": "X", "evidence_refs": [], "chart": {
"title": "X", "chart_type": "line", "x_data": ["t"], "series": [{"name": "s", "data": [1]}],
}},
])
def test_unified_presentation_rejects_invalid_commands(command):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
@@ -17,12 +17,19 @@ def anyio_backend():
@pytest.mark.anyio
@pytest.mark.parametrize("available", [False, True])
async def test_execution_requires_message_storage(available):
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
@pytest.mark.parametrize("available,agent_ownership_available,ready", [
(False, False, False), (True, False, False), (True, True, True),
])
async def test_execution_requires_message_and_agent_ownership_storage(
available, agent_ownership_available, ready,
):
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={
"available": available,
"agent_ownership_available": agent_ownership_available,
}))
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
repository = OperationalTaskRepository(connection, uuid4())
if available:
if ready:
await repository.require_execution_storage()
else:
with pytest.raises(DomainError) as failure:
@@ -30,9 +37,31 @@ async def test_execution_requires_message_storage(available):
assert failure.value.status == 503
assert failure.value.code == "DEPENDENCY_UNAVAILABLE"
assert "024_agent_messages.sql" in failure.value.message
assert "030_operational_run_agent_ownership.sql" in failure.value.message
assert "to_regclass('workbench.agent_messages')" in connection.execute.call_args.args[0]
@pytest.mark.anyio
@pytest.mark.parametrize("available", [False, True])
async def test_run_update_uses_agent_owner_column_only_after_migration(available):
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
updated = SimpleNamespace(rowcount=1)
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, updated]))
repository = OperationalTaskRepository(connection, uuid4())
run_id = str(uuid4())
await repository.update_run({
"run_id": run_id, "status": "running", "revision": 2,
"agent_session_id": "session-1", "agent_instance_id": "agent-a",
"lease_expires_at": None, "heartbeat_at": None, "started_at": None,
"finished_at": None, "failure_code": None, "failure_detail": None,
"dispatch_attempts": 0, "next_dispatch_at": None, "updated_at": None,
}, 1)
query, params = connection.execute.call_args.args
assert ("agent_instance_id=%s" in query) is available
assert ("agent-a" in params) is available
@pytest.mark.anyio
async def test_missing_message_storage_rejects_claim_before_state_changes():
@asynccontextmanager
@@ -56,3 +85,16 @@ async def test_missing_message_storage_rejects_claim_before_state_changes():
repository.lock.assert_not_awaited()
repository.update_run.assert_not_awaited()
repository.update_task.assert_not_awaited()
@pytest.mark.anyio
async def test_presentation_history_storage_filters_project_task_and_committed_cursor():
project_id, task_id = uuid4(), uuid4()
connection = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(fetchall=AsyncMock(return_value=[]))))
repository = OperationalTaskRepository(connection, project_id)
assert await repository.list_presentation_events(str(task_id), 7, 30, 21) == []
query, params = connection.execute.call_args.args
assert params == (str(project_id), str(task_id), 7, 30, 21)
assert "project_id=%s" in query and "aggregate_id=%s" in query
assert "operational.presentation.requested" in query and "event_seq<=%s" in query
assert "ORDER BY event_seq LIMIT %s" in query
+119 -2
View File
@@ -402,6 +402,9 @@ class RunTaskRepository(TaskRepository):
async def get_run(self, run_id):
return self.run if run_id == self.run["run_id"] else None
async def require_execution_storage(self):
return None
async def update_run(self, run, expected_revision):
assert run["revision"] == expected_revision + 1
self.run = run
@@ -416,6 +419,11 @@ class RunTaskRepository(TaskRepository):
async def current_event_cursor(self):
return len(self.events)
async def list_presentation_events(self, task_id, after, through, limit):
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
if after < index <= through and event["topic"] == "operational.presentation.requested"
and event["aggregate_id"] == task_id][:limit]
def _active_run(task, now):
return OperationalTaskRun(
@@ -427,6 +435,30 @@ def _active_run(task, now):
).model_dump(mode="json")
@pytest.mark.anyio
async def test_run_claim_persists_agent_instance_ownership():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
run = _active_run(task, now)
run.update(status="queued", revision=1, agent_session_id=None,
lease_expires_at=None, heartbeat_at=None, started_at=None)
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
claimed = await service.claim_run(
task["task_id"], run["run_id"],
AgentRunCommand(
request_id=run["execution_request_id"], expected_revision=1,
source_session_id="session-1", agent_instance_id="agent-a",
),
)
assert claimed.agent_session_id == "session-1"
assert claimed.agent_instance_id == "agent-a"
@pytest.mark.anyio
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
@@ -544,6 +576,28 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
await service.presentation_cursor(uuid4())
@pytest.mark.anyio
@pytest.mark.parametrize("payload", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 15},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "high"}]},
{"action": "clear_scada_analysis"},
])
async def test_unified_commands_record_canonical_events_without_mutating_task(payload):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
)
result = await service.publish_presentation_event(task["task_id"], command)
assert result.recorded and repository.events[0]["metadata"]["command"] == payload
assert await service.publish_presentation_event(task["task_id"], command) == result
assert len(repository.events) == 1 and repository.task["revision"] == task["revision"]
@pytest.mark.anyio
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
@@ -566,6 +620,16 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
emitted = repository.events[-1]["metadata"]["command"]["data"]
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
assert emitted["features"][0]["properties"]["weight"] == 1
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": emitted},
))
spoofed = {**emitted, "features": [{**emitted["features"][0],
"geometry": {"type": "Point", "coordinates": [120, 30]}}]}
with pytest.raises(DomainError) as spoof:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": spoofed},
))
assert spoof.value.code == "OVERLAY_DATA_MISMATCH"
with pytest.raises(DomainError) as missing:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
@@ -580,7 +644,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
assert len(repository.events) == 1
assert len(repository.events) == 2
@pytest.mark.anyio
@@ -699,6 +763,28 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
assert repository.events == []
@pytest.mark.anyio
async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_session_id():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
run["agent_instance_id"] = "agent-a"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as mismatch:
await service.heartbeat_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=2,
source_session_id="session-1", agent_instance_id="agent-b",
),
)
assert mismatch.value.code == "AGENT_INSTANCE_MISMATCH"
@pytest.mark.anyio
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
@@ -1537,7 +1623,7 @@ def _work_order_snapshot(work_order_id, now):
"feedback_requirements": ["上传检测结果"],
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
"origin": "synthetic",
"origin": "system_data",
"updated_at": now.isoformat(),
}
@@ -1844,6 +1930,37 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
assert error.value.code == "EVIDENCE_NOT_FOUND"
@pytest.mark.anyio
async def test_presentation_history_is_task_scoped_paginated_and_readable_after_run_finishes():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now)
repository = RunTaskRepository(task, _active_run(task, now))
repository.run["status"] = "succeeded"
for index in range(4):
await repository.append_event(
topic="operational.presentation.requested", aggregate_type="operational_task_presentation",
aggregate_id=task["task_id"] if index != 1 else str(uuid4()), aggregate_revision=1,
occurred_at=now, metadata={"run_id": repository.run["run_id"],
"execution_generation": 1 if index < 2 else 2,
"command": {"action": "clear_highlights"}},
)
service = OperationalTaskService(repository, context, {OPERATIONAL_TASK_VIEW}, clock=lambda: now)
first = await service.presentation_history(task["task_id"], limit=1)
assert [item.event_seq for item in first.items] == [1]
assert first.event_cursor == 4 and first.next_after == 1
# An append during pagination is outside the captured boundary.
repository.events.append(repository.events[-1])
second = await service.presentation_history(task["task_id"], after=first.next_after, through=first.event_cursor)
assert [item.event_seq for item in second.items] == [3, 4]
assert [item.execution_generation for item in second.items] == [2, 2]
assert second.next_after is None and second.event_cursor == 4
assert repository.run["status"] == "succeeded" and repository.task["revision"] == task["revision"]
with pytest.raises(DomainError):
await service.presentation_history(uuid4())
assert len(repository.events) == 5
@pytest.mark.anyio
@pytest.mark.parametrize("report_factory", [_current_report, _insufficient_report])
async def test_final_report_rejected_while_original_run_has_pending_calculations(report_factory):
+52 -5
View File
@@ -90,8 +90,7 @@ async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_re
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert submitted == [row["message_id"] for row in pending]
assert len(failures) == 1 and pending[1]["message_id"] in failures[0][0]
assert failures[0][1]["status"] == "failed"
assert failures == []
@pytest.mark.anyio
@@ -126,6 +125,8 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4())
submitted = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
assert not request.url.path.endswith("schedules/dispatch-due")
if request.url.path.endswith("leases/recover"):
@@ -140,7 +141,10 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
body = json.loads(request.content)
assert str(request.url) == "http://agent.test/api/v1/task-executions"
submitted.append(body)
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
return httpx.Response(202, json={
"task_id": task_id, "run_id": run_id, "session_id": "execution-session",
"agent_instance_id": "agent-a",
})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
worker = OperationalWorker(settings, client)
@@ -149,6 +153,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
assert submitted[0] == submitted[1]
assert submitted[0] == {
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
"agent_instance_id": "agent-a",
}
assert "credential" not in submitted[0]
@@ -178,6 +183,8 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
limits, submitted, failures = [], [], []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.method == "GET":
if request.url.params.get("status") != "queued":
return httpx.Response(200, json={"items": []})
@@ -192,7 +199,10 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
submitted.append(body)
if body["task_id"] == runs[0]["task_id"]:
return httpx.Response(503, json={"detail": "temporary failure"})
return httpx.Response(202, json={"task_id": body["task_id"], "run_id": body["run_id"]})
return httpx.Response(202, json={
"task_id": body["task_id"], "run_id": body["run_id"],
"agent_instance_id": "agent-a",
})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
@@ -257,10 +267,16 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
renewed = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.url.path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
items = [{"run_id": run_id, "agent_session_id": "existing-session"}] if request.url.params.get("status") == "running" else []
items = [{
"run_id": run_id,
"agent_session_id": "existing-session",
"agent_instance_id": "agent-a",
}] if request.url.params.get("status") == "running" else []
return httpx.Response(200, json={"items": items})
assert request.url.host == "agent.test"
assert str(request.url) == "http://agent.test/api/v1/session/existing-session/credentials"
@@ -274,3 +290,34 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(renewed) == 1
@pytest.mark.anyio
async def test_worker_only_restores_sessions_owned_by_target_agent_instance():
own_run_id, foreign_run_id, project_id = str(uuid4()), str(uuid4()), str(uuid4())
renewed = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.url.path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
items = [{
"run_id": own_run_id,
"agent_session_id": "own-session",
"agent_instance_id": "agent-a",
}, {
"run_id": foreign_run_id,
"agent_session_id": "foreign-session",
"agent_instance_id": "agent-b",
}] if request.url.params.get("status") == "running" else []
return httpx.Response(200, json={"items": items})
assert str(request.url) == "http://agent.test/api/v1/session/own-session/credentials"
renewed.append(request)
return httpx.Response(202, json={"accepted": True})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(renewed) == 1
@@ -86,7 +86,7 @@ def test_active_work_order_projection_removes_only_tombstone_fields():
assert "deleted_at" not in result
assert "deleted_by" not in result
assert stored_document == document
WorkOrder.model_validate(result)
assert WorkOrder.model_validate(result).origin == "system_data"
@pytest.mark.parametrize(
@@ -24,7 +24,7 @@ class ManagedStore:
now = "2026-09-18T00:00:00Z"
return command.model_dump(mode="json", exclude={"request_id"}) | {
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now,
"origin": getattr(command, "origin", "system_data"), "created_at": now, "updated_at": now,
"created_by": str(USER_ID), "updated_by": str(USER_ID), "activity": [],
}
@@ -88,12 +88,17 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
"feedback_requirements": ["上传检测结果"],
})
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
assert created.status_code == 201 and created.json()["origin"] == "system_data"
assert created.json()["work_order_type"] == "water_quality_complaint"
assert created.json()["location"] == "演示小区"
assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001"
assert created.json()["feedback_requirements"] == ["上传检测结果"]
assert "subtasks" not in created.json()
rejected = client.post("/api/sample/v1/work-orders", json={
"request_id": str(uuid4()), "title": "非法来源", "summary": "不接受演练来源",
"origin": "synthetic",
})
assert rejected.status_code == 422
work_order_id = created.json()["work_order_id"]
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply",
+42
View File
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from uuid import uuid4
import httpx
import pytest
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_data import TaskDataQuery
from app.infra.integrations.operational_data import TaskDataSettings, read_task_data
from app.mcp import peripheral
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("origins", [
[], ["system_data"], ["synthetic"], ["system_data", "synthetic"], [None], ["unverified"],
])
async def test_http_and_mcp_normalize_work_orders_as_system_data(monkeypatch, origins):
project_id = uuid4()
items = [{"work_order_id": str(uuid4()), "project_id": str(project_id), "origin": origin} for origin in origins]
async def rows(*_args, **_kwargs):
return [{"document": item} for item in items]
monkeypatch.setattr(peripheral, "read_rows", rows)
query = TaskDataQuery(source="peripheral_work_orders")
context = ProjectContext(project_id=project_id, user_id=uuid4(), project_code="test", project_role="member")
kwargs = dict(context=context, credential="Bearer TEST_ONLY", query=query,
settings=TaskDataSettings(_env_file=None, peripheral_url="http://peripheral.test"),
transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"items": items})))
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=project_id))
args = SimpleNamespace(source=query.source, limit=20, offset=0)
snapshots = [await read_task_data(**kwargs), await peripheral.read_peripheral_query(scope, args)]
for snapshot in snapshots:
assert snapshot.origin == "system_data"
assert [item["origin"] for item in snapshot.data["items"]] == ["system_data"] * len(origins)
assert "provenance_scope" not in snapshot.data
assert "source_origins" not in snapshot.data
+1 -1
View File
@@ -100,7 +100,7 @@ async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_f
try:
await asyncio.wait_for(initial_scan.wait(), timeout=3)
orders.append({
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
"revision": 1, "title": "预约维修", "summary": "等待现场处理", "priority": "normal",
"status": "open", "created_at": "2026-10-08T00:00:00Z",
"updated_at": "2026-10-08T00:00:00Z", "scheduled_at": None,
+23 -34
View File
@@ -14,7 +14,6 @@ from scripts.sync_peripheral_work_orders import (
sync_once,
work_order_snapshot,
)
from samples.peripheral.seed_identity import SEED_ACTOR_ID
from app.domain.schemas.operational_task import ExternalFeedbackCommand
@@ -36,27 +35,21 @@ def test_documented_sync_command_can_start_from_repository_root():
assert "--once" in result.stdout
def test_unlinked_synthetic_checks_are_not_operational_tasks():
def test_all_active_work_orders_are_operational_tasks():
order_id = str(uuid4())
order = {
"work_order_id": order_id,
"origin": "synthetic",
"created_by": str(SEED_ACTOR_ID),
"title": "合成压力偏差复核",
"origin": "system_data",
"title": "压力偏差复核",
"work_order_type": "general",
}
assert not should_project_work_order(order, set())
assert should_project_work_order(order, set())
assert should_project_work_order(order, {order_id})
assert should_project_work_order(
{**order, "work_order_type": "water_quality_complaint"}, set()
)
assert should_project_work_order(
{**order, "title": "现场采样:二次供水出水及末端用户水样"}, set()
) is False
assert should_project_work_order(
{**order, "created_by": str(uuid4()), "title": "合成管材接驳预约"}, set()
)
assert should_project_work_order({**order, "title": "现场采样"}, set())
def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
@@ -71,7 +64,7 @@ def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
tasks, writes = [], []
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
"revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal",
"status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
"scheduled_at": None, "work_order_type": "general"}
@@ -116,16 +109,15 @@ async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
@pytest.mark.anyio
async def test_sync_skips_unlinked_synthetic_check_before_writing():
async def test_sync_rejects_non_system_work_order_origin():
project_id, order_id = str(uuid4()), str(uuid4())
order = {
"project_id": project_id,
"work_order_id": order_id,
"origin": "synthetic",
"created_by": str(SEED_ACTOR_ID),
"revision": 1,
"title": "合成通信核查",
"summary": "仅用于外围样板联调",
"title": "来源异常工单",
"summary": "验证来源边界",
"priority": "normal",
"status": "open",
"created_at": "2026-09-23T08:00:00Z",
@@ -135,7 +127,6 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
}
def handler(request):
assert request.method == "GET"
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [order], "next_cursor": None})
return httpx.Response(200, json={"items": [], "next_offset": None})
@@ -144,14 +135,8 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
"http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY"
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
synchronized = await sync_once(
client,
settings,
"http://sample.test",
CredentialProvider(settings),
)
assert synchronized == 0
with pytest.raises(ValueError, match="origin mismatch"):
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
@pytest.mark.anyio
@@ -231,7 +216,7 @@ async def test_sync_does_not_withdraw_when_missing_order_is_not_confirmed_delete
async def test_sample_sync_rejects_cross_project_data_before_writing():
def handler(request):
assert request.method == "GET"
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}]
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "system_data"}]
if request.url.host == "sample.test" else []), "next_offset": None})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
@@ -263,11 +248,11 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
def handler(request):
if request.url.host == "sample.test":
order = {
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
"created_by": str(uuid4()),
"revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open",
"updated_at": "2026-09-23T09:00:00Z",
"title": "合成采样工单", "priority": "normal",
"title": "采样工单", "priority": "normal",
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员",
@@ -320,13 +305,15 @@ async def test_external_events_start_investigations_without_browser(event_kind):
return httpx.Response(200, json={"items": [], "next_offset": None})
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [{
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1,
"project_id": project_id, "work_order_id": order_id, "origin": "system_data", "revision": 1,
"title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open",
"created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
"scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置",
}], "next_cursor": None})
payload = json.loads(request.content)
if request.url.path.endswith("external-feedback"):
assert payload["evidence"]["kind"] == "external_feedback"
assert payload["work_order_snapshot"]["origin"] == "system_data"
return httpx.Response(200, json={})
task = {**payload, "task_id": str(uuid4())}
created.append(task)
@@ -339,6 +326,7 @@ async def test_external_events_start_investigations_without_browser(event_kind):
assert investigation["workflow"] == event_kind
assert investigation["source"]["external_id"] == order_id
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
assert all("合成" not in item["summary"] for item in created)
@pytest.mark.anyio
@@ -349,7 +337,7 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
projection = {"task_id": projection_id, "task_type": "external_work_order", "source": {
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
}}
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
"revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal",
"created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None,
"work_order_type": "general"}
@@ -398,8 +386,8 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
order = {
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
"status": "in_progress", "priority": "normal", "origin": "synthetic",
"work_order_id": str(uuid4()), "revision": 4, "title": "预约工单", "summary": "仅测试过程同步",
"status": "in_progress", "priority": "normal", "origin": "system_data",
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
"feedback_requirements": ["回填现场检查结果"],
"activity": [{
@@ -411,7 +399,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
"detail": "已创建现场检查工单。",
}],
"subtasks": [{
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
"id": "visit", "title": "现场检查", "summary": "测试数据,未现场执行",
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
"location": "测试位置", "assignee": "测试班组", "duration_minutes": 30,
"reply_window_minutes": 15, "reply_requirements": ["检查记录"],
@@ -420,6 +408,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
}],
}
snapshot = work_order_snapshot(order)
assert snapshot["origin"] == "system_data"
assert snapshot["feedback_requirements"] == ["回填现场检查结果"]
assert snapshot["activity"] == order["activity"]
assert "subtasks" not in snapshot
@@ -1,6 +1,7 @@
from uuid import uuid4
import pytest
from pydantic import ValidationError
from samples.peripheral.auth import Identity
from samples.peripheral.config import SampleSettings
@@ -29,6 +30,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
feedback_requirements=["回传开度照片"],
))
assert created["feedback_requirements"] == ["回传开度照片"]
assert created["origin"] == "system_data"
assert created["activity"][0]["kind"] == "created"
assert "subtasks" not in created
@@ -37,6 +39,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
progress_note="已复核开度,等待回执确认",
))
assert updated["status"] == "awaiting_reply"
assert updated["origin"] == "system_data"
assert [entry["sequence"] for entry in updated["activity"]] == [1, 2]
assert updated["activity"][1]["kind"] == "feedback"
assert updated["activity"][1]["detail"] == "已复核开度,等待回执确认"
@@ -68,3 +71,21 @@ def test_legacy_order_reads_flat_without_claiming_unrecorded_progress():
assert "subtasks" not in projected
assert projected["feedback_requirements"] == []
assert projected["activity"] == []
def test_work_order_contract_rejects_synthetic_origin():
with pytest.raises(ValidationError):
CreateWorkOrder(
request_id=uuid4(), title="来源校验", summary="只接受系统数据", origin="synthetic",
)
def test_legacy_work_order_origin_is_normalized_for_reads():
legacy = {
"work_order_id": str(uuid4()), "project_id": str(uuid4()), "revision": 1,
"title": "历史工单", "summary": "迁移前记录", "status": "open", "priority": "normal",
"assignee": None, "scheduled_at": None, "origin": "synthetic",
"created_at": "2026-09-18T00:00:00Z", "updated_at": "2026-09-18T00:00:00Z",
"created_by": str(uuid4()), "updated_by": str(uuid4()),
}
assert WorkOrder.model_validate(legacy).origin == "system_data"
+43
View File
@@ -40,6 +40,26 @@ def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable(
assert InvestigationReportRevision.model_validate(old).report.ui_blocks == []
@pytest.mark.parametrize("phase", [None, "final"])
def test_report_ui_accepts_final_and_legacy_phases(phase):
data = rich_report()
if phase is not None:
data["ui_blocks"][0]["block"]["phase"] = phase
report = InvestigationReportInput.model_validate(data)
assert report.ui_blocks[0].block.phase == phase
serialized = report.model_dump()["ui_blocks"][0]["block"]
assert ("phase" in serialized) == (phase is not None)
assert InvestigationReportInput.model_validate(report.model_dump()).ui_blocks[0].block.phase == phase
@pytest.mark.parametrize("phase", ["process", "unknown", None])
def test_report_ui_rejects_process_and_unknown_phases(phase):
data = rich_report()
data["ui_blocks"][0]["block"]["phase"] = phase
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(data)
@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"])
def test_report_ui_rejects_invalid_or_unbound_content(defect):
data = rich_report()
@@ -72,3 +92,26 @@ def test_report_ui_rejects_invalid_or_unbound_content(defect):
data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}]
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(data)
def test_report_ui_accepts_semantic_results_without_allowing_raw_presentation_code():
data = rich_report()
spec = data["ui_blocks"][0]["block"]["spec"]
spec["root"] = "layout"
spec["elements"] = {
"layout": {"type": "Stack", "props": {"columns": "one", "gap": "normal", "intent": "report", "density": "compact"}, "children": ["section"]},
"section": {"type": "Section", "props": {"title": "演练结果", "description": None, "role": "result"}, "children": ["result", "note"]},
"result": {"type": "Result", "props": {"title": "演练记录已保存", "summary": "现场处置仍待核验。", "status": "partial", "source": "演练回执"}, "children": []},
"note": {"type": "Text", "props": {"title": "核验说明", "text": "结果仅限演练。", "tone": "neutral"}, "children": []},
}
restored = InvestigationReportInput.model_validate(data)
assert restored.ui_blocks[0].block.spec.elements["result"].props["status"] == "partial"
for extra in ({"style": {"color": "red"}}, {"className": "text-red-500"}, {"css": "display:none"}, {"action": "dispatch"}):
invalid = deepcopy(data)
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["props"].update(extra)
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(invalid)
invalid = deepcopy(data)
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["children"] = ["note"]
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(invalid)
+179
View File
@@ -0,0 +1,179 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import pytest
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailureCommand, OperationalTaskRetryCommand
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
from app.services.operational_tasks import OperationalTaskService
from app.services.task_questions import create_question, answer_question
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
@pytest.fixture
def anyio_backend():
return "asyncio"
class QuestionRepository(RunTaskRepository):
def __init__(self, task, run):
super().__init__(task, run)
self.questions = {}
async def list_questions(self, task_id):
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
async def get_question(self, task_id, question_id):
q = self.questions.get(question_id)
return deepcopy(q) if q and q["task_id"] == task_id else None
async def save_question(self, task_id, document):
assert document["task_id"] == task_id
self.questions[document["question_request_id"]] = deepcopy(document)
def setup():
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repo = QuestionRepository(task, _active_run(task, now))
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
mode="single", allow_custom=True, required=True)])
return service, repo, UUID(task["task_id"]), command
async def finish(service, repo, task_id):
return await service.complete_run(task_id, UUID(repo.run["run_id"]),
AgentRunCommand(request_id=uuid4(), expected_revision=repo.run["revision"], source_session_id="session-1"))
def reply(**overrides):
return TaskQuestionReply(request_id=uuid4(), expected_version=1,
answers=[{"question_id": "flow", "selected_option_ids": ["unknown"], "custom_text": "需要采用情景假设"}], **overrides)
@pytest.mark.anyio
async def test_question_waits_without_final_report_and_answer_requeues_once_without_fabricating_evidence():
service, repo, task_id, command = setup()
question = await create_question(service, task_id, command)
assert (await create_question(service, task_id, command)).question_request_id == question.question_request_id
assert len(repo.notifications) == 1
with pytest.raises(DomainError, match="waiting_human"):
await answer_question(service, task_id, question.question_request_id, reply())
waiting = await finish(service, repo, task_id)
assert waiting.lifecycle_status == "waiting_human" and waiting.active_run is None
assert repo.run["status"] == "succeeded" and repo.run["failure_code"] is None
assert len(repo.notifications) == 1 and repo.notifications[0]["subject_type"] == "question"
answer = reply()
resumed = await answer_question(service, task_id, question.question_request_id, answer)
replay = await answer_question(service, task_id, question.question_request_id, answer)
assert resumed.execution_generation == replay.execution_generation == 2
assert resumed.active_run.status == "queued"
assert resumed.questions[0].answers[0].custom_text == "需要采用情景假设"
assert resumed.questions[0].answered_by == service._actor()
assert not repo.evidence and not resumed.actions
assert repo.notifications[0]["lifecycle_status"] == "resolved"
@pytest.mark.anyio
@pytest.mark.parametrize("answers", [[],
[{"question_id": "foreign", "selected_option_ids": [], "custom_text": "x"}],
[{"question_id": "flow", "selected_option_ids": ["bogus"], "custom_text": ""}],
[{"question_id": "flow", "selected_option_ids": ["unknown", "unknown"], "custom_text": ""}],
])
async def test_invalid_answers_do_not_resume_or_resolve_notification(answers):
service, repo, task_id, command = setup()
question = await create_question(service, task_id, command)
await finish(service, repo, task_id)
with pytest.raises(DomainError) as caught:
await answer_question(service, task_id, question.question_request_id,
TaskQuestionReply(request_id=uuid4(), expected_version=1, answers=answers))
assert caught.value.code == "ANSWER_INVALID"
assert repo.task["execution_generation"] == 1
assert repo.notifications[0]["lifecycle_status"] == "active"
@pytest.mark.anyio
async def test_permissions_foreign_session_duplicate_pending_and_stale_generation():
service, repo, task_id, command = setup()
service.permissions = frozenset()
with pytest.raises(DomainError) as caught:
await create_question(service, task_id, command)
assert caught.value.code == "PERMISSION_DENIED"
service.permissions = {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}
with pytest.raises(DomainError) as caught:
await create_question(service, task_id, command.model_copy(update={"source_session_id": "foreign"}))
assert caught.value.code == "SESSION_MISMATCH"
question = await create_question(service, task_id, command)
with pytest.raises(DomainError) as caught:
await create_question(service, task_id, command.model_copy(update={"request_id": uuid4()}))
assert caught.value.code == "QUESTION_PENDING"
await finish(service, repo, task_id)
await service.retry_task(task_id, OperationalTaskRetryCommand(request_id=uuid4(), reason="重新调查"))
with pytest.raises(DomainError) as caught:
await answer_question(service, task_id, question.question_request_id, reply())
assert caught.value.code == "VERSION_CONFLICT"
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
assert repo.notifications[0]["lifecycle_status"] == "resolved"
@pytest.mark.anyio
async def test_pending_calculations_must_finish_before_asking():
service, repo, task_id, command = setup()
async def pending(*_args):
return [str(uuid4())]
repo.pending_calculation_jobs = pending
with pytest.raises(DomainError) as caught:
await create_question(service, task_id, command)
assert caught.value.code == "PENDING_CALCULATIONS"
assert not repo.questions and not repo.notifications
@pytest.mark.anyio
async def test_answer_requires_write_permission_and_matching_version():
service, repo, task_id, command = setup()
question = await create_question(service, task_id, command)
await finish(service, repo, task_id)
service.permissions = {OPERATIONAL_TASK_EXECUTE}
with pytest.raises(DomainError) as caught:
await answer_question(service, task_id, question.question_request_id, reply())
assert caught.value.code == "PERMISSION_DENIED"
service.permissions = {OPERATIONAL_TASK_WRITE}
with pytest.raises(DomainError) as caught:
await answer_question(service, task_id, question.question_request_id, reply().model_copy(update={"expected_version": 9}))
assert caught.value.code == "VERSION_CONFLICT"
assert repo.task["execution_generation"] == 1
@pytest.mark.anyio
async def test_real_execution_failure_cancels_question_instead_of_disguising_failure():
service, repo, task_id, command = setup()
question = await create_question(service, task_id, command)
result = await service.fail_run(task_id, UUID(repo.run["run_id"]), AgentRunFailureCommand(
request_id=uuid4(), expected_revision=2, source_session_id="session-1",
failure_code="SOURCE_UNAVAILABLE", failure_detail="Required tool failed"))
assert result.lifecycle_status == "failed"
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
assert repo.notifications[0]["lifecycle_status"] == "resolved"
@pytest.mark.anyio
async def test_multiple_selection_and_optional_question_are_preserved():
service, repo, task_id, command = setup()
question_data = command.questions[0].model_dump()
question_data.update(mode="multiple", allow_custom=False,
options=[{"id": "in", "label": "入口计量"}, {"id": "out", "label": "出口计量"}])
command = TaskQuestionCreate(**{**command.model_dump(), "questions": [question_data,
{**question_data, "question_id": "optional", "required": False}]})
question = await create_question(service, task_id, command)
await finish(service, repo, task_id)
result = await answer_question(service, task_id, question.question_request_id, TaskQuestionReply(
request_id=uuid4(), expected_version=1,
answers=[{"question_id": "flow", "selected_option_ids": ["in", "out"], "custom_text": ""}]))
assert result.execution_generation == 2
assert result.questions[0].answers[0].selected_option_ids == ["in", "out"]
+179
View File
@@ -1,3 +1,5 @@
import pytest
from app.algorithms.valve_isolation import topology_search as valve
@@ -23,6 +25,183 @@ def test_isolatable_keeps_affected_node_ids_and_count():
assert result["must_close_valves"] == ["V-close"]
@pytest.fixture
def colliding_topology():
# The reported adjacent pipes and their same-numbered nodes are in
# different valve-bounded regions. Keep this fixture independent of a DB.
return [
"19025:pipe:17038:17037",
"19026:pipe:17038:17039",
"AUTO_TCV_4:valve:17037:D",
"AUTO_TCV_5:valve:17039:E",
"AUTO_TCV_546:valve:17038:R",
"32252:pipe:19025:X",
"15383:pipe:19026:Y",
"V-wrong-25:valve:X:R",
"V-wrong-26:valve:Y:R",
]
@pytest.mark.parametrize("target", ["19025", "19026", ["19025", "19026"]])
def test_adjacent_pipes_take_priority_over_same_numbered_nodes(colliding_topology, target):
result = valve.valve_isolation_analysis(colliding_topology, target)
assert result["affected_nodes"] == ["17037", "17038", "17039"]
assert result["must_close_valves"] == ["AUTO_TCV_4", "AUTO_TCV_5", "AUTO_TCV_546"]
assert result["optional_valves"] == []
def test_explicit_node_type_resolves_the_other_side_of_an_id_collision(colliding_topology):
result = valve.valve_isolation_analysis(
colliding_topology, "19025", accident_element_type="node",
)
assert result["affected_nodes"] == ["19025", "X"]
assert result["must_close_valves"] == ["V-wrong-25"]
def test_unambiguous_legacy_node_input_still_works():
result = valve.valve_isolation_analysis(["P:pipe:A:B", "V:valve:B:R"], "A")
assert result["affected_nodes"] == ["A", "B"]
def test_explicit_link_type_does_not_fall_back_to_a_node():
with pytest.raises(ValueError, match="not found"):
valve.valve_isolation_analysis(
["P:pipe:A:B"], "A", accident_element_type="link",
)
def test_a_boundary_valve_does_not_prove_source_isolation():
result = valve.valve_isolation_analysis(
["P:pipe:R:A", "V:valve:A:B"], "P", water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["source_check_performed"] is True
assert result["isolation_failure_reason"] == "connected_to_water_source"
assert result["affected_nodes"] == []
assert result["affected_node_count"] == 2
assert result["supply_affected_nodes"] is None
def test_supply_impact_includes_downstream_regions_but_not_preexisting_outages():
links = [
"P:pipe:A:B", "V-source:valve:A:R", "V-downstream:valve:B:C",
"P-downstream:pipe:C:D", "P-already-off:pipe:X:Y",
]
result = valve.valve_isolation_analysis(links, "P", water_source_nodes=["R"])
assert result["isolatable"] is True
assert result["source_check_performed"] is True
assert result["affected_nodes"] == ["A", "B"]
assert result["affected_node_count"] == 2
assert result["supply_affected_nodes"] == ["A", "B", "C", "D"]
assert result["supply_affected_node_count"] == 4
def test_closed_links_are_not_traversed_or_recommended_for_closure():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R", "V-closed:valve:B:C"],
"P", water_source_nodes=["R"], closed_links=["P-closed", "V-closed"],
)
assert result["isolatable"] is True
assert result["affected_nodes"] == ["A", "B"]
assert result["must_close_valves"] == ["V"]
assert result["supply_affected_nodes"] == ["A", "B"]
def test_disabled_valve_cannot_cut_the_source_path():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V-disabled:valve:A:R", "V-other:valve:B:C"],
"P", disabled_valves=["V-disabled"], water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["isolation_failure_reason"] == "connected_to_water_source"
def test_joint_accidents_fail_if_any_region_remains_connected_to_a_source():
result = valve.valve_isolation_analysis(
["P1:pipe:A:B", "V1:valve:B:R", "P2:pipe:R:C", "V2:valve:C:D"],
["P1", "P2"], water_source_nodes=["R"],
)
assert result["isolatable"] is False
def test_missing_sources_do_not_claim_verified_isolation():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:C"], "P", water_source_nodes=[],
)
assert result["isolatable"] is False
assert result["source_check_performed"] is False
assert result["isolation_failure_reason"] == "water_sources_unavailable"
assert result["supply_affected_node_count"] is None
@pytest.mark.parametrize("sources", [["R1"], ["R1", "R2"]])
def test_all_water_sources_are_considered(sources):
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:A:R1", "P2:pipe:B:R2"],
"P", water_source_nodes=sources,
)
assert result["isolatable"] is (len(sources) == 1)
def test_empty_targets_are_rejected():
with pytest.raises(ValueError, match="At least one"):
valve.valve_isolation_analysis(["P:pipe:A:B"], [])
def test_unknown_disabled_valve_is_rejected():
with pytest.raises(ValueError, match="Disabled valve"):
valve.valve_isolation_analysis(["P:pipe:A:B"], "P", disabled_valves=["typo"])
def test_service_passes_authoritative_sources_statuses_and_element_type(monkeypatch):
from app.services import valve_isolation as service
projects = []
def snapshot(network):
projects.append(network)
return (
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R"],
["R"], ["P-closed"],
)
monkeypatch.setattr(service, "get_valve_isolation_topology", snapshot)
result = service.analyze_valve_isolation(
"trusted-project", ["A"], accident_element_type="node",
)
assert projects == ["trusted-project"]
assert result["isolatable"] is True
assert result["source_check_performed"] is True
assert result["must_close_valves"] == ["V"]
assert result["supply_affected_nodes"] == ["A", "B"]
def test_parallel_links_preserve_an_open_bypass():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:R", "P-bypass:pipe:B:R"],
"P", water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["isolation_failure_reason"] == "connected_to_water_source"
def test_closed_disabled_valve_does_not_reopen_a_known_closed_link():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:R", "V-disabled:valve:A:R"],
"P", disabled_valves=["V-disabled"], closed_links=["V-disabled"],
water_source_nodes=["R"],
)
assert result["isolatable"] is True
assert result["must_close_valves"] == ["V"]
def test_disabled_valve_expands_affected_area_before_counting():
links = [
"P-1:pipe:A:B",
+30
View File
@@ -52,6 +52,36 @@ def test_network_link_nodes_use_one_unified_view_query(monkeypatch) -> None:
assert "FROM gis.network_links" in calls[0][1]
def test_isolation_snapshot_reads_authoritative_links_statuses_and_sources_once(monkeypatch):
calls = []
def fake_read_all(name, statement, params=None):
calls.append((name, statement, params))
return [
{"id": "P", "element_type": "pipe", "start_node_id": "A", "end_node_id": "B", "status": None},
{"id": "P-closed", "element_type": "pipe", "start_node_id": "A", "end_node_id": "R", "status": "CLOSED"},
{"id": "V", "element_type": "valve", "start_node_id": "B", "end_node_id": "R", "status": "CLOSED"},
{"id": "PU", "element_type": "pump", "start_node_id": "T", "end_node_id": "A", "status": "CLOSED"},
{"id": "R", "element_type": "reservoir", "start_node_id": None, "end_node_id": None, "status": None},
{"id": "T", "element_type": "tank", "start_node_id": None, "end_node_id": None, "status": None},
]
monkeypatch.setattr(network_views, "read_all", fake_read_all)
assert network_views.get_valve_isolation_topology("project_a") == (
["P:pipe:A:B", "P-closed:pipe:A:R", "V:valve:B:R", "PU:pump:T:A"],
["R", "T"], ["P-closed", "V", "PU"],
)
assert len(calls) == 1
name, statement, _params = calls[0]
assert name == "project_a"
assert "FROM network.links" in statement
assert "network.link_initial_settings" in statement
assert "COALESCE(s.status::text, p.status::text)" in statement
assert "FROM network.nodes" in statement
assert "UNION ALL" in statement
def test_topology_rows_are_loaded_in_two_batch_queries(monkeypatch) -> None:
calls = []
responses = [