- NodeDef `device`/`device_policy` become `worker`/`worker_policy`, the issue `ignored_device` becomes `ignored_worker`, and the SDK's `@node` takes `worker=` and `worker_policy=` - FlowStore._migrate renames the keys in published and draft documents once, bumping their version; a queued run's `needs` reads the old key - placement passes the label as `label`, client regenerated - docs: worker placement wording, MCP issuer without DOMAIN, device rows in the CLI data directory and the API table, device links in compose comments
196 lines
6.0 KiB
Python
196 lines
6.0 KiB
Python
import json
|
|
import os
|
|
import subprocess
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.schemas import FlowDef, NodeDef
|
|
from fluksio.flow.store import FlowExists, FlowNotFound, FlowStore
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path) -> FlowStore:
|
|
return FlowStore(tmp_path / "flows")
|
|
|
|
|
|
def commit_count(store: FlowStore) -> int:
|
|
result = subprocess.run(
|
|
["git", "-C", str(store.root), "rev-list", "--count", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return int(result.stdout.strip())
|
|
|
|
|
|
def a_flow() -> FlowDef:
|
|
return FlowDef(
|
|
name="heating",
|
|
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
|
|
)
|
|
|
|
|
|
def test_flow_round_trips(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
|
|
assert store.list_flows() == ["heating"]
|
|
assert store.read_flow("heating").nodes[0].provides[0].name == "temp"
|
|
|
|
|
|
def test_every_change_is_committed(store: FlowStore):
|
|
before = commit_count(store)
|
|
|
|
store.write_flow(a_flow())
|
|
assert commit_count(store) == before + 1
|
|
|
|
store.write_node_source("heating", "sensor", "def process():\n return {}\n")
|
|
assert commit_count(store) == before + 2
|
|
|
|
|
|
def test_saving_unchanged_content_does_nothing(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
commits = commit_count(store)
|
|
|
|
# Autosave repeats the same document; history should not grow.
|
|
assert store.write_flow(a_flow()) is False
|
|
assert commit_count(store) == commits
|
|
|
|
|
|
def test_a_flow_is_modified_when_only_its_node_code_is(store: FlowStore):
|
|
"""Node code is saved without touching the flow document."""
|
|
store.write_flow(a_flow())
|
|
# Backdated so the answer can only have come from the node file.
|
|
os.utime(store.root / "heating" / "flow.json", (0, 0))
|
|
|
|
store.write_node_source(
|
|
"heating", "sensor", "def process():\n return {'a': 1}\n"
|
|
)
|
|
|
|
updated = store.updated_at("heating")
|
|
assert updated is not None and updated > datetime.fromtimestamp(0, UTC)
|
|
|
|
|
|
def test_a_flow_that_is_not_there_has_no_modified_time(store: FlowStore):
|
|
assert store.updated_at("nope") is None
|
|
|
|
|
|
def test_missing_flow_is_reported(store: FlowStore):
|
|
with pytest.raises(FlowNotFound):
|
|
store.read_flow("nope")
|
|
|
|
|
|
def test_deleting_removes_flow_and_its_nodes(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
store.write_node_source("heating", "sensor", "def process():\n return {}\n")
|
|
|
|
store.delete_flow("heating")
|
|
|
|
assert store.list_flows() == []
|
|
assert not (store.root / "heating").exists()
|
|
|
|
|
|
def test_renaming_a_flow_carries_its_nodes(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
store.write_node_source("heating", "sensor", "def process():\n return {}\n")
|
|
|
|
renamed = store.rename_flow("heating", "warmth")
|
|
|
|
assert renamed.name == "warmth"
|
|
assert store.list_flows() == ["warmth"]
|
|
assert "def process" in store.read_node_source("warmth", "sensor")
|
|
|
|
|
|
def test_renaming_a_flow_repoints_the_flows_reading_it(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="display",
|
|
nodes=[
|
|
NodeDef(
|
|
id="gauge",
|
|
# Reads across the flow boundary, so the name must follow.
|
|
requires=[MessageSpec(name="heating.temp")],
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
store.rename_flow("heating", "warmth")
|
|
|
|
display = store.read_flow("display")
|
|
assert display.nodes[0].requires[0].name == "warmth.temp"
|
|
|
|
|
|
def test_renaming_onto_an_existing_name_is_refused(store: FlowStore):
|
|
store.write_flow(a_flow())
|
|
store.write_flow(FlowDef(name="warmth"))
|
|
|
|
with pytest.raises(FlowExists):
|
|
store.rename_flow("heating", "warmth")
|
|
|
|
assert store.list_flows() == ["heating", "warmth"]
|
|
|
|
|
|
def test_opening_the_store_renames_trigger_nodes_to_debounce(store: FlowStore):
|
|
"""The old name has no alias, so what is stored under it is rewritten once."""
|
|
store.write_flow(
|
|
FlowDef(
|
|
name="hall",
|
|
nodes=[NodeDef(id="hold", type="trigger"), NodeDef(id="lamp")],
|
|
)
|
|
)
|
|
store.write_draft(
|
|
FlowDef(name="hall", nodes=[NodeDef(id="hold", type="trigger")]),
|
|
base_version=1,
|
|
)
|
|
commits = commit_count(store)
|
|
|
|
reopened = FlowStore(store.root)
|
|
|
|
published = reopened.read_flow("hall")
|
|
draft = reopened.read_flow("hall", draft=True)
|
|
assert [node.type for node in published.nodes] == ["debounce", "python"]
|
|
assert [node.type for node in draft.nodes] == ["debounce"]
|
|
# Bumped, so an editor holding the old draft cannot save `trigger` back.
|
|
assert (published.version, draft.version) == (2, 3)
|
|
assert commit_count(store) == commits + 1
|
|
|
|
FlowStore(store.root)
|
|
assert commit_count(store) == commits + 1
|
|
|
|
|
|
def test_opening_the_store_renames_the_mqtt_publish_queue_size(store: FlowStore):
|
|
"""The mqtt setting is now `send_queue_size`, with no alias for the old name."""
|
|
relay = NodeDef(id="relay", type="mqtt", params={"publish_queue_size": 8})
|
|
store.write_flow(FlowDef(name="hall", nodes=[relay]))
|
|
commits = commit_count(store)
|
|
|
|
reopened = FlowStore(store.root)
|
|
|
|
published = reopened.read_flow("hall")
|
|
assert published.nodes[0].params == {"send_queue_size": 8}
|
|
assert published.version == 2
|
|
assert commit_count(store) == commits + 1
|
|
|
|
|
|
def test_opening_the_store_renames_a_nodes_device_to_worker(store: FlowStore):
|
|
"""`device` names a paired device now, so a node's worker label moves over."""
|
|
store.write_flow(FlowDef(name="lab", nodes=[NodeDef(id="fit")]))
|
|
path = store.root / "lab" / "flow.json"
|
|
data = json.loads(path.read_text())
|
|
node = data["nodes"][0]
|
|
del node["worker"], node["worker_policy"]
|
|
node.update(device="gpu", device_policy="prefer")
|
|
path.write_text(json.dumps(data))
|
|
|
|
published = FlowStore(store.root).read_flow("lab")
|
|
|
|
assert (published.nodes[0].worker, published.nodes[0].worker_policy) == (
|
|
"gpu",
|
|
"prefer",
|
|
)
|
|
assert published.version == 2
|