feat(graph): make the visual editor a full wiring control surface
Lets users wire the system end-to-end from the graph, and fixes the core bug that made drag-to-wire silently fail. - Fix drag-to-wire 422s across 5 entity kinds: updateConnection() now echoes the target's discriminator (source_type/stream_type/target_type) into the partial PUT, so value/colour-strip/audio/picture sources and output targets all wire correctly. New contract test (54 cases) in test_graph_wiring_contract.py. - Re-wire composite layers / mapped zones from the graph (right-click a layer/zone source edge -> Re-wire). Whole-list write preserves every sibling layer/zone setting, with an optimistic-concurrency guard and undo. - Secret-safe /graph topology: project entities to id/name/subtype + reference roots so the endpoint cannot leak webhook tokens or other credentials. - Carry slot indices on list edges; node custom-icon + schema-drift refinements; rewire i18n keys (en/ru/zh); wiring-control roadmap (TODO.md).
This commit is contained in:
@@ -4,6 +4,10 @@ These exercise reference extraction, topology building, dependents, cycle and
|
||||
dangling-reference detection without booting the app or any store.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ledgrab.api.graph_schema import (
|
||||
CONNECTION_SCHEMA,
|
||||
ENTITY_KINDS,
|
||||
@@ -11,7 +15,10 @@ from ledgrab.api.graph_schema import (
|
||||
detect_cycles,
|
||||
extract_refs,
|
||||
find_dependents,
|
||||
graph_field_roots,
|
||||
is_editable,
|
||||
schema_for_kind,
|
||||
serialize_entity_for_graph,
|
||||
validate_connection,
|
||||
would_create_cycle,
|
||||
)
|
||||
@@ -249,7 +256,79 @@ def test_validate_connection_rejects_list_field():
|
||||
entities, "color_strip_source", "css_1", "layers[].source_id", "css_2"
|
||||
)
|
||||
assert ok is False
|
||||
assert "List connection" in err
|
||||
assert "not editable" in err
|
||||
|
||||
|
||||
def test_validate_connection_rejects_color_bindable():
|
||||
# Colour bindings are structurally bindable but not graph-editable (a value
|
||||
# source can't drive a colour).
|
||||
entities = {
|
||||
"color_strip_source": [
|
||||
{"id": "css_1", "name": "X", "source_type": "single_color", "color": [255, 0, 0]}
|
||||
],
|
||||
"value_source": [{"id": "vs_1", "name": "V", "source_type": "static"}],
|
||||
}
|
||||
ok, err = validate_connection(
|
||||
entities, "color_strip_source", "css_1", "color.source_id", "vs_1"
|
||||
)
|
||||
assert ok is False
|
||||
assert "not editable" in err
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeRule:
|
||||
token: str = "SUPER_SECRET_WEBHOOK_TOKEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAutomation:
|
||||
id: str = "auto_1"
|
||||
name: str = "A"
|
||||
enabled: bool = True
|
||||
scene_preset_id: str = "sp_1"
|
||||
rules: list = field(default_factory=lambda: [_FakeRule()])
|
||||
|
||||
|
||||
# Keys that must NEVER appear in the /graph projection allowlist.
|
||||
_SECRET_KEY_RE = re.compile(
|
||||
r"token|password|secret|credential|api[_-]?key|_key$|username|\burl\b|\bhost\b", re.I
|
||||
)
|
||||
|
||||
|
||||
def test_projection_roots_never_expose_secrets():
|
||||
# The /graph projection (graph_field_roots) is the leak boundary. Assert no
|
||||
# kind's allowlist contains a secret-bearing key — locks the boundary against
|
||||
# future schema drift (a new reference field whose root looks like a secret).
|
||||
for kind in ENTITY_KINDS:
|
||||
for root in graph_field_roots(kind):
|
||||
assert not _SECRET_KEY_RE.search(
|
||||
root
|
||||
), f"{kind}: projected root {root!r} looks secret-bearing"
|
||||
|
||||
|
||||
def test_serialize_entity_for_graph_drops_secrets():
|
||||
# The graph projection must strip everything except id/name/type + reference
|
||||
# roots — a deep asdict would otherwise leak the webhook token (a real,
|
||||
# auth-equivalent secret) in the /graph response.
|
||||
projected = serialize_entity_for_graph("automation", _FakeAutomation())
|
||||
assert projected["id"] == "auto_1"
|
||||
assert projected["name"] == "A"
|
||||
assert projected["scene_preset_id"] == "sp_1" # reference root kept
|
||||
assert "rules" not in projected # non-reference field dropped
|
||||
assert "enabled" not in projected
|
||||
assert "SUPER_SECRET_WEBHOOK_TOKEN" not in json.dumps(projected)
|
||||
|
||||
|
||||
def test_is_editable_classifies_fields():
|
||||
by_field = {(c.target_kind, c.field): c for c in CONNECTION_SCHEMA}
|
||||
# Top-level reference and single-level BindableFloat → editable.
|
||||
assert is_editable(by_field[("output_target", "device_id")])
|
||||
assert is_editable(by_field[("color_strip_source", "brightness.source_id")])
|
||||
assert is_editable(by_field[("output_target", "transition.source_id")])
|
||||
# Colour binding, list slot, and double-nested → NOT editable.
|
||||
assert not is_editable(by_field[("color_strip_source", "color.source_id")])
|
||||
assert not is_editable(by_field[("color_strip_source", "layers[].source_id")])
|
||||
assert not is_editable(by_field[("output_target", "settings.brightness.source_id")])
|
||||
|
||||
|
||||
def test_validate_connection_rejects_cycle():
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Contract tests for graph drag-to-wire writes.
|
||||
|
||||
The graph editor's ``updateConnection()`` performs a *partial* PUT for a single
|
||||
dragged edge: it sends only the reference (or bindable) field being wired. Five
|
||||
entity kinds have ``Body(discriminator=...)`` PUT routes, so such a partial body
|
||||
is rejected with a 422 unless it also echoes the entity's subtype
|
||||
(``source_type`` / ``stream_type`` / ``target_type``). ``updateConnection()`` now
|
||||
reads the target's subtype back and includes it.
|
||||
|
||||
These tests lock in that contract from the backend side: each ``(kind, field)``
|
||||
pair the frontend ``CONNECTION_MAP`` drag-edits must validate as a minimal
|
||||
``{discriminator, field}`` body — and must be rejected without the discriminator
|
||||
(the exact failure that silently broke wiring). If a future schema change makes
|
||||
one of these fields required-with-siblings, or drops a subtype, these tests fail
|
||||
and flag that graph wiring will break.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from ledgrab.api.schemas.audio_sources import AudioSourceUpdate
|
||||
from ledgrab.api.schemas.color_strip_sources import ColorStripSourceUpdate
|
||||
from ledgrab.api.schemas.output_targets import OutputTargetUpdate
|
||||
from ledgrab.api.schemas.picture_sources import PictureSourceUpdate
|
||||
from ledgrab.api.schemas.value_sources import ValueSourceUpdate
|
||||
|
||||
# Each row mirrors one drag-editable (target_kind, field) pair from the frontend
|
||||
# CONNECTION_MAP, paired with a real subtype tag that owns that field and the
|
||||
# body shape updateConnection() sends: a flat ref id, or a bindable
|
||||
# ``{parent: {source_id}}`` slot (here keyed by the parent field root).
|
||||
# (kind, update_union, discriminator_field, subtype_tag, body_field, sample_value)
|
||||
_WIRING_WRITES = [
|
||||
("value_source", ValueSourceUpdate, "source_type", "audio", "audio_source_id", "as_1"),
|
||||
(
|
||||
"value_source",
|
||||
ValueSourceUpdate,
|
||||
"source_type",
|
||||
"adaptive_scene",
|
||||
"picture_source_id",
|
||||
"ps_1",
|
||||
),
|
||||
("value_source", ValueSourceUpdate, "source_type", "gradient_map", "value_source_id", "vs_1"),
|
||||
(
|
||||
"value_source",
|
||||
ValueSourceUpdate,
|
||||
"source_type",
|
||||
"css_extract",
|
||||
"color_strip_source_id",
|
||||
"css_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"picture",
|
||||
"picture_source_id",
|
||||
"ps_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"audio",
|
||||
"audio_source_id",
|
||||
"as_1",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"processed",
|
||||
"input_source_id",
|
||||
"css_2",
|
||||
),
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"processed",
|
||||
"processing_template_id",
|
||||
"cspt_1",
|
||||
),
|
||||
# bindable BindableFloat slot — body is {<parent>: {source_id}}
|
||||
(
|
||||
"color_strip_source",
|
||||
ColorStripSourceUpdate,
|
||||
"source_type",
|
||||
"audio",
|
||||
"smoothing",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
("audio_source", AudioSourceUpdate, "source_type", "capture", "audio_template_id", "at_1"),
|
||||
("audio_source", AudioSourceUpdate, "source_type", "processed", "audio_source_id", "as_2"),
|
||||
("picture_source", PictureSourceUpdate, "stream_type", "raw", "capture_template_id", "ct_1"),
|
||||
("picture_source", PictureSourceUpdate, "stream_type", "processed", "source_stream_id", "ps_2"),
|
||||
(
|
||||
"picture_source",
|
||||
PictureSourceUpdate,
|
||||
"stream_type",
|
||||
"processed",
|
||||
"postprocessing_template_id",
|
||||
"ppt_1",
|
||||
),
|
||||
("output_target", OutputTargetUpdate, "target_type", "led", "device_id", "dev_1"),
|
||||
("output_target", OutputTargetUpdate, "target_type", "led", "color_strip_source_id", "css_1"),
|
||||
# bindable slots on output targets
|
||||
(
|
||||
"output_target",
|
||||
OutputTargetUpdate,
|
||||
"target_type",
|
||||
"led",
|
||||
"brightness",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
(
|
||||
"output_target",
|
||||
OutputTargetUpdate,
|
||||
"target_type",
|
||||
"ha_light",
|
||||
"transition",
|
||||
{"source_id": "vs_1"},
|
||||
),
|
||||
]
|
||||
|
||||
_IDS = [f"{kind}.{field}[{tag}]" for kind, _u, _d, tag, field, _v in _WIRING_WRITES]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_put_validates_with_discriminator(kind, union, disc, tag, field, value):
|
||||
"""A single dragged wiring edit validates when the subtype is echoed.
|
||||
|
||||
This is exactly what updateConnection() now sends; if it fails, drag-to-wire
|
||||
for ``kind.field`` is broken.
|
||||
"""
|
||||
model = TypeAdapter(union).validate_python({disc: tag, field: value})
|
||||
assert getattr(model, disc) == tag
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_put_rejected_without_discriminator(kind, union, disc, tag, field, value):
|
||||
"""Without the discriminator the same body is rejected.
|
||||
|
||||
This is the 422 that silently broke drag-to-wire before updateConnection()
|
||||
echoed the subtype — guarding against anyone "simplifying" that away.
|
||||
"""
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(union).validate_python({field: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,union,disc,tag,field,value", _WIRING_WRITES, ids=_IDS)
|
||||
def test_partial_wiring_detach_validates_with_discriminator(kind, union, disc, tag, field, value):
|
||||
"""Detach (clearing a slot) goes through the same partial-PUT path and must
|
||||
also validate with the subtype echoed: ``{field: ""}`` for a flat reference,
|
||||
``{parent: {source_id: ""}}`` for a bindable slot — exactly what
|
||||
``detachConnection()`` -> ``updateConnection(..., "")`` sends.
|
||||
"""
|
||||
cleared = {"source_id": ""} if isinstance(value, dict) else ""
|
||||
model = TypeAdapter(union).validate_python({disc: tag, field: cleared})
|
||||
assert getattr(model, disc) == tag
|
||||
Reference in New Issue
Block a user