"""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 == []