feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
+31
View File
@@ -44,3 +44,34 @@ def test_project_workspace_sets_30_day_client_cache(monkeypatch):
assert layers == geoserver_client.PROJECT_LAYER_NAMES
assert cache_updates == ["2592000"] * len(layers)
def test_publish_feature_type_updates_existing_layer_and_cache(monkeypatch):
requests: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path))
if request.method == "GET" and request.url.path.endswith("/featuretypes/demo_layer.json"):
return httpx.Response(200, json={"featureType": {"name": "demo_layer"}})
if request.method == "GET" and "/gwc/rest/layers/" in request.url.path:
return httpx.Response(200, content=b"<GeoServerLayer><name>demo</name></GeoServerLayer>")
return httpx.Response(200)
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_CLIENT_CACHE_SECONDS", 2_592_000)
http_client = httpx.Client(
base_url="http://geoserver.example/geoserver",
transport=httpx.MockTransport(handler),
)
geoserver_client.GeoServerAdminClient(client=http_client).publish_feature_type(
workspace="demo",
datastore="demo",
layer="demo_layer",
title="Demo layer",
map_bbox=(1.0, 2.0, 3.0, 4.0),
)
assert ("PUT", "/geoserver/rest/workspaces/demo/datastores/demo/featuretypes/demo_layer") in requests
assert ("PUT", "/geoserver/gwc/rest/layers/demo:demo_layer.xml") in requests
@@ -0,0 +1,45 @@
import asyncio
from datetime import date
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from tests.conftest import FakeConnection, FakeCursor
def test_list_element_groups_returns_empty_when_source_gis_is_not_provisioned():
cursor = FakeCursor(fetchone_results=[{"relation": None}])
result = asyncio.run(
GisAttributeRepository.list_element_groups(
FakeConnection(cursor), "pipes", "P-1"
)
)
assert result == []
assert len(cursor.executed) == 1
def test_list_element_groups_reads_linked_attributes():
row = {
"dataset_key": "fx_pipe_inventory_20200811",
"dataset_title": "奉贤 DN100 及以上管网台账",
"source_date": date(2020, 8, 11),
"layer_key": "fx_20200811_dn100_pipes",
"layer_name": "DN100 及以上管线台账",
"source_fid": 42,
"match_method": "geometry",
"match_distance_m": 0.12,
"attributes": {"PIPE_D": 300, "MATERIAL": "DI"},
}
cursor = FakeCursor(
fetchone_results=[{"relation": "source_gis.element_links"}],
fetchall_results=[[row]],
)
result = asyncio.run(
GisAttributeRepository.list_element_groups(
FakeConnection(cursor), "pipes", "P-42"
)
)
assert result == [row]
assert cursor.executed[1][1] == ("pipes", "P-42")
+154
View File
@@ -0,0 +1,154 @@
import json
import inspect
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from app.services.content_digest import digest
from samples.peripheral import auth
from samples.peripheral.api import create_app, store
from samples.peripheral.config import SampleSettings
from samples.peripheral.demo_data import VERSION, build_demo_content
from samples.peripheral.errors import SampleFailure
from samples.peripheral.store import PostgresStore
START = datetime(2026, 9, 17, tzinfo=timezone.utc)
def test_same_seed_start_and_version_are_stable_across_processes():
content = build_demo_content("repeatable", START)
assert content == build_demo_content("repeatable", START)
assert content["generator_version"] == VERSION
expected_digest = digest(content)
script = """
import json
from datetime import datetime
from app.services.content_digest import digest
from samples.peripheral.demo_data import build_demo_content
content = build_demo_content('repeatable', datetime.fromisoformat('2026-09-17T00:00:00+00:00'))
print(json.dumps({'digest': digest(content), 'ids': [x['record_id'] for x in content['observations'][:3]]}))
"""
result = subprocess.run(
[sys.executable, "-c", script], check=True, capture_output=True, text=True,
)
child = json.loads(result.stdout)
assert child["digest"] == expected_digest
assert child["ids"] == [item["record_id"] for item in content["observations"][:3]]
assert all(UUID(item["record_id"]).version == 5 for item in content["observations"])
def test_default_canonical_digest_is_versioned_regression_anchor():
content = build_demo_content()
assert digest(content) == "sha256:c630f66ad0247f70a4afe79cc5f95f4be35d54acfe85fbfe39f3166ee09a57c5"
assert (len(content["points"]), len(content["observations"]), len(content["device_states"])) == (5, 240, 9)
def test_seed_changes_content_and_timezone_is_normalized():
base = build_demo_content("seed-a", START)
assert digest(base) != digest(build_demo_content("seed-b", START))
same_in_china = build_demo_content("seed-a", START.astimezone(timezone(timedelta(hours=8))))
assert same_in_china == base
assert base["window"]["start"] == "2026-09-17T00:00:00Z"
@pytest.mark.parametrize("seed", ["", "x" * 129, None, 7])
def test_invalid_seed_is_rejected(seed):
with pytest.raises(ValueError, match="seed"):
build_demo_content(seed, START)
@pytest.mark.parametrize("start", [
datetime(2026, 9, 17),
datetime(2026, 9, 17, 0, 1, tzinfo=timezone.utc),
datetime(2026, 9, 17, 0, 0, 1, tzinfo=timezone.utc),
])
def test_invalid_start_is_rejected(start):
with pytest.raises(ValueError, match="timezone|aligned"):
build_demo_content("seed", start)
def test_story_counts_null_zero_and_temporal_links_are_explicit():
content = build_demo_content("story", START)
assert len(content["points"]) == 5
assert len(content["observations"]) == 240
assert len(content["device_states"]) == 9
assert len(content["alerts"]) == 2
assert len(content["operations"]) == 4
missing = [item for item in content["observations"] if item["value"] is None]
zero = [item for item in content["observations"] if item["value"] == 0.0]
assert len(missing) == 4 and all(item["quality"] == "missing" for item in missing)
assert len(zero) == 4 and all(item["quality"] == "valid" for item in zero)
states = {(item["asset_id"], item["state"]): item["observed_at"] for item in content["device_states"]}
communication = next(item for item in content["alerts"] if item["code"] == "communication_lost")
assert communication["asset_id"] == "DEMO-P001"
assert communication["occurred_at"] == states[("DEMO-P001", "offline")]
assert communication["cleared_at"] == states[("DEMO-P001", "online")]
operations = {(item["asset_id"], item["operation"]): item["occurred_at"] for item in content["operations"]}
assert operations[("DEMO-PUMP-01", "pump_stop")] == states[("DEMO-PUMP-01", "stopped")]
assert operations[("DEMO-VALVE-01", "valve_close")] == states[("DEMO-VALVE-01", "closed")]
assert len({item["record_id"] for item in content["observations"] + content["device_states"] + content["operations"]}) == 253
class FakeStore:
def __init__(self, result=None, failure=None):
self.result = result
self.failure = failure
self.reads = 0
def demo_data(self, who):
self.reads += 1
if self.failure:
raise self.failure
return {**self.result, "project_id": who.project_id,
"source_id": "test:demo", "source_revision": digest(self.result)}
def test_demo_endpoint_uses_next_auth_and_allows_read_only_permission(monkeypatch):
project_id, user_id = uuid4(), uuid4()
fake = FakeStore(build_demo_content("api", START))
app = create_app(SampleSettings(project_code="tjwater_v2"))
app.dependency_overrides[store] = lambda: fake
monkeypatch.setattr(auth, "next_get", lambda request, path, params=None: {
"project_id": str(project_id), "user_id": str(user_id), "network": "tjwater_v2",
"permissions": ["simulation.view"],
})
response = TestClient(app).get("/api/sample/v1/demo-data", headers={
"Authorization": "Bearer TEST", "X-Project-Id": str(project_id),
})
assert response.status_code == 200
assert response.json()["project_id"] == str(project_id)
assert fake.reads == 1
def test_demo_endpoint_requires_auth_and_reports_missing_storage_without_seeding():
fake = FakeStore(failure=SampleFailure(
"STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503,
))
app = create_app(SampleSettings(project_code="tjwater_v2"))
app.dependency_overrides[store] = lambda: fake
client = TestClient(app)
unauthorized = client.get("/api/sample/v1/demo-data")
assert unauthorized.status_code == 401
assert fake.reads == 0
# Authentication is deliberately isolated here so this assertion tests the
# missing-table behavior: the API reports it and never invokes a generator.
from samples.peripheral.api import identity
from samples.peripheral.auth import Identity
app.dependency_overrides[identity] = lambda: Identity(str(uuid4()), str(uuid4()), False)
unavailable = client.get("/api/sample/v1/demo-data")
assert unavailable.status_code == 503
assert unavailable.json()["code"] == "STORAGE_NOT_CONFIGURED"
assert fake.reads == 1
def test_demo_read_path_contains_no_schema_or_seed_writes():
source = inspect.getsource(PostgresStore.demo_data).upper()
assert "SELECT " in source
assert all(keyword not in source for keyword in ("CREATE ", "ALTER ", "INSERT ", "UPDATE ", "DELETE "))
@@ -0,0 +1,129 @@
from uuid import uuid4
from fastapi.testclient import TestClient
from samples.peripheral.api import create_app, store
from samples.peripheral.auth import Identity, identity
from samples.peripheral.schemas import CreateTestDataRecord, UpdateTestDataRecord, UpdateWorkOrder
PROJECT_ID = uuid4()
USER_ID = uuid4()
class ManagedStore:
def __init__(self):
self.calls = []
def list_work_orders(self, who, limit, cursor):
self.calls.append(("list_work_orders", who, limit, cursor))
return {"items": [], "next_cursor": None}
def create_work_order(self, who, command):
self.calls.append(("create_work_order", who, command))
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,
"created_by": str(USER_ID), "updated_by": str(USER_ID),
}
def update_work_order(self, who, work_order_id, command):
self.calls.append(("update_work_order", who, work_order_id, command))
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
"title": command.title or "updated", "summary": command.summary or "summary",
"status": command.status or "open", "priority": command.priority or "normal",
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
"work_order_id": str(work_order_id)}
def delete_work_order(self, who, work_order_id, command):
self.calls.append(("delete_work_order", who, work_order_id, command))
return {"resource_type": "work_order", "resource_id": str(work_order_id),
"revision": command.expected_revision + 1, "deleted": True}
def list_test_data(self, who, limit, cursor):
self.calls.append(("list_test_data", who, limit, cursor))
return {"items": [], "next_cursor": None}
def create_test_data(self, who, command):
self.calls.append(("create_test_data", who, command))
now = "2026-09-18T00:00:00Z"
return command.model_dump(mode="json", exclude={"request_id"}) | {
"record_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now,
"created_by": str(USER_ID), "updated_by": str(USER_ID),
}
def update_test_data(self, who, record_id, command):
self.calls.append(("update_test_data", who, record_id, command))
return self.create_test_data(who, CreateTestDataRecord(
request_id=command.request_id, label=command.label or "updated", point_id=command.point_id or "演示",
observed_at=command.observed_at or "2026-09-18T00:00:00Z", value=command.value,
unit=command.unit or "m", quality=command.quality or "valid", notes=command.notes or "",
)) | {"revision": command.expected_revision + 1, "record_id": str(record_id)}
def delete_test_data(self, who, record_id, command):
self.calls.append(("delete_test_data", who, record_id, command))
return {"resource_type": "test_data_record", "resource_id": str(record_id),
"revision": command.expected_revision + 1, "deleted": True}
def client_with(fake):
app = create_app()
app.dependency_overrides[store] = lambda: fake
app.dependency_overrides[identity] = lambda: Identity(str(PROJECT_ID), str(USER_ID), True)
return TestClient(app)
def test_managed_work_order_and_data_routes_are_typed_crud():
fake = ManagedStore()
with client_with(fake) as client:
assert client.get("/api/sample/v1/work-orders").json()["items"] == []
created = client.post("/api/sample/v1/work-orders", json={
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
})
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
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": "in_progress",
"clear_assignee": False, "clear_scheduled_at": False,
})
assert changed.status_code == 200 and changed.json()["revision"] == 2
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 2,
})
assert deleted.json()["resource_type"] == "work_order"
assert client.get("/api/sample/v1/test-data").json()["items"] == []
data = client.post("/api/sample/v1/test-data", json={
"request_id": str(uuid4()), "label": "演示 压力", "point_id": "演示",
"observed_at": "2026-09-18T00:00:00Z", "value": 31.2, "unit": "m",
"quality": "valid", "notes": "联调",
})
assert data.status_code == 201 and data.json()["point_id"] == "演示"
record_id = data.json()["record_id"]
changed_data = client.patch(f"/api/sample/v1/test-data/{record_id}", json={
"request_id": str(uuid4()), "expected_revision": 1, "value": 30.8, "clear_value": False,
})
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
deleted_data = client.request("DELETE", f"/api/sample/v1/test-data/{record_id}", json={
"request_id": str(uuid4()), "expected_revision": 2,
})
assert deleted_data.json()["resource_type"] == "test_data_record"
def test_managed_update_models_reject_empty_or_conflicting_changes():
request_id = uuid4()
for model, payload in (
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1}),
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
"value": 1.0, "clear_value": True}),
):
try:
model.model_validate(payload)
except ValueError:
pass
else:
raise AssertionError("invalid partial update was accepted")
@@ -40,7 +40,7 @@ def test_list_results_uses_typed_comparison_when_result_type_is_present():
query, params = conn.cursor_instance.calls[0]
assert "result_type = %s" in query
assert "%s IS NULL" not in query
assert params == (run_id, "leakage_identification")
assert params == (run_id, "leakage_identification", 100, 0)
def test_list_results_omits_result_type_filter_when_not_requested():
@@ -51,4 +51,56 @@ def test_list_results_omits_result_type_filter_when_not_requested():
query, params = conn.cursor_instance.calls[0]
assert "result_type = %s" not in query
assert params == (run_id,)
assert params == (run_id, 100, 0)
def test_list_runs_and_results_apply_explicit_page_bounds():
conn = _FakeConnection()
run_id = uuid4()
asyncio.run(AnalysisRepository.list_runs(conn, limit=25, offset=50))
asyncio.run(
AnalysisRepository.list_results(conn, run_id, limit=40, offset=80)
)
run_query, run_params = conn.cursor_instance.calls[0]
result_query, result_params = conn.cursor_instance.calls[1]
assert "LIMIT %s OFFSET %s" in run_query
assert run_params == (25, 50)
assert "LIMIT %s OFFSET %s" in result_query
assert result_params == (run_id, 40, 80)
def test_list_recent_runs_is_bounded_and_orders_newest_first():
conn = _FakeConnection()
asyncio.run(AnalysisRepository.list_recent_runs(conn, limit=25))
query, params = conn.cursor_instance.calls[0]
assert "ORDER BY created_at DESC, run_id" in query
assert "LIMIT %s" in query
assert params == (25,)
def test_list_recent_results_bounds_latest_rows_then_restores_display_order():
conn = _FakeConnection()
run_id = uuid4()
asyncio.run(AnalysisRepository.list_recent_results(conn, run_id, limit=25))
query, params = conn.cursor_instance.calls[0]
assert "ORDER BY created_at DESC, result_id DESC" in query
assert "ORDER BY created_at, result_id" in query
assert params == (run_id, 25)
def test_list_run_summaries_is_bounded_and_checks_for_results():
conn = _FakeConnection()
asyncio.run(AnalysisRepository.list_run_summaries(conn, limit=11, offset=20))
query, params = conn.cursor_instance.calls[0]
assert "EXISTS" in query
assert "analysis.results" in query
assert "ORDER BY r.created_at DESC, r.run_id" in query
assert params == (11, 20)
@@ -159,9 +159,13 @@ def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch):
min_diameter=300,
created_by="alice",
sensor_locations=["J1", "J2"],
sensor_type="water_quality",
method="sensitivity",
)
assert created["sensor_count"] == 2
assert created["sensor_type"] == "water_quality"
assert created["method"] == "sensitivity"
statements = [call.args[0] for call in cursor.execute.call_args_list]
assert "INSERT INTO analysis.runs" in statements[0]
assert "INSERT INTO analysis.results" in statements[1]
+140
View File
@@ -0,0 +1,140 @@
from contextlib import asynccontextmanager
from copy import deepcopy
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.task_alert import TaskAlertEvent
from app.services.task_alerts import TaskAlertService
NOW = datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc)
USER_ID = uuid4()
@pytest.fixture
def anyio_backend():
return "asyncio"
class Repository:
def __init__(self):
self.events = {}
self.alerts = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock_alert(self, _alert_id):
return None
async def get_event(self, producer_id, event_id):
return deepcopy(self.events.get((producer_id, event_id)))
async def get_alert(self, alert_id):
value = self.alerts.get(alert_id)
return (value["revision"], deepcopy(value["document"])) if value else None
async def save_event(self, event, resulting_state):
self.events[(event["producer_id"], event["event_id"])] = deepcopy(
event | {"resulting_state": resulting_state}
)
async def save_alert(self, state):
self.alerts[state["alert_id"]] = {
"revision": state["revision"],
"document": deepcopy({key: value for key, value in state.items() if key != "revision"}),
}
def command(
event_type="raised",
*,
event_id=None,
title="Low pressure",
producer_id=f"principal:{USER_ID}",
):
return TaskAlertEvent(
event_id=event_id or uuid4(),
producer_id=producer_id,
alert_id="schedule-1:2026-09-18T02:30:00Z",
event_type=event_type,
occurred_at=NOW,
condition_id=None,
source={
"task_type": "scheduled_task",
"task_id": "schedule-1",
"run_id": "occurrence-1",
},
title=title,
description="Scheduled task reported a failure.",
severity="warning",
)
@pytest.mark.anyio
async def test_alert_events_are_idempotent_and_transitions_are_monotonic():
repository = Repository()
service = TaskAlertService(
repository,
SimpleNamespace(project_role="member", user_id=USER_ID),
clock=lambda: NOW,
)
raised_command = command()
raised = await service.push(raised_command)
repeated = await service.push(raised_command)
assert raised.status == repeated.status == "active"
assert raised.revision == repeated.revision == 1
acknowledged = await service.push(command("acknowledged"))
resolved = await service.push(command("resolved"))
assert acknowledged.status == "acknowledged" and acknowledged.revision == 2
assert resolved.status == "resolved" and resolved.revision == 3
with pytest.raises(DomainError) as invalid:
await service.push(command("acknowledged"))
assert invalid.value.code == "INVALID_TRANSITION"
@pytest.mark.anyio
async def test_alert_event_reuse_and_fact_rewrites_are_rejected():
repository = Repository()
service = TaskAlertService(
repository,
SimpleNamespace(project_role="member", user_id=USER_ID),
clock=lambda: NOW,
)
event_id = uuid4()
await service.push(command(event_id=event_id))
with pytest.raises(DomainError) as reused:
await service.push(command(event_id=event_id, title="Changed"))
assert reused.value.code == "IDEMPOTENCY_CONFLICT"
with pytest.raises(DomainError) as rewritten:
await service.push(command("resolved", title="Changed"))
assert rewritten.value.code == "IDEMPOTENCY_CONFLICT"
@pytest.mark.anyio
async def test_internal_alert_producer_is_bound_without_user_membership():
repository = Repository()
service = TaskAlertService(
repository,
None,
clock=lambda: NOW,
internal_producer_id="scheduler:demo",
)
state = await service.push(command(producer_id="scheduler:demo"))
assert state.status == "active"
mismatched = command(producer_id="scheduler:demo")
mismatched.producer_id = "another-task"
with pytest.raises(DomainError) as denied:
await service.push(mismatched)
assert denied.value.code == "PERMISSION_DENIED"
+284
View File
@@ -0,0 +1,284 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert
from app.services.workbench_runtime import (
build_workbench_runtime_snapshot,
project_latest_analysis,
project_sensor_location_map_payload,
sensor_location_ids_for_map_projection,
)
NOW = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
@pytest.fixture
def anyio_backend():
return "asyncio"
def test_workbench_alert_rejects_naive_datetimes():
with pytest.raises(ValidationError):
WorkbenchPersistedAlert.model_validate({
"alert_id": "alert-1",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "schedule-1",
"run_id": "occurrence-1",
},
"title": "test",
"description": "test",
"occurred_at": "2026-09-18T02:30:00",
"severity": "warning",
"status": "active",
})
@pytest.mark.anyio
async def test_snapshot_reports_not_configured_without_persisted_sections():
snapshot = await build_workbench_runtime_snapshot(
clock=lambda: NOW,
)
assert snapshot == {
"availability": "not_configured",
"source": "none",
"scenario_availability": "not_configured",
"artifact_availability": "not_configured",
"schedule_availability": "not_configured",
"work_order_availability": "not_configured",
"alert_availability": "not_configured",
"generated_at": NOW,
"conditions": [],
"alerts": [],
"scenarios": [],
"latest_analysis": None,
"work_orders": [],
}
def analysis_run(**overrides):
value = {
"run_id": uuid4(),
"name": "北区爆管定位",
"run_type": "burst_location",
"created_by": "alice",
"created_at": NOW,
"started_at": NOW,
"status": "completed",
"parameters": {},
}
value.update(overrides)
return value
@pytest.mark.anyio
async def test_snapshot_keeps_analysis_results_out_of_operational_conditions():
failed_analysis = analysis_run(status="failed")
result_created_at = NOW.replace(minute=31)
snapshot = await build_workbench_runtime_snapshot(
analysis_runs=[failed_analysis],
latest_analysis_results=[
{
"result_type": "burst_location",
"created_at": result_created_at,
"payload": {
"drawing_payload": {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"properties": {"label": "后端结果"},
"geometry": {
"type": "Point",
"coordinates": [121.5, 30.9],
},
}],
}
},
}
],
clock=lambda: NOW,
)
assert snapshot["source"] == "none"
assert snapshot["scenario_availability"] == "ready"
assert snapshot["artifact_availability"] == "ready"
assert snapshot["schedule_availability"] == "not_configured"
assert snapshot["work_order_availability"] == "not_configured"
assert snapshot["conditions"] == []
assert snapshot["scenarios"] == [
{
"id": f"analysis-run-{failed_analysis['run_id']}",
"run_id": str(failed_analysis["run_id"]),
"name": "北区爆管定位",
"description": "爆管定位 · 已失败 · 创建人 alice",
"status": "review",
"run_type": "burst_location",
"run_type_label": "爆管定位",
"run_status": "failed",
"created_at": NOW,
}
]
assert snapshot["latest_analysis"]["results"] == [
{
"result_type": "burst_location",
"created_at": result_created_at,
"facts": [],
}
]
assert snapshot["latest_analysis"]["map_payload"]["features"][0]["properties"] == {
"label": "后端结果"
}
assert snapshot["alerts"] == []
def test_latest_analysis_projects_only_known_result_facts():
projected = project_latest_analysis(
analysis_run(run_type="sensor_placement"),
[{
"result_type": "sensor_placement",
"node_id": "J-1",
"created_at": NOW,
"payload": {
"sensor_number": 2,
"minimum_diameter": 300,
"sensor_locations": ["J-1", "J-2"],
"internal_debug": "must-not-leak",
},
}],
)
assert projected["results"][0]["facts"] == [
{"label": "关联节点", "value": "J-1"},
{"label": "测点数量", "value": "2"},
{"label": "最小管径参数", "value": "300"},
{"label": "测点位置", "value": "J-1、J-2"},
]
assert "must-not-leak" not in str(projected)
def test_sensor_location_projection_uses_latest_result_order_and_known_nodes():
results = [
{
"result_type": "sensor_placement",
"payload": {"sensor_locations": ["J-old"]},
},
{
"result_type": "sensor_placement",
"payload": {"sensor_locations": ["J-2", "J-1", "J-2"]},
},
]
node_ids = sensor_location_ids_for_map_projection(results)
payload = project_sensor_location_map_payload(node_ids, [
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
])
assert node_ids == ["J-2", "J-1"]
assert payload is not None
assert [feature["id"] for feature in payload["features"]] == ["J-2", "J-1"]
@pytest.mark.anyio
async def test_snapshot_projects_persisted_work_orders_and_alerts():
work_order = {
"id": "wo-1",
"code": "WO-20260917-001",
"scheduled_at": NOW.replace(hour=3),
"title": "北区阀门现场复核",
"summary": "复核阀门开度并回传。",
"status": "running",
"risk_level": "attention",
"updated_at": NOW,
"detail": "来自正式工单同步适配器。",
"recommendation": "完成后复核压力。",
"duration_minutes": 45,
"source": "生产工单系统",
"dispatcher": "调度中心",
"priority": "urgent",
"subtasks": [{
"id": "inspect-valve",
"title": "阀门开度复核",
"summary": "现场复核阀门开度。",
"status": "running",
"scheduled_at": NOW.replace(hour=3),
"location": "北区阀门 V-1",
"assignee": "北区班组",
"duration_minutes": 45,
"reply_window_minutes": 20,
"stages": [
{
"id": "dispatch",
"sequence": 1,
"title": "派发",
"status": "done",
"owner": "调度中心",
"time_label": "10:20",
"description": "已派发。",
},
{
"id": "execution",
"sequence": 2,
"title": "执行中",
"status": "current",
"owner": "北区班组",
"time_label": "10:30—11:15",
"description": "现场执行中。",
},
{
"id": "reply",
"sequence": 3,
"title": "待复令",
"status": "pending",
"owner": "北区班组 → 调度中心",
"time_label": "11:15—11:35",
"description": "等待执行结果。",
},
],
"reply_requirements": ["回传阀门开度", "复核压力"],
}],
}
persisted_alert = {
"alert_id": "alarm-1",
"condition_id": None,
"source": {
"task_type": "scheduled_task",
"task_id": "scada-diagnosis-5m",
"run_id": "trigger-1",
},
"title": "北区设备通信告警",
"description": "由 SCADA 数据诊断任务明确推送。",
"occurred_at": NOW.replace(minute=29),
"severity": "warning",
"status": "active",
}
snapshot = await build_workbench_runtime_snapshot(
work_order_storage_configured=True,
work_orders=[work_order],
alert_storage_configured=True,
persisted_alerts=[persisted_alert],
clock=lambda: NOW,
)
assert snapshot["availability"] == "ready"
assert snapshot["source"] == "none"
assert snapshot["schedule_availability"] == "not_configured"
assert snapshot["work_order_availability"] == "ready"
assert snapshot["alert_availability"] == "ready"
assert snapshot["conditions"] == []
assert snapshot["alerts"] == [{
"id": "backend-alert-alarm-1",
"condition_id": None,
"source": persisted_alert["source"],
"title": "北区设备通信告警",
"description": "由 SCADA 数据诊断任务明确推送。",
"occurred_at": NOW.replace(minute=29),
"severity": "warning",
}]
assert snapshot["work_orders"][0]["code"] == "WO-20260917-001"