|
7 | 7 |
|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
| 10 | +from collections.abc import Mapping |
10 | 11 | from pathlib import Path |
11 | 12 | from typing import Any, cast |
12 | 13 |
|
13 | 14 | import pytest |
14 | 15 | import yaml |
15 | 16 |
|
16 | 17 | from openarmature.graph import ( |
| 18 | + END, |
17 | 19 | CompileError, |
| 20 | + EdgeException, |
| 21 | + EndSentinel, |
| 22 | + GraphBuilder, |
18 | 23 | NodeException, |
19 | 24 | RoutingError, |
20 | 25 | RuntimeGraphError, |
| 26 | + State, |
21 | 27 | SubscribedObserver, |
22 | 28 | ) |
| 29 | +from openarmature.graph.events import NodeEvent |
23 | 30 | from openarmature.graph.observer import Observer |
24 | 31 |
|
25 | 32 | from .adapter import ( |
@@ -71,7 +78,7 @@ def _fixture_id(path: Path) -> str: |
71 | 78 | ) |
72 | 79 |
|
73 | 80 |
|
74 | | -def _unsupported_directive(spec: dict[str, Any]) -> str | None: |
| 81 | +def _unsupported_directive(spec: Mapping[str, Any]) -> str | None: |
75 | 82 | """Return the first node directive the legacy adapter can't translate, |
76 | 83 | or None if every node uses one of the directives it handles. Walks |
77 | 84 | both the top-level graph and an optional inner ``subgraph`` block.""" |
@@ -162,11 +169,34 @@ def _compile_subgraphs_map( |
162 | 169 | async def test_runtime_fixture(fixture_path: Path) -> None: |
163 | 170 | spec = _load(fixture_path) |
164 | 171 |
|
| 172 | + # ``cases:`` form (e.g., 020-observer-edge-error-events): each entry |
| 173 | + # is a self-contained per-case spec. Iterate; treat each case as a |
| 174 | + # standalone fixture body. Wrapping AssertionError with the case |
| 175 | + # name keeps failure messages locatable. |
| 176 | + # Fixture 020 uses edge-condition `callable:` directives |
| 177 | + # (`state_field_read`, `edge_raises`) that are unique to this fixture |
| 178 | + # and don't fit the generic adapter DSL. Custom driver below. |
| 179 | + if fixture_path.stem == "020-observer-edge-error-events": |
| 180 | + await _run_fixture_020(spec) |
| 181 | + return |
| 182 | + |
| 183 | + if "cases" in spec: |
| 184 | + for case in cast("list[dict[str, Any]]", spec["cases"]): |
| 185 | + try: |
| 186 | + await _run_runtime_case(case, fixture_path.stem) |
| 187 | + except AssertionError as e: |
| 188 | + raise AssertionError(f"case {case.get('name')!r}: {e}") from e |
| 189 | + return |
| 190 | + |
| 191 | + await _run_runtime_case(spec, fixture_path.stem) |
| 192 | + |
| 193 | + |
| 194 | +async def _run_runtime_case(spec: Mapping[str, Any], fixture_id: str) -> None: |
165 | 195 | # Skip fixtures whose nodes use directives the legacy adapter doesn't |
166 | 196 | # translate (fan_out, flaky variants, calls_llm, etc.). Each directive |
167 | 197 | # is gated to the phase that lands its runtime support. |
168 | 198 | if (hit := _unsupported_directive(spec)) is not None: |
169 | | - pytest.skip(f"{fixture_path.stem}: unsupported node directive {hit}") |
| 199 | + pytest.skip(f"{fixture_id}: unsupported node directive {hit}") |
170 | 200 |
|
171 | 201 | # Subgraph fixtures (006, 011, 013) declare an inner subgraph via the |
172 | 202 | # singular `subgraph:` key. Fixture 019 introduces the plural `subgraphs:` |
@@ -292,6 +322,117 @@ async def test_runtime_fixture(fixture_path: Path) -> None: |
292 | 322 | ) |
293 | 323 |
|
294 | 324 |
|
| 325 | +# --------------------------------------------------------------------------- |
| 326 | +# Fixture 020 — observer edge-error events (proposal 0012 / spec v0.9.0) |
| 327 | +# |
| 328 | +# Two sub-cases verifying §3 step 3 (revised) + §6 (revised): edge-resolution |
| 329 | +# failures (routing_error, edge_exception) land on the preceding node's |
| 330 | +# completed event with `error` populated, sharing the started/completed pair |
| 331 | +# rather than producing a separate event pair. |
| 332 | +# |
| 333 | +# Custom driver (rather than the generic harness) because fixture 020's |
| 334 | +# edge-condition `callable:` directives (`state_field_read`, `edge_raises`) |
| 335 | +# don't match the adapter DSL's `if_field/equals/then/else` shape — these |
| 336 | +# directives are spec-defined just for this fixture's two sub-cases. The |
| 337 | +# semantic intent is captured directly here. |
| 338 | +# --------------------------------------------------------------------------- |
| 339 | + |
| 340 | + |
| 341 | +async def _run_fixture_020(spec: Mapping[str, Any]) -> None: |
| 342 | + cases = cast("list[dict[str, Any]]", spec["cases"]) |
| 343 | + for case in cases: |
| 344 | + case_name = cast("str", case["name"]) |
| 345 | + try: |
| 346 | + await _run_fixture_020_case(case) |
| 347 | + except AssertionError as e: |
| 348 | + raise AssertionError(f"case {case_name!r}: {e}") from e |
| 349 | + |
| 350 | + |
| 351 | +async def _run_fixture_020_case(case: Mapping[str, Any]) -> None: |
| 352 | + """Build a two-node graph (a → b) with the case's edge-condition |
| 353 | + directive translated to a Python edge function, then assert the |
| 354 | + proposal-0012 contract on the captured observer events.""" |
| 355 | + |
| 356 | + class FixtureState(State): |
| 357 | + x: int = 0 |
| 358 | + |
| 359 | + received: list[NodeEvent] = [] |
| 360 | + |
| 361 | + async def observer(event: NodeEvent) -> None: |
| 362 | + received.append(event) |
| 363 | + |
| 364 | + async def node_a(_state: Any) -> dict[str, Any]: |
| 365 | + return {"x": 1} |
| 366 | + |
| 367 | + async def node_b(_state: Any) -> dict[str, Any]: |
| 368 | + return {"x": 2} |
| 369 | + |
| 370 | + cond = cast("dict[str, Any]", case["edges"][0]["condition"]) |
| 371 | + callable_name = cast("str", cond["callable"]) |
| 372 | + if callable_name == "state_field_read": |
| 373 | + # Per spec proposal 0012 fixture 020: the edge function returns |
| 374 | + # the value from initial_state's named field. The fixture's case |
| 375 | + # initial_state populates that field with a string that is NOT a |
| 376 | + # declared node name in the graph, so the engine raises |
| 377 | + # ``RoutingError``. We reproduce the semantic via a closure |
| 378 | + # over the initial_state value. |
| 379 | + initial_state_dict = cast("dict[str, Any]", case.get("initial_state", {})) |
| 380 | + field_name = cast("str", cond["field"]) |
| 381 | + target_value = cast("str", initial_state_dict[field_name]) |
| 382 | + |
| 383 | + def edge_fn(_state: Any) -> str | EndSentinel: |
| 384 | + return target_value |
| 385 | + elif callable_name == "edge_raises": |
| 386 | + message = cast("str", cond.get("message", "edge raised")) |
| 387 | + |
| 388 | + def edge_fn(_state: Any) -> str | EndSentinel: |
| 389 | + raise RuntimeError(message) |
| 390 | + else: |
| 391 | + raise AssertionError(f"fixture 020: unknown condition callable {callable_name!r}") |
| 392 | + |
| 393 | + g = ( |
| 394 | + GraphBuilder(FixtureState) |
| 395 | + .add_node("a", node_a) |
| 396 | + .add_node("b", node_b) |
| 397 | + .add_conditional_edge("a", edge_fn) |
| 398 | + .add_edge("b", END) |
| 399 | + .set_entry("a") |
| 400 | + .compile() |
| 401 | + ) |
| 402 | + g.attach_observer(observer) |
| 403 | + |
| 404 | + expected_error = cast("dict[str, Any]", case["expected_error"]) |
| 405 | + expected_category = cast("str", expected_error["category"]) |
| 406 | + |
| 407 | + with pytest.raises(RuntimeGraphError) as excinfo: |
| 408 | + await g.invoke(FixtureState()) |
| 409 | + await g.drain() |
| 410 | + |
| 411 | + err = excinfo.value |
| 412 | + assert err.category == expected_category |
| 413 | + if expected_category == "routing_error": |
| 414 | + assert isinstance(err, RoutingError) |
| 415 | + elif expected_category == "edge_exception": |
| 416 | + assert isinstance(err, EdgeException) |
| 417 | + |
| 418 | + # Per the revised §6 contract: edge-resolution failures share the |
| 419 | + # preceding node's started/completed pair. Node a fires exactly one |
| 420 | + # started + one completed event; node b never fires. |
| 421 | + a_events = [e for e in received if e.node_name == "a"] |
| 422 | + b_events = [e for e in received if e.node_name == "b"] |
| 423 | + assert len(a_events) == 2, f"expected 2 events for node a; got {len(a_events)}" |
| 424 | + assert b_events == [], f"node b MUST never fire events on edge-resolution failure; got {b_events}" |
| 425 | + |
| 426 | + started, completed = a_events |
| 427 | + assert started.phase == "started" |
| 428 | + assert completed.phase == "completed" |
| 429 | + assert completed.post_state is None, ( |
| 430 | + "completed event MUST have post_state absent when edge resolution fails" |
| 431 | + ) |
| 432 | + assert completed.error is not None |
| 433 | + assert completed.error.category == expected_category |
| 434 | + |
| 435 | + |
295 | 436 | # --------------------------------------------------------------------------- |
296 | 437 | # 007 compile-errors: one parametrized case per entry in the `cases:` table. |
297 | 438 | # --------------------------------------------------------------------------- |
|
0 commit comments