Files
app/backend/tests/flow/test_runtime_control.py
T
stroblmeandClaude Fable 5 93b4a9a0b1 Step a paused flow, and deliver what a rate limit held back
Three things a pause and an interval were quietly losing:

- A rebuild builds a fresh pipeline, so nothing is paused any more and no
  resume ever comes for what the old one parked. Release it on rebuild.
- POST /flows/{name}/step takes the oldest parked item and runs that one wave
  while the flow stays paused, so a held-back cascade can be walked through.
  Nothing parked answers plainly rather than failing.
- A per-port interval was leading-edge only: a producer going quiet inside the
  window left the consumer on the value before it. The held value is kept and
  a flush item scheduled on the queue's existing timer, so the window ends with
  a delivery. One timer in flight per node, and none without a queue to run it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
2026-08-16 16:44:17 +02:00

168 lines
4.9 KiB
Python

"""Stopping a flow takes it off the engine; pausing holds its nodes."""
from pathlib import Path
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.deps import get_current_user
from app.api.routes.flows import router
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
from app.flow.schemas import FlowDef
from app.flow.store import FlowStore
def spec(name: str) -> MessageSpec:
return MessageSpec(name=name, dtype=DType.FLOAT)
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow(flow, node_id)
return node
def a_chain(flow: str, ran: list[str]) -> list[Node]:
"""source → middle: two nodes, so stepping has somewhere to stop."""
def source(params):
ran.append(f"{flow}.source")
return {"temp": 20.0}
def middle(temp, params):
ran.append(f"{flow}.middle")
return {"warm": temp > 10}
return [
make_node("source", flow, source, provides=[spec("temp")]),
make_node(
"middle",
flow,
middle,
requires=[spec("temp")],
provides=[MessageSpec(name="warm", dtype=DType.BOOL)],
),
]
def test_a_stopped_flow_runs_nothing_and_its_neighbours_carry_on():
ran: list[str] = []
pipeline = Pipeline(
nodes=a_chain("stopped", ran) + a_chain("running", ran),
disabled_flows={"stopped"},
)
pipeline.run({})
assert not any(name.startswith("stopped.") for name in ran)
assert {"running.source", "running.middle"} <= set(ran)
def test_a_stopped_flow_ignores_a_trigger_from_its_own_nodes():
ran: list[str] = []
nodes = a_chain("stopped", ran)
pipeline = Pipeline(nodes=nodes, disabled_flows={"stopped"})
# What an MQTT subscription or a webhook would do.
nodes[0].inject({"temp": 20.0})
assert ran == []
assert "stopped.temp" not in pipeline.state
def test_a_paused_flow_still_takes_values_but_acts_on_none_of_them():
ran: list[str] = []
nodes = a_chain("demo", ran)
pipeline = Pipeline(nodes=nodes)
pipeline.pause("demo")
nodes[0].inject({"temp": 20.0})
# The value is there to look at; nothing downstream of it ran.
assert pipeline.state["demo.temp"] == 20.0
assert ran == []
def test_resuming_runs_what_was_held_back():
ran: list[str] = []
pipeline = Pipeline(nodes=a_chain("demo", ran))
pipeline.pause("demo")
pipeline.run({})
assert ran == []
pipeline.resume("demo")
assert ran == ["demo.source", "demo.middle"]
assert pipeline.paused_flows() == []
def test_stopped_survives_a_restart(tmp_path: Path):
store = FlowStore(tmp_path / "flows")
assert store.read_enabled("heating") is True
store.write_enabled("heating", False)
assert store.read_enabled("heating") is False
# A second store over the same directory is what a restart looks like.
assert FlowStore(tmp_path / "flows").read_enabled("heating") is False
class _StubController:
"""Only what the step route touches: the store, and the step itself."""
def __init__(self, store: FlowStore, stepped: str | None) -> None:
self.store = store
self.stepped = stepped
self.calls: list[str] = []
def step_flow(self, flow: str) -> str | None:
self.calls.append(flow)
return self.stepped
def _stub(tmp_path: Path, stepped: str | None) -> _StubController:
"""A controller over a store holding one flow, called 'heating'."""
store = FlowStore(tmp_path / "flows")
store.write_flow(FlowDef(name="heating"))
return _StubController(store, stepped)
def _client(controller: Any) -> TestClient:
app = FastAPI()
app.include_router(router, prefix="/api/v1")
app.state.flow_controller = controller
app.dependency_overrides[get_current_user] = lambda: None
return TestClient(app)
def test_the_step_endpoint_reports_what_it_ran(tmp_path: Path):
controller = _stub(tmp_path, "heating.sensor")
response = _client(controller).post("/api/v1/flows/heating/step")
assert response.status_code == 200
assert response.json() == {"message": "Stepped 'heating.sensor'"}
assert controller.calls == ["heating"]
def test_stepping_a_flow_with_nothing_parked_answers_plainly(tmp_path: Path):
"""A button pressed once too often is not an error."""
controller = _stub(tmp_path, None)
response = _client(controller).post("/api/v1/flows/heating/step")
assert response.status_code == 200
assert response.json() == {"message": "Nothing held back in flow 'heating'"}
def test_stepping_an_unknown_flow_is_a_404(tmp_path: Path):
controller = _stub(tmp_path, None)
response = _client(controller).post("/api/v1/flows/nosuchflow/step")
assert response.status_code == 404
assert controller.calls == []