"""The one-time move of an older instance's panels file into devices.""" import json import subprocess from pathlib import Path from fluksio.flow.devices import DeviceStore from fluksio.flow.store import FlowStore def test_panels_move_into_devices_once(tmp_path: Path) -> None: flows = FlowStore(tmp_path / "flows") panels = tmp_path / "panels.json" panels.write_text( json.dumps( { "panels": [ {"id": "hall", "title": "Hall", "dashboards": ["home"], "nonce": 2}, {"id": "knob", "touch": True, "io": [], "nonce": 0}, ], "version": 7, } ) ) store = DeviceStore(flows, tmp_path / "devices.json") store.migrate(panels) assert not panels.exists() assert (tmp_path / "panels.json.migrated").exists() hall = store.find("hall") assert hall is not None and hall.dashboards == ["home"] and hall.version == 1 assert store.find("knob").touch is True # type: ignore[union-attr] # The nonce stays outside git, so a credential minted at it still stands. assert store.nonce("hall") == 2 assert "nonce" not in (flows.root / "_devices" / "hall.json").read_text() log = subprocess.run( ["git", "-C", str(flows.root), "log", "--format=%s"], capture_output=True, text=True, ).stdout assert log.splitlines()[0] == "Migrate panels to devices" # Nothing in `_devices` is taken for a flow. assert flows.list_flows() == [] # A second start finds nothing to do, and a panels file restored from a # backup does not lower a nonce an unpair has raised since. store.bump("hall") panels.write_text((tmp_path / "panels.json.migrated").read_text()) store.migrate(panels) assert store.nonce("hall") == 3 assert ( log == subprocess.run( ["git", "-C", str(flows.root), "log", "--format=%s"], capture_output=True, text=True, ).stdout ) def _flow(flows: FlowStore, name: str, draft: dict | None = None, **published) -> None: """Write a published flow document, and a draft beside it if given.""" folder = flows.root / name folder.mkdir(parents=True, exist_ok=True) (folder / "flow.json").write_text(json.dumps({"name": name, **published})) if draft is not None: (folder / "flow.draft.json").write_text(json.dumps({"name": name, **draft})) def test_device_ports_move_into_flows_once(tmp_path: Path) -> None: flows = FlowStore(tmp_path / "flows") _flow( flows, "home", version=3, inputs=[ {"spec": {"name": "brightness", "dtype": "int"}, "initial": 80}, ], nodes=[ { "id": "bell", "type": "python", "requires": [{"name": "doorbell", "dtype": "bool"}], } ], draft={ "version": 5, "inputs": [{"spec": {"name": "brightness", "dtype": "int"}, "initial": 80}], "nodes": [ { "id": "bell", "type": "python", "requires": [{"name": "doorbell", "dtype": "bool"}], }, {"id": "hall", "type": "inject"}, ], }, ) # Declares `home.light` from outside: a row on it goes to the owner first, # and only here when the owner does not declare it. _flow( flows, "lights", nodes=[ { "id": "dim", "type": "python", "provides": [{"name": "home.light", "dtype": "float"}], } ], ) devices = flows.root / "_devices" devices.mkdir() (devices / "hall.json").write_text( json.dumps( { "id": "hall", "title": "Hall", "version": 4, "io": [ { "port": "backlight", "message": "home.brightness", "direction": "out", }, { "port": "gpio.4", "message": "home.doorbell", "direction": "in", "config": {"pull": "up"}, }, {"port": "dimmer", "message": "home.light", "direction": "out"}, {"port": "mic", "message": "home.nowhere", "direction": "in"}, ], } ) ) store = DeviceStore(flows, tmp_path / "devices.json") store.move_io() published = flows.read_flow("home") draft = flows.read_flow("home", draft=True) assert (published.version, draft.version) == (4, 6) for doc in (published, draft): # `hall` is taken by an inject node in the draft, so the id moves on in # both documents alike. node = next(n for n in doc.nodes if n.type == "device") assert node.id == "hall_2" and node.title == "Hall" assert node.params == {"device": "hall", "config": {"gpio.4": {"pull": "up"}}} assert [(s.port, s.name, s.dtype.value) for s in node.provides] == [ ("gpio.4", "doorbell", "bool") ] assert [(s.port, s.name, s.dtype.value, s.trigger) for s in node.requires] == [ ("backlight", "brightness", "int", False) ] lights = flows.read_flow("lights") node = next(n for n in lights.nodes if n.type == "device") assert node.id == "hall" and lights.version == 2 assert [(s.port, s.name) for s in node.requires] == [("dimmer", "home.light")] # The device lost `io` and kept its version; the row nothing declares is # gone. What it reads back is the same rows, in port order. assert "io" not in json.loads((devices / "hall.json").read_text()) assert store.find("hall").version == 4 # type: ignore[union-attr] assert [(r.port, r.message, r.direction, r.config) for r in store.io("hall")] == [ ("backlight", "home.brightness", "out", {}), ("dimmer", "home.light", "out", {}), ("gpio.4", "home.doorbell", "in", {"pull": "up"}), ] log = subprocess.run( ["git", "-C", str(flows.root), "log", "--format=%s"], capture_output=True, text=True, ).stdout.splitlines() assert log[0] == "Move device ports into flows" # Nothing left to move, and nothing written twice. store.move_io() assert flows.read_flow("home").version == 4 assert ( subprocess.run( ["git", "-C", str(flows.root), "log", "--format=%s"], capture_output=True, text=True, ).stdout.splitlines() == log ) def test_a_crash_after_the_flows_were_written_adds_nothing_twice( tmp_path: Path, ) -> None: flows = FlowStore(tmp_path / "flows") _flow(flows, "home", inputs=[{"spec": {"name": "level", "dtype": "int"}}]) devices = flows.root / "_devices" devices.mkdir() device = { "id": "hall", "io": [{"port": "backlight", "message": "home.level", "direction": "out"}], } (devices / "hall.json").write_text(json.dumps(device)) store = DeviceStore(flows, tmp_path / "devices.json") store.move_io() # As if the device file had not been rewritten before the process died. (devices / "hall.json").write_text(json.dumps(device)) store.move_io() node = next(n for n in flows.read_flow("home").nodes if n.type == "device") assert len(node.requires) == 1 assert flows.read_flow("home").version == 2 def test_panels_with_io_reach_the_flows_in_one_start(tmp_path: Path) -> None: """An instance from before devices: panels.json, then device nodes.""" flows = FlowStore(tmp_path / "flows") _flow(flows, "kiosk", inputs=[{"spec": {"name": "brightness", "dtype": "int"}}]) panels = tmp_path / "panels.json" panels.write_text( json.dumps( { "panels": [ { "id": "knob", "io": [ { "port": "backlight", "message": "kiosk.brightness", "direction": "out", "config": {"wake_s": 30}, } ], } ] } ) ) store = DeviceStore(flows, tmp_path / "devices.json") store.migrate(panels) store.move_io() assert [(r.port, r.message, r.config) for r in store.io("knob")] == [ ("backlight", "kiosk.brightness", {"wake_s": 30}) ] def test_the_nonces_are_read_again_only_when_the_file_changes(tmp_path: Path) -> None: """Every device request reads its nonce, so the file is parsed once per change, and a change from outside this process is still seen.""" store = DeviceStore(FlowStore(tmp_path / "flows"), tmp_path / "devices.json") assert store.nonce("hall") == 0 store.bump("hall") assert store.nonce("hall") == 1 assert store._states() is store._states() raw = json.loads(store.state_file.read_text()) raw["hall"]["nonce"] = 7 store.state_file.write_text(json.dumps(raw, indent=4)) assert store.nonce("hall") == 7