Files
app/backend/tests/flow/test_state_edges.py
T
stroblmeandClaude Opus 5 60d7ec81c0 Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 21:48:05 +02:00

156 lines
4.7 KiB
Python

"""State carried between runs, without a state API.
Logic nodes are pure functions of their inputs, so an accumulator keeps its
running value in a message it both reads and writes. That is the sanctioned
shape, and these tests pin what it means.
"""
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline
from fluksio.flow.state import MemoryState
def counter() -> Node:
"""Adds each reading to a total it keeps in its own output message."""
def process(reading, params, total=0.0):
return {"total": total + reading}
node = Node(
f=process,
requires=[
MessageSpec(name="reading", port="reading", dtype=DType.FLOAT),
MessageSpec(name="total", port="total", dtype=DType.FLOAT),
],
provides=[MessageSpec(name="total", port="total", dtype=DType.FLOAT)],
name="accumulate",
)
node.assign_flow("f", "accumulate")
return node
def source() -> Node:
def process(params):
return {}
node = Node(
f=process,
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
name="sensor",
)
node.assign_flow("f", "sensor")
return node
def test_a_node_reading_what_it_writes_does_not_depend_on_itself():
node = counter()
pipeline = Pipeline(nodes=[node])
assert pipeline.dependencies[node] == frozenset()
# Nor is it downstream of itself, so publishing does not re-run it.
assert node not in pipeline.edges[node]
assert pipeline.validate({"f.reading": True, "f.total": True}) == []
def test_the_running_total_survives_between_runs():
state = MemoryState()
node = counter()
pipeline = Pipeline(
nodes=[node],
state=state,
initial_values={"f.reading": 0.0, "f.total": 0.0},
)
for reading in (2.0, 3.0, 5.0):
state["f.reading"] = reading
pipeline.run()
assert state["f.total"] == 10.0
def test_a_self_loop_without_a_starting_value_is_reported():
"""It could never run: the value it waits for is the one it writes."""
node = counter()
pipeline = Pipeline(nodes=[node, source()])
issues = pipeline.validate({"f.reading": True})
assert [i.code for i in issues] == ["self_loop_needs_initial"]
assert issues[0].message_name == "f.total"
# Declaring it as a flow input with a value settles it.
assert pipeline.validate({"f.reading": True, "f.total": True}) == []
def test_a_non_triggering_input_makes_a_two_node_loop_legal():
"""A→B→A is a cycle only while both edges wake their consumer.
Marking the back edge non-triggering says what is actually meant: B's
result is state A reads on its next run, not something that runs A.
"""
def forward(params, value=0.0):
return {"echo": value}
def back(echo, params):
return {"value": echo + 1.0}
a = Node(
f=forward,
# The back edge: read when a runs, never the reason it runs.
requires=[
MessageSpec(name="value", port="value", dtype=DType.FLOAT, trigger=False)
],
provides=[MessageSpec(name="echo", port="echo", dtype=DType.FLOAT)],
name="a",
)
b = Node(
f=back,
requires=[MessageSpec(name="echo", port="echo", dtype=DType.FLOAT)],
provides=[MessageSpec(name="value", port="value", dtype=DType.FLOAT)],
name="b",
)
a.assign_flow("f", "a")
b.assign_flow("f", "b")
state = MemoryState()
pipeline = Pipeline(nodes=[a, b], state=state, initial_values={"f.value": 1.0})
assert pipeline.dependencies[a] == frozenset()
assert pipeline.dependencies[b] == frozenset({a})
# Kahn sees no loop, so nothing is reported as cyclic.
assert [i.code for i in pipeline.validate({"f.value": True})] == []
pipeline.run()
assert state["f.echo"] == 1.0
assert state["f.value"] == 2.0
# The next run reads what b wrote, which is the point of the back edge.
pipeline.run()
assert state["f.echo"] == 2.0
assert state["f.value"] == 3.0
def test_a_non_triggering_input_never_holds_a_node_back():
"""Absent, it is simply left out of the call rather than awaited."""
def process(params, seen=None):
return {"out": 1.0 if seen is None else 2.0}
node = Node(
f=process,
requires=[
MessageSpec(name="seen", port="seen", dtype=DType.FLOAT, trigger=False)
],
provides=[MessageSpec(name="out", port="out", dtype=DType.FLOAT)],
name="n",
)
node.assign_flow("f", "n")
state = MemoryState()
pipeline = Pipeline(nodes=[node], state=state)
pipeline.run()
assert state["f.out"] == 1.0