From 2e51f46dfd7f8f9bc6502f7d680d23a25df9288b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 02:29:19 +0300 Subject: [PATCH] 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). --- TODO.md | 78 ++++++-- server/src/ledgrab/api/graph_schema.py | 74 ++++++- server/src/ledgrab/api/routes/graph.py | 4 +- .../static/js/core/graph-connections.ts | 181 +++++++++++++++++- .../src/ledgrab/static/js/core/graph-edges.ts | 9 + .../ledgrab/static/js/core/graph-layout.ts | 31 +-- .../src/ledgrab/static/js/core/graph-nodes.ts | 2 +- .../static/js/features/graph-editor.ts | 102 +++++++++- server/src/ledgrab/static/locales/en.json | 3 + server/src/ledgrab/static/locales/ru.json | 3 + server/src/ledgrab/static/locales/zh.json | 3 + server/tests/api/test_graph_schema.py | 81 +++++++- .../tests/api/test_graph_wiring_contract.py | 159 +++++++++++++++ 13 files changed, 677 insertions(+), 53 deletions(-) create mode 100644 server/tests/api/test_graph_wiring_contract.py diff --git a/TODO.md b/TODO.md index 18b293a..67da460 100644 --- a/TODO.md +++ b/TODO.md @@ -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 `{ : { 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 diff --git a/server/src/ledgrab/api/graph_schema.py b/server/src/ledgrab/api/graph_schema.py index 30d674a..a8f7085 100644 --- a/server/src/ledgrab/api/graph_schema.py +++ b/server/src/ledgrab/api/graph_schema.py @@ -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: diff --git a/server/src/ledgrab/api/routes/graph.py b/server/src/ledgrab/api/routes/graph.py index 38fc74a..b28a788 100644 --- a/server/src/ledgrab/api/routes/graph.py +++ b/server/src/ledgrab/api/routes/graph.py @@ -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 diff --git a/server/src/ledgrab/static/js/core/graph-connections.ts b/server/src/ledgrab/static/js/core/graph-connections.ts index 4e06adc..9fdc32e 100644 --- a/server/src/ledgrab/static/js/core/graph-connections.ts +++ b/server/src/ledgrab/static/js/core/graph-connections.ts @@ -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([ + // (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 { + 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(); + 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(); + 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> = { + 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>(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> = { + 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 success + */ +export async function updateListSlotConnection( + targetId: string, + targetKind: string, + listField: string, + index: number, + refField: string, + newSourceId: string | null, + expectedCurrent?: string | null, +): Promise { + 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>(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; + 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), [refField]: newSourceId || '' } + : { ...(el as Record) }, + ); + const body: Record = { [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 }; diff --git a/server/src/ledgrab/static/js/core/graph-edges.ts b/server/src/ledgrab/static/js/core/graph-edges.ts index f14e9b2..a675673 100644 --- a/server/src/ledgrab/static/js/core/graph-edges.ts +++ b/server/src/ledgrab/static/js/core/graph-edges.ts @@ -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'); diff --git a/server/src/ledgrab/static/js/core/graph-layout.ts b/server/src/ledgrab/static/js/core/graph-layout.ts index b0b31db..e855ee6 100644 --- a/server/src/ledgrab/static/js/core/graph-layout.ts +++ b/server/src/ledgrab/static/js/core/graph-layout.ts @@ -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'); diff --git a/server/src/ledgrab/static/js/core/graph-nodes.ts b/server/src/ledgrab/static/js/core/graph-nodes.ts index d758403..490f662 100644 --- a/server/src/ledgrab/static/js/core/graph-nodes.ts +++ b/server/src/ledgrab/static/js/core/graph-nodes.ts @@ -656,7 +656,7 @@ export function markIssues(group: SVGGElement, issues: Map): 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'); diff --git a/server/src/ledgrab/static/js/features/graph-editor.ts b/server/src/ledgrab/static/js/features/graph-editor.ts index 4de96c5..77c8323 100644 --- a/server/src/ledgrab/static/js/features/graph-editor.ts +++ b/server/src/ledgrab/static/js/features/graph-editor.ts @@ -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 { 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(matches: T[], targetId: string): T[] { +function _availableMatches(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 { + 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 { if (!_selectedEdge) return; const { from, to, field, targetKind } = _selectedEdge; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 220eeff..c2dd9b8 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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}", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index c687c41..3a0010b 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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}", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index ed5bb14..c450ce2 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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}", diff --git a/server/tests/api/test_graph_schema.py b/server/tests/api/test_graph_schema.py index 3c938f7..e73c3f2 100644 --- a/server/tests/api/test_graph_schema.py +++ b/server/tests/api/test_graph_schema.py @@ -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(): diff --git a/server/tests/api/test_graph_wiring_contract.py b/server/tests/api/test_graph_wiring_contract.py new file mode 100644 index 0000000..72cc8ce --- /dev/null +++ b/server/tests/api/test_graph_wiring_contract.py @@ -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 {: {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