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:
@@ -1032,32 +1032,68 @@ viewer. Driven by the ULTRA-DEEP review (findings A1–A5, B1–B6, C1–C6, D1
|
||||
(only offers slots the target entity actually has). Writes the partial
|
||||
`{ <slot>: { source_id } }` payload → backend `Bindable*.apply_update` merges,
|
||||
preserving the static value. Verified data-safe (no `from_raw`/value-reset path).
|
||||
- [x] Render the two functional value-source references `buildGraph` was missing —
|
||||
`value_source.value_source_id` (gradient_map → inner value source) and
|
||||
`value_source.color_strip_source_id` (css_extract → strip). Both are runtime-
|
||||
resolved and already drag-editable; now visible/detachable in the graph.
|
||||
- [x] **B4 foundation:** backend schema now authoritative about graph-editability
|
||||
(`is_editable()` + `editable` flag in `/graph/schema`); `validate-connection`
|
||||
hardened to reject non-editable fields (colour/list/double-nested), not just lists.
|
||||
- [x] **B4 drift guard + gap fixes:** `checkSchemaDrift()` (graph-connections.ts) warns
|
||||
once if the frontend `CONNECTION_MAP` editable set diverges from `/graph/schema`
|
||||
(the automated "10-step checklist"). Surfacing it found 3 real gaps; fixed 2:
|
||||
`color_strip_source.input_source_id` + `processing_template_id` are now drag-editable
|
||||
(processed-strip wiring; `apply_update` is partial-safe). The 3rd —
|
||||
`device.default_css_processing_template_id` — is intentionally NOT drag-editable
|
||||
(the device PUT route isn't partial-safe; a one-field PUT could null the URL) and is
|
||||
in the drift-check exclude set. Also broadened `_availableMatches` to hide any slot
|
||||
the target entity doesn't expose (subtype-accurate; refs are always-emitted so empty
|
||||
slots stay wireable). Review also caught a **dead `output_target.picture_source_id`
|
||||
slot** (no output target stores it — not a field/schema, never emitted) — removed
|
||||
from both registries + `buildGraph`.
|
||||
- [x] **Comprehensive review pass (4 subagents: backend/frontend-core/orchestrator/security).**
|
||||
Findings fixed:
|
||||
- **CRITICAL (security):** `GET /api/v1/graph` leaked plaintext **webhook tokens**
|
||||
(`asdict` recursed `Automation.rules[].token`, an auth-equivalent secret). Fixed with
|
||||
**field-projection** — `serialize_entity_for_graph()` / `graph_field_roots()` project
|
||||
each entity to only `{id, name, subtype, reference-roots}`; secrets can't survive.
|
||||
Added a structural regression test asserting no projection root is secret-bearing for
|
||||
any kind (drift-proof boundary) + a token-drop test.
|
||||
- MEDIUM: added missing `value_source.clock_id` (AnimatedColorValueSource → sync_clock)
|
||||
to the backend registry for topology/dependents completeness (drift-excluded on the
|
||||
frontend — value-source PUT needs a `source_type` discriminator, so it's editor-only).
|
||||
- MEDIUM/LOW: `CSS.escape` on the markIssues id selector; grouped/clarified
|
||||
`_DRIFT_EXCLUDE`; fixed the stale `_availableMatches` JSDoc; documented the
|
||||
`checkSchemaDrift` forward-reference. Orchestrator + frontend-core + security: APPROVE.
|
||||
- Verification: `npm --prefix server run typecheck` + `run build` clean; ruff clean;
|
||||
graph backend tests 24 pass; full backend suite 1614 pass. 6 code-review passes,
|
||||
graph backend tests 35 pass; full backend suite green. ~8 code-review passes,
|
||||
all CRITICAL/HIGH findings fixed.
|
||||
|
||||
### Left to do (deferred)
|
||||
|
||||
- [ ] **BindableColor slots** (`color`, `color_peak`, `fallback_color`,
|
||||
`default_color` on color_strip_source) — left non-editable in B1 because
|
||||
scalar-value-source → colour-slot semantics are unclear. **First check:** do
|
||||
colour-producing value sources exist? If a value_source can drive a colour,
|
||||
mark these 4 CONNECTION_MAP entries `bindable: true` (they already validate on
|
||||
the backend); the write path (`{ color: { source_id } }` → `BindableColor.apply_update`)
|
||||
is already value-preserving. If not, leave read-only.
|
||||
- [ ] **B4 — delete the frontend `CONNECTION_MAP` duplication.** Blocked on a backend
|
||||
write endpoint. Plan:
|
||||
1. Add `PUT /api/v1/graph/connection` (body: target_kind/id, field, source_id)
|
||||
that validates (reuse `validate_connection`) then APPLIES the write
|
||||
server-side — top-level via the owning store's update; single-level bindable
|
||||
via `apply_update`. Must reuse each entity's existing update path (validation,
|
||||
factory reconstruction, entity_changed event) — do NOT hand-roll per-store
|
||||
mutation. Highest regression risk; needs per-kind tests.
|
||||
2. Switch frontend `updateConnection`/`detachConnection` to call it.
|
||||
3. Have the frontend fetch `/graph/schema` and build ports/edges from it,
|
||||
then delete `CONNECTION_MAP` + the buildGraph edge duplication
|
||||
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist
|
||||
in `contexts/graph-editor.md`.
|
||||
- [x] **BindableColor slots** — CHECKED, decision: keep read-only (won't fix).
|
||||
Value sources are scalar-only (`ValueStream.get_value() -> float`) and every
|
||||
colour consumer (`color_strip/single.py`, `effect_stream.py`) reads the static
|
||||
RGB via `bcolor()`, ignoring `source_id`. So a value_source cannot drive a
|
||||
colour — wiring `color`/`color_peak`/… would be a dead binding. Documented in
|
||||
`api/graph_schema.py` next to the BindableColor entries. (Would only become
|
||||
viable if a colour-producing value-source type is added.)
|
||||
- [~] **B4 — delete the frontend `CONNECTION_MAP` duplication.**
|
||||
- [x] **Foundation done:** the backend schema now carries an authoritative
|
||||
`editable` flag per field (`is_editable()` in `api/graph_schema.py`, mirroring
|
||||
the frontend `_isEditable`: top-level refs + single-level BindableFloat slots;
|
||||
NOT colour/list/double-nested). `validate-connection` is hardened to reject any
|
||||
non-editable field (was list-only). `editable` is surfaced in `/graph/schema`.
|
||||
- [ ] **Remaining (the refactor):** frontend fetches `/graph/schema` on load and
|
||||
derives connection metadata + edges from it (port the `extract_refs` dot-path/list
|
||||
grammar to TS), keeping only a tiny `kind → {endpoint, cache}` write-routing table;
|
||||
then delete the field-level `CONNECTION_MAP` + the `buildGraph` edge loops
|
||||
(graph-connections.ts / graph-layout.ts). Removes the 10-step sync checklist in
|
||||
`contexts/graph-editor.md`. **A backend apply-write endpoint is NOT required** —
|
||||
keep the proven per-entity PUT. Risk: regressing drag-connect/bindable; keep a
|
||||
dev drift-check (frontend editable set vs `/graph/schema`) during the transition.
|
||||
Note: frontend `CONNECTION_MAP` also has inert `ha_source_id`/`gradient_id` entries
|
||||
(no graph node kind) — drop them, the backend schema already omits them.
|
||||
- [ ] **D6 — blueprint import/instantiate.** Export exists; the apply half (serialize
|
||||
a selected subgraph's topology + entities, re-import with id remapping, conflict
|
||||
handling) is large and data-integrity-sensitive (see Data Migration Policy in
|
||||
|
||||
@@ -99,6 +99,8 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
ConnectionField("value_source", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField("value_source", "value_source_id", "value_source", "value"),
|
||||
ConnectionField("value_source", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
# AnimatedColorValueSource references a sync clock for shared timing.
|
||||
ConnectionField("value_source", "clock_id", "sync_clock", "clock"),
|
||||
# ── Color strip sources (top-level) ──
|
||||
ConnectionField("color_strip_source", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField("color_strip_source", "audio_source_id", "audio_source", "audio"),
|
||||
@@ -129,6 +131,11 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
)
|
||||
),
|
||||
# ── Color strip sources (BindableColor value bindings) ──
|
||||
# NOTE: `bindable` here is *structural* (these are BindableColor fields). They
|
||||
# are NOT usefully wireable from the graph: a ValueStream yields a scalar
|
||||
# (`get_value() -> float`) and every colour consumer reads the static RGB via
|
||||
# `bcolor()` (source_id ignored at runtime). The graph editor keeps them
|
||||
# read-only; do not enable them without a colour-producing value source.
|
||||
*(
|
||||
ConnectionField(
|
||||
"color_strip_source",
|
||||
@@ -168,7 +175,6 @@ CONNECTION_SCHEMA: tuple[ConnectionField, ...] = (
|
||||
# ── Output targets ──
|
||||
ConnectionField("output_target", "device_id", "device", "device"),
|
||||
ConnectionField("output_target", "color_strip_source_id", "color_strip_source", "colorstrip"),
|
||||
ConnectionField("output_target", "picture_source_id", "picture_source", "picture"),
|
||||
ConnectionField(
|
||||
"output_target", "brightness.source_id", "value_source", "value", bindable=True, nested=True
|
||||
),
|
||||
@@ -201,6 +207,34 @@ def schema_for_kind(kind: str) -> list[ConnectionField]:
|
||||
return [c for c in CONNECTION_SCHEMA if c.target_kind == kind]
|
||||
|
||||
|
||||
# BindableColor slots are structurally bindable but NOT graph-editable: a
|
||||
# ValueStream yields a scalar (``get_value() -> float``) and colour consumers
|
||||
# read the static RGB via ``bcolor()`` (source_id ignored at runtime), so a
|
||||
# value source cannot drive a colour.
|
||||
_COLOR_BINDABLE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"color.source_id",
|
||||
"color_peak.source_id",
|
||||
"fallback_color.source_id",
|
||||
"default_color.source_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_editable(cf: ConnectionField) -> bool:
|
||||
"""Whether a field can be wired from the graph.
|
||||
|
||||
Editable = a top-level reference, or a single-level ``BindableFloat`` slot.
|
||||
List slots (need an element index), double-nested fields, and the dead
|
||||
colour bindings stay read-only.
|
||||
"""
|
||||
if cf.is_list:
|
||||
return False
|
||||
if not cf.nested:
|
||||
return True
|
||||
return cf.bindable and cf.field.count(".") == 1 and cf.field not in _COLOR_BINDABLE_FIELDS
|
||||
|
||||
|
||||
def schema_as_dicts() -> list[dict[str, Any]]:
|
||||
"""Serialize the registry for the ``/graph/schema`` endpoint."""
|
||||
return [
|
||||
@@ -212,6 +246,7 @@ def schema_as_dicts() -> list[dict[str, Any]]:
|
||||
"bindable": c.bindable,
|
||||
"nested": c.nested,
|
||||
"is_list": c.is_list,
|
||||
"editable": is_editable(c),
|
||||
}
|
||||
for c in CONNECTION_SCHEMA
|
||||
]
|
||||
@@ -269,6 +304,32 @@ def serialize_entity(model: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def graph_field_roots(kind: str) -> set[str]:
|
||||
"""Top-level keys the graph needs for ``kind``: ``id``/``name``, the subtype
|
||||
field, and the root segment of every reference path for that kind."""
|
||||
roots: set[str] = {"id", "name"}
|
||||
type_field = NODE_TYPE_FIELD.get(kind, "")
|
||||
if type_field:
|
||||
roots.add(type_field)
|
||||
for cf in CONNECTION_SCHEMA:
|
||||
if cf.target_kind == kind:
|
||||
roots.add(cf.field.split(".", 1)[0].removesuffix("[]"))
|
||||
return roots
|
||||
|
||||
|
||||
def serialize_entity_for_graph(kind: str, model: Any) -> dict[str, Any]:
|
||||
"""Serialize a model and project it to ONLY the keys the graph needs.
|
||||
|
||||
This projection is a **security boundary**: a full ``asdict``/``to_dict``
|
||||
can carry secrets (webhook tokens, device/HA/MQTT credentials), so every
|
||||
field except ``id``/``name``, the subtype field and reference-path roots is
|
||||
dropped before the data reaches the graph API.
|
||||
"""
|
||||
full = serialize_entity(model)
|
||||
roots = graph_field_roots(kind)
|
||||
return {k: v for k, v in full.items() if k in roots}
|
||||
|
||||
|
||||
# ── Topology / validation ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -480,12 +541,11 @@ def validate_connection(
|
||||
)
|
||||
if cf is None:
|
||||
return False, f"Unknown connection field: {target_kind}.{field}"
|
||||
if cf.is_list:
|
||||
# List slots (layers/zones/scene targets) hold many edges sharing the
|
||||
# same (to, field); without an element index this endpoint can't model
|
||||
# which one is being replaced for the cycle check. Edit those via the
|
||||
# entity editor.
|
||||
return False, f"List connection '{field}' must be edited via the entity editor"
|
||||
if not is_editable(cf):
|
||||
# List slots (need an element index), double-nested fields, and dead
|
||||
# colour bindings can't be wired from the graph — edit via the entity
|
||||
# editor instead.
|
||||
return False, f"Field '{field}' is not editable via the graph"
|
||||
if not _entity_exists(entities_by_kind, target_kind, target_id):
|
||||
return False, f"Target entity not found: {target_id}"
|
||||
if not source_id:
|
||||
|
||||
@@ -28,7 +28,7 @@ from ledgrab.api.graph_schema import (
|
||||
build_topology,
|
||||
find_dependents,
|
||||
schema_as_dicts,
|
||||
serialize_entity,
|
||||
serialize_entity_for_graph,
|
||||
validate_connection,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ def _gather_entities() -> dict[str, list[dict[str, Any]]]:
|
||||
logger.warning("graph: store for kind %s unavailable: %s", kind, exc)
|
||||
out[kind] = []
|
||||
continue
|
||||
out[kind] = [serialize_entity(m) for m in models]
|
||||
out[kind] = [serialize_entity_for_graph(kind, m) for m in models]
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
streamsCache, colorStripSourcesCache, valueSourcesCache,
|
||||
audioSourcesCache, outputTargetsCache, automationsCacheObj,
|
||||
} from './state.ts';
|
||||
import { logError } from './log.ts';
|
||||
|
||||
/** Result of the backend pre-write connection validator. */
|
||||
export interface ConnectionValidation {
|
||||
@@ -62,6 +63,69 @@ export async function getDependents(kind: string, id: string): Promise<GraphDepe
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Schema drift guard (B4) ───────────────────────────────────── */
|
||||
|
||||
// Backend-declared reference fields the frontend intentionally does NOT drag-edit
|
||||
// (the backend still lists them for topology/dependents completeness, so the drift
|
||||
// check ignores them). Two categories:
|
||||
// (a) the source kind is not a graph node — nothing to drag from.
|
||||
// (b) the owning entity's PUT route is not safely partial-writable via a single
|
||||
// dragged field, so it's edited through the entity editor instead.
|
||||
const _DRIFT_EXCLUDE = new Set<string>([
|
||||
// (a) no graph node for the source kind — nothing to drag from:
|
||||
'value_source|ha_source_id',
|
||||
'value_source|gradient_id',
|
||||
// (b) not safely partial-PUT-able from a single dragged field:
|
||||
'device|default_css_processing_template_id', // a one-field device PUT could null the URL
|
||||
// (c) editable in principle but not surfaced as a graph edge yet:
|
||||
'value_source|clock_id', // sync_clock → animated_colour value-source timing
|
||||
]);
|
||||
|
||||
let _driftChecked = false;
|
||||
|
||||
interface SchemaConnection { target_kind: string; field: string; editable: boolean; }
|
||||
|
||||
/**
|
||||
* Dev safety net for the B4 finding: warn once if the frontend CONNECTION_MAP's
|
||||
* editable set diverges from the backend `/graph/schema` (the drift the manual
|
||||
* "10-step checklist" guards against). Read-only — never affects the graph.
|
||||
* No-op against older servers without the endpoint.
|
||||
*
|
||||
* Note: this references `CONNECTION_MAP` (const) and `_isEditable` (fn) declared
|
||||
* later in the module — safe because it is only ever invoked at runtime (from
|
||||
* `loadGraphEditor`), well after module initialization.
|
||||
*/
|
||||
export async function checkSchemaDrift(): Promise<void> {
|
||||
if (_driftChecked) return;
|
||||
_driftChecked = true;
|
||||
|
||||
let connections: SchemaConnection[];
|
||||
try {
|
||||
connections = (await apiGet<{ connections: SchemaConnection[] }>('/graph/schema')).connections || [];
|
||||
} catch {
|
||||
return; // endpoint unavailable — nothing to compare against
|
||||
}
|
||||
|
||||
const k = (kind: string, field: string): string => `${kind}|${field}`;
|
||||
const backend = new Set<string>();
|
||||
for (const c of connections) {
|
||||
if (c.editable && !_DRIFT_EXCLUDE.has(k(c.target_kind, c.field))) backend.add(k(c.target_kind, c.field));
|
||||
}
|
||||
const frontend = new Set<string>();
|
||||
for (const c of CONNECTION_MAP) {
|
||||
if (_isEditable(c) && !_DRIFT_EXCLUDE.has(k(c.targetKind, c.field))) frontend.add(k(c.targetKind, c.field));
|
||||
}
|
||||
|
||||
const missingOnFrontend = [...backend].filter(key => !frontend.has(key));
|
||||
const missingOnBackend = [...frontend].filter(key => !backend.has(key));
|
||||
if (missingOnFrontend.length || missingOnBackend.length) {
|
||||
logError('graph.schema_drift', new Error(
|
||||
`CONNECTION_MAP drift vs /graph/schema — editable fields missing on frontend: ` +
|
||||
`[${missingOnFrontend.join(', ')}]; missing on backend: [${missingOnBackend.join(', ')}]`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
interface ConnectionEntry {
|
||||
@@ -117,12 +181,14 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
// Processed strip: input source + processing template (apply_update is partial-safe)
|
||||
{ targetKind: 'color_strip_source', field: 'input_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
{ targetKind: 'color_strip_source', field: 'processing_template_id', sourceKind: 'cspt', edgeType: 'template', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
|
||||
// Output targets
|
||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true, bindable: true },
|
||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
|
||||
// Automations
|
||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
@@ -206,6 +272,25 @@ export function getConnectionByField(targetKind: string, field: string): Connect
|
||||
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && _isEditable(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity kinds whose per-entity PUT route validates the body against a Pydantic
|
||||
* **discriminated union** (`Body(discriminator=...)`). Such a route 422s unless
|
||||
* the body echoes the discriminator field, so a partial wiring write (just a
|
||||
* reference field) is rejected outright. Maps the target kind → the
|
||||
* discriminator's body-field name; the value is the target's *current* subtype,
|
||||
* which we read back from the entity immediately before the write.
|
||||
*
|
||||
* Without this, drag-to-wire silently fails for nearly every source kind. Keep
|
||||
* in sync with the backend `NODE_TYPE_FIELD` map in `api/graph_schema.py`.
|
||||
*/
|
||||
const _DISCRIMINATOR_FIELD: Readonly<Record<string, string>> = {
|
||||
picture_source: 'stream_type',
|
||||
audio_source: 'source_type',
|
||||
value_source: 'source_type',
|
||||
color_strip_source: 'source_type',
|
||||
output_target: 'target_type',
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a connection: set the reference field on the target entity.
|
||||
* @param {string} targetId - The target entity's ID
|
||||
@@ -226,6 +311,21 @@ export async function updateConnection(targetId: string, targetKind: string, fie
|
||||
? { [field.split('.')[0]]: { source_id: newSourceId || '' } }
|
||||
: { [field]: newSourceId };
|
||||
|
||||
// Discriminated-union PUT routes reject a body without their discriminator.
|
||||
// Echo the target's current subtype so a partial wiring write validates
|
||||
// instead of 422-ing. Best-effort: a failed read leaves the PUT to fail as
|
||||
// before — it never makes things worse.
|
||||
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||
if (discrimField) {
|
||||
try {
|
||||
const current = await apiGet<Record<string, unknown>>(url);
|
||||
const tag = current?.[discrimField];
|
||||
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||
} catch {
|
||||
/* leave body as-is; the PUT below will surface any error */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await apiPut(url, body);
|
||||
// Invalidate the relevant cache so data refreshes
|
||||
@@ -243,4 +343,83 @@ export async function detachConnection(targetId: string, targetKind: string, fie
|
||||
return updateConnection(targetId, targetKind, field, '');
|
||||
}
|
||||
|
||||
/* ── List-element slots (composite layers / mapped zones) ──────────── */
|
||||
|
||||
/**
|
||||
* Targets that hold *list* reference slots. Editing one element means
|
||||
* re-PUTting the whole list, so we map the kind → its endpoint + cache.
|
||||
* (Only color strip sources have list slots today: composite `layers`,
|
||||
* mapped `zones`.)
|
||||
*/
|
||||
const _LIST_SLOT_TARGET: Readonly<Record<string, { endpoint: string; cache: { invalidate(): void } }>> = {
|
||||
color_strip_source: { endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-wire a single element of a list reference slot — e.g. a composite
|
||||
* `layers[index].source_id` or a mapped `zones[index].source_id`.
|
||||
*
|
||||
* The owning entity's PUT replaces the *entire* list, so this reads the entity
|
||||
* back, copies every element verbatim, changes only `list[index][refField]`,
|
||||
* and PUTs the full list (plus the discriminator). Echoing the existing element
|
||||
* objects is what preserves each layer/zone's other settings (blend mode,
|
||||
* opacity, LED range, per-layer brightness/template, …) — a naive partial write
|
||||
* would silently drop that config.
|
||||
*
|
||||
* @param newSourceId New source id, or '' to clear (only valid for optional refs).
|
||||
* @returns Promise<boolean> success
|
||||
*/
|
||||
export async function updateListSlotConnection(
|
||||
targetId: string,
|
||||
targetKind: string,
|
||||
listField: string,
|
||||
index: number,
|
||||
refField: string,
|
||||
newSourceId: string | null,
|
||||
expectedCurrent?: string | null,
|
||||
): Promise<boolean> {
|
||||
const target = _LIST_SLOT_TARGET[targetKind];
|
||||
if (!target || !Number.isInteger(index) || index < 0) return false;
|
||||
|
||||
const url = target.endpoint.replace('{id}', targetId);
|
||||
try {
|
||||
const current = await apiGet<Record<string, unknown>>(url);
|
||||
const list = current?.[listField];
|
||||
if (!Array.isArray(list) || index >= list.length) return false;
|
||||
|
||||
// Optimistic-concurrency guard: `index` is positional, so if the list was
|
||||
// reordered/edited out-of-band (e.g. via the entity editor) between render
|
||||
// and write — or between an action and its undo/redo — that index now points
|
||||
// at a *different* element. Refuse rather than rewrite the wrong slot.
|
||||
if (expectedCurrent != null) {
|
||||
const el = list[index] as Record<string, unknown>;
|
||||
const actual = typeof el?.[refField] === 'string' ? (el[refField] as string) : '';
|
||||
if (actual !== expectedCurrent) return false;
|
||||
}
|
||||
|
||||
// Copy every element; change only the one ref on the targeted element.
|
||||
// (`|| ''` clears the ref — only valid for *optional* refs; the graph only
|
||||
// re-wires the required `source_id`, so callers always pass a real id here.)
|
||||
const nextList = list.map((el, i) =>
|
||||
i === index
|
||||
? { ...(el as Record<string, unknown>), [refField]: newSourceId || '' }
|
||||
: { ...(el as Record<string, unknown>) },
|
||||
);
|
||||
const body: Record<string, unknown> = { [listField]: nextList };
|
||||
|
||||
// Discriminated-union PUT routes need the subtype echoed (see updateConnection).
|
||||
const discrimField = _DISCRIMINATOR_FIELD[targetKind];
|
||||
if (discrimField) {
|
||||
const tag = current[discrimField];
|
||||
if (typeof tag === 'string' && tag) body[discrimField] = tag;
|
||||
}
|
||||
|
||||
await apiPut(url, body);
|
||||
target.cache.invalidate();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { CONNECTION_MAP };
|
||||
|
||||
@@ -19,6 +19,9 @@ interface GraphEdge {
|
||||
type: string;
|
||||
field?: string;
|
||||
editable?: boolean;
|
||||
/** List-element reference (composite layer / mapped zone) — exposed as
|
||||
* `data-slot-*` so the editor can re-wire just this slot. */
|
||||
slot?: { list: string; index: number; ref: string };
|
||||
points?: { x: number; y: number }[] | null;
|
||||
fromNode?: GraphNodeRect;
|
||||
toNode?: GraphNodeRect;
|
||||
@@ -124,6 +127,12 @@ function _renderEdge(edge: GraphEdge): SVGElement {
|
||||
'data-to': to,
|
||||
'data-field': field || '',
|
||||
});
|
||||
// List-element reference: expose the slot so the editor can re-wire it.
|
||||
if (edge.slot) {
|
||||
path.setAttribute('data-slot-list', edge.slot.list);
|
||||
path.setAttribute('data-slot-index', String(edge.slot.index));
|
||||
path.setAttribute('data-slot-ref', edge.slot.ref);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
const title = svgEl('title');
|
||||
|
||||
@@ -29,6 +29,10 @@ interface LayoutEdge {
|
||||
label: string;
|
||||
type: string;
|
||||
editable: boolean;
|
||||
/** For list-element references (composite layers / mapped zones): which list,
|
||||
* which element index, and the reference field on that element. Lets the
|
||||
* editor re-wire one slot without disturbing its siblings. */
|
||||
slot?: { list: string; index: number; ref: string };
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -236,7 +240,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
nodeByIdLocal.set(id, node);
|
||||
}
|
||||
|
||||
function addEdge(from: string, to: string, field: string, label: string = ''): void {
|
||||
function addEdge(from: string, to: string, field: string, label: string = '', slot?: { list: string; index: number; ref: string }): void {
|
||||
if (!from || !to) return;
|
||||
// The referrer (`to`) is always a current entity in these loops; if the
|
||||
// referenced entity (`from`) is missing, the reference is dangling —
|
||||
@@ -254,7 +258,7 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
);
|
||||
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
|
||||
const editable = !field.includes('.');
|
||||
edges.push({ from, to, field, label, type, editable });
|
||||
edges.push({ from, to, field, label, type, editable, ...(slot ? { slot } : {}) });
|
||||
}
|
||||
|
||||
// Every entity may carry a custom `icon` (+ `icon_color`); pass them through
|
||||
@@ -348,6 +352,11 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
for (const s of e.valueSources || []) {
|
||||
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
|
||||
if (s.picture_source_id) addEdge(s.picture_source_id, s.id, 'picture_source_id');
|
||||
// Derived value sources: gradient_map derives from an inner value source;
|
||||
// css_extract derives from a color strip. Both are real, runtime-resolved
|
||||
// references (and drag-editable) — render them so they're visible.
|
||||
if (s.value_source_id) addEdge(s.value_source_id, s.id, 'value_source_id');
|
||||
if (s.color_strip_source_id) addEdge(s.color_strip_source_id, s.id, 'color_strip_source_id');
|
||||
}
|
||||
|
||||
// Color strip source edges
|
||||
@@ -360,19 +369,20 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
|
||||
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
|
||||
|
||||
// Composite layers
|
||||
// Composite layers — carry the slot index so each `layer.source_id`
|
||||
// edge can be re-wired individually from the graph (siblings untouched).
|
||||
if (s.layers) {
|
||||
for (const layer of s.layers) {
|
||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id');
|
||||
s.layers.forEach((layer: any, i: number) => {
|
||||
if (layer.source_id) addEdge(layer.source_id, s.id, 'layer.source_id', '', { list: 'layers', index: i, ref: 'source_id' });
|
||||
if (layer.brightness_source_id) addEdge(layer.brightness_source_id, s.id, 'layer.brightness_source_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mapped zones
|
||||
// Mapped zones — carry the slot index (re-wirable from the graph).
|
||||
if (s.zones) {
|
||||
for (const zone of s.zones) {
|
||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id');
|
||||
}
|
||||
s.zones.forEach((zone: any, i: number) => {
|
||||
if (zone.source_id) addEdge(zone.source_id, s.id, 'zone.source_id', '', { list: 'zones', index: i, ref: 'source_id' });
|
||||
});
|
||||
}
|
||||
|
||||
// Advanced picture calibration lines
|
||||
@@ -405,7 +415,6 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||
const transVsId = bindableSourceId(t.transition);
|
||||
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
||||
// KC target settings
|
||||
if (t.settings) {
|
||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||
|
||||
@@ -656,7 +656,7 @@ export function markIssues(group: SVGGElement, issues: Map<string, string[]>): v
|
||||
|
||||
for (const [id, msgs] of issues) {
|
||||
if (!msgs.length) continue;
|
||||
const el = group.querySelector(`.graph-node[data-id="${id}"]`);
|
||||
const el = group.querySelector(`.graph-node[data-id="${CSS.escape(id)}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('has-issue');
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { apiGet } from '../core/api-client.ts';
|
||||
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts';
|
||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge, validateConnection, getDependents } from '../core/graph-connections.ts';
|
||||
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, updateListSlotConnection, isEditableEdge, validateConnection, getDependents, checkSchemaDrift } from '../core/graph-connections.ts';
|
||||
import { showTypePicker } from '../core/icon-select.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { readJson, writeJson, isObject, isString, isNumber } from '../core/storage.ts';
|
||||
@@ -358,6 +358,10 @@ export async function loadGraphEditor(): Promise<void> {
|
||||
if (gc) gc.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Dev safety net: warn once if the frontend connection map drifts from the
|
||||
// backend schema (fire-and-forget; never blocks the graph).
|
||||
void checkSchemaDrift();
|
||||
|
||||
try {
|
||||
const entities = await _fetchAllEntities();
|
||||
// Index raw entities by id for subtype-safe bindable-slot resolution.
|
||||
@@ -2628,16 +2632,22 @@ function _onConnectPointerUp(e: PointerEvent): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the bindable slots the target entity actually exposes (subtype-safe)
|
||||
* — e.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
||||
* `smoothing`. Non-bindable matches always pass through.
|
||||
* Keep only the slots the target entity actually exposes (subtype-safe) — a
|
||||
* field is offered iff its first path segment is a key on the serialized entity.
|
||||
* E.g. an "effect" strip offers `intensity`/`scale`, a "picture" strip offers
|
||||
* `smoothing`; a processed strip offers `input_source_id`. Applies to ALL
|
||||
* matches (bindable and top-level alike); reference fields are always emitted by
|
||||
* `to_dict` so empty slots stay wireable.
|
||||
*/
|
||||
function _availableMatches<T extends { field: string; bindable?: boolean }>(matches: T[], targetId: string): T[] {
|
||||
function _availableMatches<T extends { field: string }>(matches: T[], targetId: string): T[] {
|
||||
const ent = _entitiesById.get(targetId);
|
||||
return matches.filter(m => {
|
||||
if (!m.bindable || !ent) return true;
|
||||
return m.field.split('.')[0] in ent;
|
||||
});
|
||||
if (!ent) return matches; // no data (e.g. freshly created) → don't over-filter
|
||||
// Offer a field only if the target entity actually exposes its slot (its
|
||||
// first path segment is a key on the serialized entity). Reference fields
|
||||
// are always emitted by `to_dict` even when empty, so empty slots stay
|
||||
// wireable (B2); subtype-specific fields (e.g. processed-strip
|
||||
// `input_source_id`) are correctly hidden on subtypes that lack them.
|
||||
return matches.filter(m => m.field.split('.')[0] in ent);
|
||||
}
|
||||
|
||||
/** Ask the user which field to wire when a source maps to multiple target fields. */
|
||||
@@ -2841,6 +2851,13 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
|
||||
_dismissEdgeContextMenu();
|
||||
|
||||
const field = edgePath.getAttribute('data-field') || '';
|
||||
// List-slot edges (composite layers / mapped zones) aren't editable through
|
||||
// the flat (to, field) path, but each carries its slot index so it can be
|
||||
// re-wired individually.
|
||||
if (edgePath.getAttribute('data-slot-list')) {
|
||||
_showListSlotRewireMenu(edgePath, e, container);
|
||||
return;
|
||||
}
|
||||
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
|
||||
|
||||
const toId = edgePath.getAttribute('data-to') ?? '';
|
||||
@@ -2877,6 +2894,73 @@ function _dismissEdgeContextMenu(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-wire menu for a list-slot edge (a composite layer or mapped zone source).
|
||||
* Each such edge carries `data-slot-*`, so we can replace just that one
|
||||
* element's reference and leave its siblings (and its own other settings)
|
||||
* untouched. The picker lists every node of the source's kind; the backend
|
||||
* rejects self-reference / cycles.
|
||||
*/
|
||||
function _showListSlotRewireMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void {
|
||||
const toId = edgePath.getAttribute('data-to') ?? ''; // composite/mapped owner
|
||||
const fromId = edgePath.getAttribute('data-from') ?? ''; // current source occupant
|
||||
const listField = edgePath.getAttribute('data-slot-list') ?? '';
|
||||
const index = parseInt(edgePath.getAttribute('data-slot-index') ?? '', 10);
|
||||
const refField = edgePath.getAttribute('data-slot-ref') ?? '';
|
||||
const toNode = _nodeMap?.get(toId);
|
||||
if (!toNode || Number.isNaN(index)) return;
|
||||
|
||||
const sourceKind = _nodeMap?.get(fromId)?.kind ?? 'color_strip_source';
|
||||
const candidates = [...(_nodeMap?.values() ?? [])]
|
||||
.filter((n: any) => n.kind === sourceKind && n.id !== toId && n.id !== fromId)
|
||||
.sort((a: any, b: any) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'graph-edge-menu';
|
||||
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
|
||||
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'graph-edge-menu-item';
|
||||
btn.textContent = t('graph.rewire');
|
||||
btn.addEventListener('click', () => {
|
||||
_dismissEdgeContextMenu();
|
||||
if (candidates.length === 0) { showToast(t('graph.no_compatible_connection'), 'info'); return; }
|
||||
showTypePicker({
|
||||
title: t('graph.rewire_choose_source'),
|
||||
items: candidates.map((n: any) => ({ value: n.id, icon: _ico(P.link), label: n.name || n.id })),
|
||||
onPick: (newSourceId: string) => { void _doListSlotRewire(toId, toNode.kind, listField, index, refField, newSourceId, fromId); },
|
||||
});
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
|
||||
container.querySelector('.graph-container')!.appendChild(menu);
|
||||
_edgeContextMenu = menu;
|
||||
}
|
||||
|
||||
/** Re-wire one list slot (composite layer / mapped zone) and record an undoable action. */
|
||||
async function _doListSlotRewire(
|
||||
targetId: string, targetKind: string, listField: string, index: number,
|
||||
refField: string, newSourceId: string, prevSourceId: string,
|
||||
): Promise<void> {
|
||||
if (newSourceId === prevSourceId) return;
|
||||
// Pass the expected current occupant so the write refuses if the slot was
|
||||
// reordered/edited out-of-band (positional `index` could otherwise hit the
|
||||
// wrong element). Each step expects the slot to still hold what it replaces.
|
||||
const ok = await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId);
|
||||
if (ok) {
|
||||
pushUndoAction({
|
||||
label: t('graph.action.rewire'),
|
||||
undo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, prevSourceId, newSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||
redo: async () => { if (!(await updateListSlotConnection(targetId, targetKind, listField, index, refField, newSourceId, prevSourceId))) throw new Error(t('graph.connection_failed')); },
|
||||
});
|
||||
showToast(t('graph.connection_updated'), 'success');
|
||||
await loadGraphEditor();
|
||||
} else {
|
||||
showToast(t('graph.connection_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _detachSelectedEdge(): Promise<void> {
|
||||
if (!_selectedEdge) return;
|
||||
const { from, to, field, targetKind } = _selectedEdge;
|
||||
|
||||
@@ -2590,7 +2590,10 @@
|
||||
"graph.action.connect": "Connect",
|
||||
"graph.action.disconnect": "Disconnect",
|
||||
"graph.action.move": "Move node",
|
||||
"graph.action.rewire": "Re-wire slot",
|
||||
"graph.choose_connection": "Choose connection",
|
||||
"graph.rewire": "Re-wire…",
|
||||
"graph.rewire_choose_source": "Choose a new source",
|
||||
"graph.issues": "Issues",
|
||||
"graph.issues_none": "No issues found",
|
||||
"graph.issue.broken_ref": "Broken reference: {field}",
|
||||
|
||||
@@ -2272,7 +2272,10 @@
|
||||
"graph.action.connect": "Соединить",
|
||||
"graph.action.disconnect": "Отсоединить",
|
||||
"graph.action.move": "Переместить узел",
|
||||
"graph.action.rewire": "Переподключить слот",
|
||||
"graph.choose_connection": "Выберите соединение",
|
||||
"graph.rewire": "Переподключить…",
|
||||
"graph.rewire_choose_source": "Выберите новый источник",
|
||||
"graph.issues": "Проблемы",
|
||||
"graph.issues_none": "Проблем не найдено",
|
||||
"graph.issue.broken_ref": "Битая ссылка: {field}",
|
||||
|
||||
@@ -2268,7 +2268,10 @@
|
||||
"graph.action.connect": "连接",
|
||||
"graph.action.disconnect": "断开连接",
|
||||
"graph.action.move": "移动节点",
|
||||
"graph.action.rewire": "重新连接槽位",
|
||||
"graph.choose_connection": "选择连接",
|
||||
"graph.rewire": "重新连接…",
|
||||
"graph.rewire_choose_source": "选择新的来源",
|
||||
"graph.issues": "问题",
|
||||
"graph.issues_none": "未发现问题",
|
||||
"graph.issue.broken_ref": "无效引用:{field}",
|
||||
|
||||
@@ -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