Skip to main content

jellyflow_egui/
lib.rs

1//! Immediate-mode egui adapter for Jellyflow.
2//!
3//! This crate keeps rendering, windowing, and pointer capture in egui while delegating graph
4//! semantics, layout, and mutation rules to the headless Jellyflow crates.
5
6#![deny(unsafe_code)]
7
8pub use eframe::egui;
9
10pub mod app;
11pub mod bridge;
12mod handle_layout;
13pub mod input;
14pub mod renderer;
15pub mod samples;
16pub mod state;
17pub mod ui;
18
19pub use app::JellyflowEguiApp;
20pub use bridge::JellyflowEguiBridge;
21pub use renderer::{
22    EguiNodeControlEdit, EguiNodeRepeatableAction, EguiNodeWidgetRenderOutcome,
23    EguiNodeWidgetRenderer, EguiRepeatableMoveDirection, FieldListNodeRenderer, NodeContentLevel,
24    NodeInteractiveRegion, NodeRenderInput, NodeRenderLayout, NodeRendererState, NodeRendererStyle,
25    NodeWidgetRenderInput, RendererCatalog, RichNodeRenderer,
26};
27pub use samples::{SampleGraphError, SampleGraphKind};
28pub use state::{
29    ActiveCanvasInteraction, CanvasSnapshot, CanvasTool, HoverTarget, InspectorState,
30    JellyflowEguiState, LayoutPresetChoice,
31};
32
33#[cfg(test)]
34mod tests {
35    use super::{
36        ActiveCanvasInteraction, CanvasSnapshot, EguiNodeControlEdit, EguiNodeRepeatableAction,
37        EguiNodeWidgetRenderOutcome, EguiRepeatableMoveDirection, JellyflowEguiApp,
38        JellyflowEguiBridge, NodeContentLevel, NodeRendererStyle, RendererCatalog, SampleGraphKind,
39    };
40    use eframe::egui::{Pos2, Rect, Vec2};
41    use jellyflow::core::{
42        CanvasPoint, CanvasRect, CanvasSize, DefaultTypeCompatibility, GraphOp, GraphTransaction,
43        NodeId, PortDirection, PortKind, TypeCompatibility, TypeDesc,
44    };
45    use jellyflow::runtime::runtime::connection::{
46        ConnectEdgeRequest, ConnectionHandleRef, ConnectionHandleValidity,
47    };
48    use jellyflow::runtime::runtime::drag::NodeNudgeDirection;
49    use jellyflow::runtime::runtime::geometry::HandlePosition;
50    use jellyflow::runtime::runtime::measurement::{
51        NodeHandleMeasurementSource, NodeInternalsInvalidationReason, NodeMeasurementStatus,
52    };
53    use jellyflow::runtime::runtime::resize::NodeResizeDirection;
54    use jellyflow::runtime::schema::NodeControlBinding;
55
56    #[test]
57    fn demo_app_builds_without_windowing() {
58        let app = JellyflowEguiApp::demo().expect("demo app builds");
59
60        assert!(!app.bridge.store().graph().nodes().is_empty());
61        assert!(app.bridge.descriptors().len() >= 4);
62        assert!(
63            app.state.canvas.fit_view_requested,
64            "new demo apps should fit once after the real canvas size is known"
65        );
66    }
67
68    #[test]
69    fn all_sample_graphs_build_with_nodes_edges_and_default_layouts() {
70        for sample in SampleGraphKind::ALL {
71            let app = JellyflowEguiApp::sample(sample).expect("sample app builds");
72
73            assert_eq!(app.state.selected_sample, sample);
74            assert_eq!(app.state.selected_layout_preset, sample.default_layout());
75            assert!(app.state.status_message.is_none());
76            assert!(
77                app.bridge.store().graph().nodes().len() >= 3,
78                "{sample:?} should contain multiple nodes"
79            );
80            assert!(
81                !app.bridge.store().graph().edges().is_empty(),
82                "{sample:?} should contain edges"
83            );
84        }
85    }
86
87    #[test]
88    fn product_samples_reuse_edge_metadata_and_port_descriptors() {
89        let app =
90            JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder).expect("sample builds");
91
92        assert!(app.bridge.store().graph().edges().values().any(|edge| {
93            edge.view.label.as_deref() == Some("error")
94                && edge.view.renderer_key.as_deref() == Some("sample-edge")
95        }));
96        assert!(app.bridge.store().graph().ports().values().any(|port| {
97            port.kind == PortKind::Exec && port.dir == PortDirection::Out && port.key.0 == "yes"
98        }));
99
100        let erd = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
101        let table_descriptor = erd
102            .bridge
103            .descriptors()
104            .into_iter()
105            .find(|descriptor| descriptor.kind.0 == "demo.table")
106            .expect("table descriptor exists");
107        assert!(
108            table_descriptor
109                .ports
110                .iter()
111                .any(|port| port.view.anchor.as_deref() == Some("field.primary_key"))
112        );
113        assert!(erd.bridge.store().graph().edges().values().any(|edge| {
114            edge.view.label.as_deref() == Some("1:N")
115                && edge.data.get("label").and_then(|value| value.as_str()) == Some("1:N")
116        }));
117
118        let shader =
119            JellyflowEguiApp::sample(SampleGraphKind::ShaderGraph).expect("shader sample builds");
120        assert!(shader.bridge.store().graph().nodes().values().any(|node| {
121            node.kind.0 == "demo.shader.mix"
122                && node
123                    .data
124                    .get("config")
125                    .and_then(|value| value.get("factor"))
126                    .is_some()
127        }));
128        assert!(shader.bridge.store().graph().ports().values().any(|port| {
129            port.kind == PortKind::Data && port.key.0 == "color" && port.dir == PortDirection::Out
130        }));
131        assert!(shader.bridge.store().graph().ports().values().any(|port| {
132            port.key.0 == "factor" && port.ty.as_ref().is_some_and(|ty| *ty == TypeDesc::Float)
133        }));
134        assert!(shader.bridge.store().graph().ports().values().any(|port| {
135            port.key.0 == "color" && port.ty.as_ref().is_some_and(|ty| *ty == shader_vec4())
136        }));
137        let shader_descriptor = shader
138            .bridge
139            .descriptors()
140            .into_iter()
141            .find(|descriptor| descriptor.renderer_key == "shader-card")
142            .expect("shader descriptor exists");
143        assert!(
144            shader_descriptor
145                .surface_slots
146                .iter()
147                .any(|slot| slot.anchor.as_deref() == Some("rail.inputs"))
148        );
149    }
150
151    #[test]
152    fn shader_sample_rejects_incompatible_typed_connections_through_default_store_path() {
153        let mut shader =
154            JellyflowEguiApp::sample(SampleGraphKind::ShaderGraph).expect("shader sample builds");
155        let graph = shader.bridge.store().graph();
156        let color = graph
157            .ports()
158            .iter()
159            .find_map(|(id, port)| {
160                (port.key.0 == "color" && port.dir == PortDirection::Out).then_some(*id)
161            })
162            .expect("shader color output exists");
163        let factor = graph
164            .ports()
165            .iter()
166            .find_map(|(id, port)| {
167                (port.key.0 == "factor" && port.dir == PortDirection::In).then_some(*id)
168            })
169            .expect("shader factor input exists");
170        let mode = shader
171            .bridge
172            .store()
173            .resolved_interaction_state()
174            .connection_mode;
175
176        let err = shader
177            .bridge
178            .store_mut()
179            .apply_connect_edge(ConnectEdgeRequest::new(color, factor, mode))
180            .expect_err("vec4 color output must not connect to float factor input");
181
182        let diagnostics = err.diagnostics().expect("typed rejection diagnostics");
183        assert!(
184            diagnostics
185                .iter()
186                .any(|diagnostic| diagnostic.key == "connect.type_mismatch")
187        );
188    }
189
190    #[test]
191    fn renderer_catalog_uses_registered_styles_and_fallback() {
192        let mut catalog = RendererCatalog::new();
193        catalog.register("custom-card", NodeRendererStyle::task());
194
195        assert_eq!(
196            catalog.style_for_key("custom-card"),
197            NodeRendererStyle::task()
198        );
199        assert_eq!(
200            catalog.style_for_key("unknown"),
201            NodeRendererStyle::fallback()
202        );
203
204        let bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
205        let descriptor = bridge
206            .descriptors()
207            .into_iter()
208            .find(|descriptor| descriptor.renderer_key == "task-card")
209            .expect("demo registry exposes task renderer");
210        assert_eq!(
211            RendererCatalog::default().style_for_descriptor(&descriptor),
212            NodeRendererStyle::task()
213        );
214        let topic = bridge
215            .descriptors()
216            .into_iter()
217            .find(|descriptor| descriptor.renderer_key == "topic-card")
218            .expect("demo registry exposes topic renderer");
219        assert_eq!(
220            RendererCatalog::default().style_for_descriptor(&topic),
221            NodeRendererStyle::topic()
222        );
223    }
224
225    #[test]
226    fn default_handle_bounds_follow_explicit_node_size() {
227        let mut bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
228        let node = bridge
229            .store()
230            .graph()
231            .nodes()
232            .iter()
233            .find_map(|(node, record)| (record.kind.0 == "demo.task").then_some(*node))
234            .expect("demo task node exists");
235        let from_size = bridge.store().graph().nodes()[&node].size;
236        let to_size = CanvasSize {
237            width: 260.0,
238            height: 120.0,
239        };
240        bridge
241            .store_mut()
242            .dispatch_transaction(&GraphTransaction::from_ops([GraphOp::SetNodeSize {
243                id: node,
244                from: from_size,
245                to: Some(to_size),
246            }]))
247            .expect("size update dispatches");
248
249        let output = bridge
250            .default_handle_bounds(node)
251            .into_iter()
252            .find(|(handle, _)| handle.direction == PortDirection::Out)
253            .expect("task output handle exists");
254
255        assert_eq!(output.1.rect.origin.x, to_size.width - 5.0);
256        assert_eq!(output.1.rect.origin.y, to_size.height * 0.5 - 5.0);
257    }
258
259    #[test]
260    fn handle_bounds_follow_port_view_descriptor_sides() {
261        let bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
262        let decision = bridge
263            .store()
264            .graph()
265            .nodes()
266            .iter()
267            .find_map(|(node, record)| (record.kind.0 == "demo.decision").then_some(*node))
268            .expect("decision node exists");
269        let handles = bridge.default_handle_bounds(decision);
270
271        assert!(handles.iter().any(|(handle, bounds)| {
272            handle.direction == PortDirection::In && bounds.position == HandlePosition::Left
273        }));
274        assert!(handles.iter().any(|(handle, bounds)| {
275            bridge.store().graph().ports()[&handle.port].key.0 == "yes"
276                && bounds.position == HandlePosition::Top
277        }));
278        assert!(handles.iter().any(|(handle, bounds)| {
279            bridge.store().graph().ports()[&handle.port].key.0 == "no"
280                && bounds.position == HandlePosition::Bottom
281        }));
282    }
283
284    #[test]
285    fn erd_snapshot_places_table_handles_on_field_anchor_regions() {
286        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
287        let snapshot = app.bridge.rebuild_snapshot(
288            &CanvasSnapshot::empty(),
289            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
290        );
291        let graph = app.bridge.store().graph();
292        let table = graph
293            .nodes()
294            .iter()
295            .find_map(|(node, record)| (record.kind.0 == "demo.table").then_some(*node))
296            .expect("table node exists");
297        let mut pk = None;
298        let mut fk = None;
299        for port in &graph.nodes()[&table].ports {
300            let record = &graph.ports()[port];
301            let handle = ConnectionHandleRef::new(table, *port, record.dir);
302            let bounds = snapshot
303                .handle_bounds
304                .get(&handle)
305                .copied()
306                .expect("handle bounds exist");
307            match record.key.0.as_str() {
308                "pk" => pk = Some(bounds),
309                "fk" => fk = Some(bounds),
310                _ => {}
311            }
312        }
313        let pk = pk.expect("primary key handle exists");
314        let fk = fk.expect("foreign key handle exists");
315
316        assert_eq!(pk.position, HandlePosition::Right);
317        assert_eq!(fk.position, HandlePosition::Left);
318        assert!(
319            pk.rect.origin.y < fk.rect.origin.y,
320            "pk should align to the first table field and fk to a later field"
321        );
322    }
323
324    #[test]
325    fn erd_snapshot_reports_semantic_region_measurements_to_runtime() {
326        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
327        let first = app.bridge.rebuild_snapshot(
328            &CanvasSnapshot::empty(),
329            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
330        );
331        let graph = app.bridge.store().graph();
332        let table = graph
333            .nodes()
334            .iter()
335            .find_map(|(node, record)| (record.kind.0 == "demo.table").then_some(*node))
336            .expect("table node exists");
337        assert!(matches!(
338            first.layout_facts.node_measurement_status(table),
339            NodeMeasurementStatus::Fresh { .. }
340        ));
341        let pk_port = graph.nodes()[&table]
342            .ports
343            .iter()
344            .copied()
345            .find(|port| graph.ports()[port].key.0 == "pk")
346            .expect("pk port exists");
347
348        let measurement = app
349            .bridge
350            .store()
351            .node_measurement(table)
352            .expect("runtime measurement reported");
353        assert!(
354            measurement
355                .slots
356                .iter()
357                .any(|slot| slot.key == "field.primary_key")
358        );
359        assert!(
360            measurement
361                .anchors
362                .iter()
363                .any(|anchor| anchor.anchor == "field.primary_key"
364                    && anchor.port_key.as_ref().is_some_and(|key| key.0 == "pk"))
365        );
366
367        let source = app
368            .bridge
369            .store()
370            .resolve_node_handle_measurement(ConnectionHandleRef::new(
371                table,
372                pk_port,
373                graph.ports()[&pk_port].dir,
374            ))
375            .source;
376        assert!(matches!(
377            source,
378            NodeHandleMeasurementSource::MeasuredHandle
379                | NodeHandleMeasurementSource::MeasuredAnchor { .. }
380        ));
381    }
382
383    #[test]
384    fn shader_graph_typed_ports_reject_incompatible_hover_and_commit() {
385        let mut app =
386            JellyflowEguiApp::sample(SampleGraphKind::ShaderGraph).expect("shader sample builds");
387        let _snapshot = app.bridge.rebuild_snapshot(
388            &CanvasSnapshot::empty(),
389            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
390        );
391        let graph = app.bridge.store().graph();
392        let texture = graph
393            .nodes()
394            .iter()
395            .find_map(|(node, record)| {
396                (record.kind.0 == "demo.shader.texture_sample").then_some(*node)
397            })
398            .expect("texture node exists");
399        let mix = graph
400            .nodes()
401            .iter()
402            .find_map(|(node, record)| (record.kind.0 == "demo.shader.mix").then_some(*node))
403            .expect("mix node exists");
404        let color = graph.nodes()[&texture]
405            .ports
406            .iter()
407            .copied()
408            .find(|port| {
409                graph.ports()[port].key.0 == "color"
410                    && graph.ports()[port].dir == PortDirection::Out
411            })
412            .expect("texture color port exists");
413        let factor = graph.nodes()[&mix]
414            .ports
415            .iter()
416            .copied()
417            .find(|port| graph.ports()[port].key.0 == "factor")
418            .expect("factor port exists");
419        let color_handle = ConnectionHandleRef::new(texture, color, graph.ports()[&color].dir);
420        let factor_handle = ConnectionHandleRef::new(mix, factor, graph.ports()[&factor].dir);
421        let factor_bounds = app
422            .bridge
423            .store()
424            .resolve_node_handle_measurement(factor_handle)
425            .bounds
426            .expect("factor bounds");
427        let mix_pos = graph.nodes()[&mix].pos;
428        let pointer = CanvasPoint {
429            x: mix_pos.x + factor_bounds.rect.origin.x + factor_bounds.rect.size.width * 0.5,
430            y: mix_pos.y + factor_bounds.rect.origin.y + factor_bounds.rect.size.height * 0.5,
431        };
432
433        let hover = app.bridge.resolve_connection_target(pointer, color_handle);
434
435        assert_eq!(hover.feedback, ConnectionHandleValidity::Invalid);
436        assert!(!hover.is_handle_valid);
437        assert!(
438            app.bridge
439                .connect_handles(color_handle, factor_handle)
440                .expect_err("float factor must not connect to vec4 albedo")
441                .contains("type mismatch")
442        );
443
444        let mut compat = DefaultTypeCompatibility;
445        assert!(
446            !compat
447                .compatible(&TypeDesc::Float, &shader_vec4())
448                .is_compatible()
449        );
450    }
451
452    #[test]
453    fn snapshot_caches_node_render_layouts_for_visible_nodes() {
454        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
455        let snapshot = app.bridge.rebuild_snapshot(
456            &CanvasSnapshot::empty(),
457            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
458        );
459
460        assert!(
461            snapshot
462                .visible_node_ids
463                .iter()
464                .all(|node| snapshot.node_render_layouts.contains_key(node))
465        );
466        assert!(snapshot.node_render_layouts.values().any(|layout| {
467            layout
468                .interactive_regions
469                .iter()
470                .any(|region| region.key == "field.primary_key")
471        }));
472    }
473
474    #[test]
475    fn table_resize_clamps_to_renderer_minimum_size() {
476        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
477        let snapshot = app.bridge.rebuild_snapshot(
478            &CanvasSnapshot::empty(),
479            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
480        );
481        let graph = app.bridge.store().graph();
482        let table = graph
483            .nodes()
484            .iter()
485            .find_map(|(node, record)| (record.kind.0 == "demo.table").then_some(*node))
486            .expect("table node exists");
487        let from_size = graph.nodes()[&table].size;
488        app.bridge
489            .store_mut()
490            .dispatch_transaction(&GraphTransaction::from_ops([GraphOp::SetNodeSize {
491                id: table,
492                from: from_size,
493                to: Some(CanvasSize {
494                    width: 360.0,
495                    height: 240.0,
496                }),
497            }]))
498            .expect("table can be enlarged for resize test");
499        let snapshot = app.bridge.rebuild_snapshot(
500            &snapshot,
501            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
502        );
503        let rect = snapshot
504            .node_rects
505            .get(&table)
506            .copied()
507            .expect("table rect exists");
508        let min_size = snapshot
509            .node_render_layouts
510            .get(&table)
511            .expect("table render layout exists")
512            .min_size;
513        let start = CanvasPoint {
514            x: rect.origin.x + rect.size.width,
515            y: rect.origin.y + rect.size.height,
516        };
517        let current = CanvasPoint {
518            x: start.x - 1_000.0,
519            y: start.y - 1_000.0,
520        };
521
522        let plan = app
523            .bridge
524            .plan_pointer_resize(table, start, current, NodeResizeDirection::BottomRight)
525            .expect("overshot resize clamps to renderer minimum");
526
527        assert_eq!(plan.to, min_size);
528        app.bridge
529            .commit_interaction(ActiveCanvasInteraction::NodeResize {
530                node: table,
531                direction: NodeResizeDirection::BottomRight,
532                start_pointer: start,
533                current_pointer: current,
534                preview: Some(plan),
535            })
536            .expect("resize commit succeeds")
537            .expect("resize commits");
538        assert_eq!(
539            app.bridge.store().graph().nodes()[&table].size,
540            Some(min_size)
541        );
542    }
543
544    #[test]
545    fn resize_commit_remeasures_node_internals_on_next_snapshot() {
546        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
547        let snapshot = app.bridge.rebuild_snapshot(
548            &CanvasSnapshot::empty(),
549            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
550        );
551        let snapshot = app.bridge.rebuild_snapshot(
552            &snapshot,
553            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
554        );
555        let graph = app.bridge.store().graph();
556        let table = graph
557            .nodes()
558            .iter()
559            .find_map(|(node, record)| (record.kind.0 == "demo.table").then_some(*node))
560            .expect("table node exists");
561        assert!(matches!(
562            app.bridge.store().node_measurement_status(table),
563            NodeMeasurementStatus::Fresh { .. }
564        ));
565        let rect = snapshot.node_rects[&table];
566        let start = CanvasPoint {
567            x: rect.origin.x + rect.size.width,
568            y: rect.origin.y + rect.size.height,
569        };
570        let current = CanvasPoint {
571            x: start.x + 96.0,
572            y: start.y + 32.0,
573        };
574        let plan = app
575            .bridge
576            .plan_pointer_resize(table, start, current, NodeResizeDirection::BottomRight)
577            .expect("resize plan");
578
579        app.bridge
580            .commit_interaction(ActiveCanvasInteraction::NodeResize {
581                node: table,
582                direction: NodeResizeDirection::BottomRight,
583                start_pointer: start,
584                current_pointer: current,
585                preview: Some(plan),
586            })
587            .expect("resize commit succeeds")
588            .expect("resize commits");
589
590        assert!(
591            matches!(
592                app.bridge.store().node_measurement_status(table),
593                NodeMeasurementStatus::Dirty { .. }
594            ),
595            "adapter must mark resized node internals dirty so old field handles are not reused"
596        );
597
598        let next = app.bridge.rebuild_snapshot(
599            &snapshot,
600            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
601        );
602        assert!(
603            matches!(
604                app.bridge.store().node_measurement_status(table),
605                NodeMeasurementStatus::Fresh { .. }
606            ),
607            "the next adapter snapshot must report current geometry before querying layout facts"
608        );
609        assert!(matches!(
610            next.layout_facts.node_measurement_status(table),
611            NodeMeasurementStatus::Fresh { .. }
612        ));
613        assert_eq!(
614            next.node_rects[&table].size,
615            app.bridge.store().graph().nodes()[&table].size.unwrap()
616        );
617    }
618
619    #[test]
620    fn zoom_change_invalidates_visible_node_internals_for_density_remeasurement() {
621        let mut app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
622            .expect("automation sample builds");
623        let snapshot = app.bridge.rebuild_snapshot(
624            &CanvasSnapshot::empty(),
625            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
626        );
627        let _snapshot = app.bridge.rebuild_snapshot(
628            &snapshot,
629            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
630        );
631        let node = *app
632            .bridge
633            .store()
634            .graph()
635            .nodes()
636            .keys()
637            .next()
638            .expect("node exists");
639        assert!(matches!(
640            app.bridge.store().node_measurement_status(node),
641            NodeMeasurementStatus::Fresh { .. }
642        ));
643
644        assert!(
645            app.bridge
646                .zoom_at_screen(CanvasPoint { x: 400.0, y: 240.0 }, 0.55)
647        );
648
649        assert!(
650            matches!(
651                app.bridge.store().node_measurement_status(node),
652                NodeMeasurementStatus::Dirty { .. }
653            ),
654            "zoom changes can change density/content slots, so adapter measurements must be dirty"
655        );
656    }
657
658    #[test]
659    fn data_change_notification_invalidates_node_internals_until_remeasured() {
660        let mut app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
661            .expect("automation sample builds");
662        let snapshot = app.bridge.rebuild_snapshot(
663            &CanvasSnapshot::empty(),
664            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
665        );
666        let snapshot = app.bridge.rebuild_snapshot(
667            &snapshot,
668            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
669        );
670        let node = *app
671            .bridge
672            .store()
673            .graph()
674            .nodes()
675            .keys()
676            .next()
677            .expect("node exists");
678        assert!(matches!(
679            app.bridge.store().node_measurement_status(node),
680            NodeMeasurementStatus::Fresh { .. }
681        ));
682
683        app.bridge.notify_node_data_changed(node);
684
685        assert!(matches!(
686            app.bridge.store().node_measurement_status(node),
687            NodeMeasurementStatus::Dirty {
688                reason: NodeInternalsInvalidationReason::DataChanged,
689                ..
690            }
691        ));
692
693        let _stale = app.bridge.rebuild_snapshot(
694            &snapshot,
695            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
696        );
697        assert!(
698            matches!(
699                app.bridge.store().node_measurement_status(node),
700                NodeMeasurementStatus::Fresh { revision } if revision > 0
701            ),
702            "current widget geometry reported during rebuild should clear data-change dirty state"
703        );
704
705        let _fresh = app.bridge.rebuild_snapshot(
706            &_stale,
707            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
708        );
709        assert!(matches!(
710            app.bridge.store().node_measurement_status(node),
711            NodeMeasurementStatus::Fresh { .. }
712        ));
713    }
714
715    #[test]
716    fn component_state_change_notification_uses_component_dirty_reason() {
717        let mut app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
718            .expect("automation sample builds");
719        let snapshot = app.bridge.rebuild_snapshot(
720            &CanvasSnapshot::empty(),
721            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
722        );
723        let _snapshot = app.bridge.rebuild_snapshot(
724            &snapshot,
725            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
726        );
727        let node = *app
728            .bridge
729            .store()
730            .graph()
731            .nodes()
732            .keys()
733            .next()
734            .expect("node exists");
735
736        app.bridge.notify_node_component_state_changed(node);
737
738        assert!(matches!(
739            app.bridge.store().node_measurement_status(node),
740            NodeMeasurementStatus::Dirty {
741                reason: NodeInternalsInvalidationReason::ComponentStateChanged,
742                ..
743            }
744        ));
745    }
746
747    #[test]
748    fn node_widget_outcome_control_edit_updates_node_data_and_marks_dirty() {
749        let mut app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
750            .expect("automation sample builds");
751        let snapshot = app.bridge.rebuild_snapshot(
752            &CanvasSnapshot::empty(),
753            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
754        );
755        let _snapshot = app.bridge.rebuild_snapshot(
756            &snapshot,
757            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
758        );
759        let node = app
760            .bridge
761            .store()
762            .graph()
763            .nodes()
764            .iter()
765            .find_map(|(id, node)| (node.kind.0 == "demo.llm").then_some(*id))
766            .expect("automation llm node exists");
767
768        app.bridge
769            .apply_node_widget_outcome(
770                node,
771                EguiNodeWidgetRenderOutcome {
772                    rendered: true,
773                    changed: true,
774                    control_edits: vec![EguiNodeControlEdit::new(
775                        "control.prompt",
776                        NodeControlBinding::data_path("fields.prompt"),
777                        serde_json::json!("Updated prompt"),
778                    )],
779                    repeatable_actions: Vec::new(),
780                },
781            )
782            .expect("control edit applies");
783
784        let node_record = &app.bridge.store().graph().nodes()[&node];
785        assert_eq!(node_record.data["fields"]["prompt"], "Updated prompt");
786        assert!(matches!(
787            app.bridge.store().node_measurement_status(node),
788            NodeMeasurementStatus::Dirty {
789                reason: NodeInternalsInvalidationReason::DataChanged,
790                ..
791            }
792        ));
793    }
794
795    #[test]
796    fn repeatable_actions_add_move_and_remove_items_through_store_transactions() {
797        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
798        let node = app
799            .bridge
800            .store()
801            .graph()
802            .nodes()
803            .iter()
804            .find_map(|(id, node)| (node.kind.0 == "demo.table").then_some(*id))
805            .expect("erd table node exists");
806        let original_len = app.bridge.store().graph().nodes()[&node].data["columns"]
807            .as_array()
808            .expect("columns array exists")
809            .len();
810
811        app.bridge
812            .apply_node_repeatable_action(
813                node,
814                EguiNodeRepeatableAction::Add {
815                    collection_key: "table.columns".to_owned(),
816                },
817            )
818            .expect("add applies");
819        assert_eq!(
820            app.bridge.store().graph().nodes()[&node].data["columns"]
821                .as_array()
822                .expect("columns array exists")
823                .len(),
824            original_len + 1
825        );
826        assert!(matches!(
827            app.bridge.store().node_measurement_status(node),
828            NodeMeasurementStatus::Dirty {
829                reason: NodeInternalsInvalidationReason::DataChanged,
830                ..
831            }
832        ));
833
834        app.bridge
835            .apply_node_repeatable_action(
836                node,
837                EguiNodeRepeatableAction::Move {
838                    collection_key: "table.columns".to_owned(),
839                    item_id: "id".to_owned(),
840                    direction: EguiRepeatableMoveDirection::Down,
841                },
842            )
843            .expect("move applies");
844        assert_eq!(
845            app.bridge.store().graph().nodes()[&node].data["columns"][1]["id"],
846            "id"
847        );
848
849        app.bridge
850            .apply_node_repeatable_action(
851                node,
852                EguiNodeRepeatableAction::Remove {
853                    collection_key: "table.columns".to_owned(),
854                    item_id: "email".to_owned(),
855                },
856            )
857            .expect("remove applies");
858        assert!(
859            app.bridge.store().graph().nodes()[&node].data["columns"]
860                .as_array()
861                .expect("columns array exists")
862                .iter()
863                .all(|item| item["id"] != "email")
864        );
865    }
866
867    #[test]
868    fn dropped_wire_menu_is_backed_by_authoring_action_descriptors() {
869        let app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
870            .expect("automation sample builds");
871        let (node, port) = app
872            .bridge
873            .store()
874            .graph()
875            .ports()
876            .iter()
877            .find_map(|(id, port)| {
878                (port.dir == PortDirection::Out && port.key.0 == "completion")
879                    .then_some((port.node, *id))
880            })
881            .expect("sample has a completion output port");
882        let handle = ConnectionHandleRef::new(node, port, PortDirection::Out);
883
884        let menu = app
885            .bridge
886            .dropped_wire_menu_for_handle(handle)
887            .expect("dropped wire menu exists");
888
889        assert_eq!(menu.key, "menu.dropped_wire");
890        assert!(
891            menu.action_keys
892                .iter()
893                .any(|key| key == "action.insert.llm")
894        );
895        let action = app
896            .bridge
897            .action_descriptor("action.insert.llm")
898            .expect("insert action descriptor exists");
899        assert!(action.is_enabled());
900    }
901
902    #[test]
903    fn product_shape_snapshots_keep_authoring_regions_inside_nodes() {
904        for (sample, label, expected_kind, expected_region) in [
905            (
906                SampleGraphKind::AutomationBuilder,
907                "dify workflow",
908                "demo.llm",
909                Some("config.model"),
910            ),
911            (
912                SampleGraphKind::ShaderGraph,
913                "shader graph",
914                "demo.shader.mix",
915                Some("config.factor"),
916            ),
917            (
918                SampleGraphKind::Erd,
919                "erd table",
920                "demo.table",
921                Some("repeatable.table.columns.header"),
922            ),
923            (SampleGraphKind::MindMap, "mind map", "demo.topic", None),
924        ] {
925            let mut app = JellyflowEguiApp::sample(sample).expect("sample builds");
926            let snapshot = rebuild_product_snapshot(&mut app, 1.0);
927            let node = node_by_kind(&app, expected_kind);
928            let layout = snapshot
929                .node_render_layouts
930                .get(&node)
931                .unwrap_or_else(|| panic!("{label} layout exists"));
932            if let Some(expected_region) = expected_region {
933                assert!(
934                    layout
935                        .interactive_regions
936                        .iter()
937                        .any(|region| region.key == expected_region),
938                    "{label} should expose `{expected_region}`"
939                );
940            } else {
941                assert!(
942                    snapshot
943                        .handle_bounds
944                        .keys()
945                        .any(|handle| handle.node == node),
946                    "{label} should preserve handle facts even without rich internals"
947                );
948            }
949            assert_regions_inside_node(&snapshot, node, label);
950            assert_regions_do_not_overlap_vertically(layout, label);
951        }
952    }
953
954    #[test]
955    fn rich_node_resize_keeps_regions_and_handles_aligned() {
956        let mut app = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
957        let before = rebuild_product_snapshot(&mut app, 1.0);
958        let node = node_by_kind_with_region(&app, &before, "demo.table", "field.foreign_key");
959        let from_size = app.bridge.store().graph().nodes()[&node].size;
960
961        app.bridge
962            .store_mut()
963            .dispatch_transaction(&GraphTransaction::from_ops([GraphOp::SetNodeSize {
964                id: node,
965                from: from_size,
966                to: Some(CanvasSize {
967                    width: 124.0,
968                    height: 72.0,
969                }),
970            }]))
971            .expect("resize update dispatches");
972
973        let snapshot = app.bridge.rebuild_snapshot(
974            &before,
975            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
976        );
977        assert_regions_inside_node(&snapshot, node, "resized erd table");
978        assert_handle_tracks_anchor(&app, &snapshot, node, "pk", "field.primary_key");
979        assert_handle_tracks_anchor(&app, &snapshot, node, "fk", "field.foreign_key");
980    }
981
982    #[test]
983    fn repeatable_item_port_handles_follow_dynamic_item_regions() {
984        let mut app =
985            JellyflowEguiApp::sample(SampleGraphKind::ShaderGraph).expect("shader sample builds");
986
987        let snapshot = rebuild_product_snapshot(&mut app, 1.0);
988        let node = node_by_kind(&app, "demo.shader.mix");
989        assert_handle_tracks_anchor(&app, &snapshot, node, "a", "rail.inputs.a.input");
990
991        let graph = app.bridge.store().graph();
992        let port = graph.nodes()[&node]
993            .ports
994            .iter()
995            .copied()
996            .find(|port| graph.ports()[port].key.0 == "a")
997            .expect("dynamic input port exists");
998        let handle = ConnectionHandleRef::new(node, port, graph.ports()[&port].dir);
999        let measurement = app
1000            .bridge
1001            .store()
1002            .node_measurement(node)
1003            .expect("shader mix measurement exists");
1004
1005        assert!(measurement.anchors.iter().any(|anchor| {
1006            anchor.anchor == "rail.inputs.a.input"
1007                && anchor
1008                    .port_key
1009                    .as_ref()
1010                    .is_some_and(|port_key| port_key.0 == "a")
1011        }));
1012        assert!(
1013            app.bridge
1014                .store()
1015                .resolve_node_handle_measurement(handle)
1016                .bounds
1017                .is_some()
1018        );
1019    }
1020
1021    #[test]
1022    fn density_modes_have_regression_coverage_for_rich_nodes() {
1023        assert_eq!(NodeContentLevel::from_zoom(1.0), NodeContentLevel::Full);
1024        assert_eq!(NodeContentLevel::from_zoom(0.5), NodeContentLevel::Compact);
1025        assert_eq!(NodeContentLevel::from_zoom(0.12), NodeContentLevel::Shell);
1026
1027        let mut app = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
1028            .expect("automation sample builds");
1029        let full = rebuild_product_snapshot(&mut app, 1.0);
1030        let node = node_by_kind(&app, "demo.llm");
1031        assert_regions_inside_node(&full, node, "full density llm");
1032
1033        let compact = rebuild_product_snapshot(&mut app, 0.5);
1034        assert!(
1035            compact
1036                .handle_bounds
1037                .keys()
1038                .any(|handle| handle.node == node),
1039            "compact density must keep visible handles even when details collapse"
1040        );
1041        assert_regions_inside_node(&compact, node, "compact density llm");
1042
1043        let shell = rebuild_product_snapshot(&mut app, 0.12);
1044        assert!(
1045            shell.handle_bounds.keys().any(|handle| handle.node == node),
1046            "shell density must preserve handle facts for wires"
1047        );
1048    }
1049
1050    #[test]
1051    fn authoring_interaction_states_have_regression_fixtures() {
1052        let mut shader =
1053            JellyflowEguiApp::sample(SampleGraphKind::ShaderGraph).expect("shader sample builds");
1054        let _snapshot = rebuild_product_snapshot(&mut shader, 1.0);
1055        assert_shader_invalid_hover(&mut shader);
1056
1057        let automation = JellyflowEguiApp::sample(SampleGraphKind::AutomationBuilder)
1058            .expect("automation sample builds");
1059        let (node, port) = automation
1060            .bridge
1061            .store()
1062            .graph()
1063            .ports()
1064            .iter()
1065            .find_map(|(id, port)| {
1066                (port.dir == PortDirection::Out && port.key.0 == "completion")
1067                    .then_some((port.node, *id))
1068            })
1069            .expect("completion output port exists");
1070        let menu = automation
1071            .bridge
1072            .dropped_wire_menu_for_handle(ConnectionHandleRef::new(node, port, PortDirection::Out))
1073            .expect("dropped-wire menu exists");
1074        assert!(
1075            menu.action_keys
1076                .iter()
1077                .any(|key| key == "action.insert.llm")
1078        );
1079
1080        let erd = JellyflowEguiApp::sample(SampleGraphKind::Erd).expect("erd sample builds");
1081        let descriptor = erd
1082            .bridge
1083            .descriptor_for_node(node_by_kind(&erd, "demo.table"))
1084            .expect("table descriptor exists");
1085        assert!(!descriptor.inspectors.is_empty());
1086        assert!(!descriptor.menus.is_empty());
1087    }
1088
1089    #[test]
1090    fn start_node_drag_preserves_existing_multi_selection_for_drag_plan() {
1091        let mut bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
1092        let mut nodes = bridge
1093            .store()
1094            .graph()
1095            .nodes()
1096            .keys()
1097            .copied()
1098            .collect::<Vec<_>>();
1099        nodes.sort();
1100        let primary = nodes[0];
1101        let secondary = nodes[1];
1102
1103        bridge
1104            .store_mut()
1105            .set_selection(vec![primary, secondary], Vec::new(), Vec::new());
1106        bridge.start_node_drag(primary, false);
1107
1108        assert_eq!(
1109            bridge.store().view_state().selected_nodes,
1110            vec![primary, secondary]
1111        );
1112
1113        let plan = bridge
1114            .plan_node_drag(primary, CanvasPoint { x: 32.0, y: 18.0 })
1115            .expect("selected nodes produce a drag plan");
1116        let planned_nodes = plan
1117            .items()
1118            .iter()
1119            .map(|item| item.node)
1120            .collect::<Vec<_>>();
1121        assert!(planned_nodes.contains(&primary));
1122        assert!(planned_nodes.contains(&secondary));
1123    }
1124
1125    #[test]
1126    fn bridge_nudge_selection_commits_keyboard_move() {
1127        let mut bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
1128        let node = *bridge
1129            .store()
1130            .graph()
1131            .nodes()
1132            .keys()
1133            .next()
1134            .expect("demo node exists");
1135        let before = bridge.store().graph().nodes()[&node].pos;
1136        bridge.select_node(node, false);
1137
1138        let outcome = bridge
1139            .nudge_selection(NodeNudgeDirection::Right, false)
1140            .expect("nudge dispatch succeeds")
1141            .expect("nudge commits");
1142
1143        assert!(!outcome.dispatch().committed().ops().is_empty());
1144        assert!(bridge.store().graph().nodes()[&node].pos.x > before.x);
1145        assert_eq!(bridge.store().graph().nodes()[&node].pos.y, before.y);
1146    }
1147
1148    #[test]
1149    fn bridge_nudge_selection_is_noop_without_selection() {
1150        let mut bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
1151        bridge.clear_selection();
1152
1153        let outcome = bridge
1154            .nudge_selection(NodeNudgeDirection::Right, false)
1155            .expect("nudge no-selection path succeeds");
1156
1157        assert!(outcome.is_none());
1158    }
1159
1160    #[test]
1161    fn bridge_pan_interaction_commit_does_not_apply_delta_twice() {
1162        let mut bridge = JellyflowEguiBridge::demo().expect("demo bridge builds");
1163        bridge.pan_by_screen_delta(CanvasPoint { x: 40.0, y: -20.0 });
1164        let after_live_pan = bridge.store().view_state().pan;
1165
1166        bridge
1167            .commit_interaction(ActiveCanvasInteraction::Pan {
1168                current_pointer: CanvasPoint { x: 40.0, y: -20.0 },
1169            })
1170            .expect("pan commit ends interaction");
1171
1172        assert_eq!(bridge.store().view_state().pan, after_live_pan);
1173    }
1174
1175    fn rebuild_product_snapshot(app: &mut JellyflowEguiApp, zoom: f32) -> CanvasSnapshot {
1176        let pan = app.bridge.store().view_state().pan;
1177        app.bridge.store_mut().set_viewport(pan, zoom);
1178        app.bridge.rebuild_snapshot(
1179            &CanvasSnapshot::empty(),
1180            Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 800.0)),
1181        )
1182    }
1183
1184    fn node_by_kind(app: &JellyflowEguiApp, kind: &str) -> NodeId {
1185        app.bridge
1186            .store()
1187            .graph()
1188            .nodes()
1189            .iter()
1190            .find_map(|(node, record)| (record.kind.0 == kind).then_some(*node))
1191            .unwrap_or_else(|| panic!("node kind `{kind}` exists"))
1192    }
1193
1194    fn node_by_kind_with_region(
1195        app: &JellyflowEguiApp,
1196        snapshot: &CanvasSnapshot,
1197        kind: &str,
1198        region_key: &str,
1199    ) -> NodeId {
1200        app.bridge
1201            .store()
1202            .graph()
1203            .nodes()
1204            .iter()
1205            .find_map(|(node, record)| {
1206                (record.kind.0 == kind
1207                    && snapshot
1208                        .node_render_layouts
1209                        .get(node)
1210                        .is_some_and(|layout| {
1211                            layout
1212                                .interactive_regions
1213                                .iter()
1214                                .any(|region| region.key == region_key)
1215                        }))
1216                .then_some(*node)
1217            })
1218            .unwrap_or_else(|| panic!("node kind `{kind}` with region `{region_key}` exists"))
1219    }
1220
1221    fn assert_regions_inside_node(snapshot: &CanvasSnapshot, node: NodeId, label: &str) {
1222        let node_rect = snapshot
1223            .node_rects
1224            .get(&node)
1225            .unwrap_or_else(|| panic!("{label} node rect exists"));
1226        let layout = snapshot
1227            .node_render_layouts
1228            .get(&node)
1229            .unwrap_or_else(|| panic!("{label} render layout exists"));
1230
1231        for region in &layout.interactive_regions {
1232            assert!(
1233                rect_inside_node(region.rect, node_rect.size, 0.5),
1234                "{label} region `{}` should stay inside node bounds: {:?} in {:?}",
1235                region.key,
1236                region.rect,
1237                node_rect
1238            );
1239        }
1240    }
1241
1242    fn assert_regions_do_not_overlap_vertically(
1243        layout: &crate::renderer::NodeRenderLayout,
1244        label: &str,
1245    ) {
1246        let mut regions = layout
1247            .interactive_regions
1248            .iter()
1249            .filter(|region| region.z_index <= 1)
1250            .collect::<Vec<_>>();
1251        regions.sort_by(|a, b| a.rect.origin.y.total_cmp(&b.rect.origin.y));
1252        for pair in regions.windows(2) {
1253            let first = pair[0];
1254            let second = pair[1];
1255            let first_bottom = first.rect.origin.y + first.rect.size.height;
1256            assert!(
1257                first_bottom <= second.rect.origin.y + 0.5,
1258                "{label} regions `{}` and `{}` should not overlap vertically",
1259                first.key,
1260                second.key
1261            );
1262        }
1263    }
1264
1265    fn assert_handle_tracks_anchor(
1266        app: &JellyflowEguiApp,
1267        snapshot: &CanvasSnapshot,
1268        node: NodeId,
1269        port_key: &str,
1270        anchor_key: &str,
1271    ) {
1272        let graph = app.bridge.store().graph();
1273        let port = graph.nodes()[&node]
1274            .ports
1275            .iter()
1276            .copied()
1277            .find(|port| graph.ports()[port].key.0 == port_key)
1278            .unwrap_or_else(|| panic!("port `{port_key}` exists"));
1279        let handle = ConnectionHandleRef::new(node, port, graph.ports()[&port].dir);
1280        let bounds = snapshot
1281            .handle_bounds
1282            .get(&handle)
1283            .unwrap_or_else(|| panic!("handle bounds for `{port_key}` exist"));
1284        let anchor = snapshot
1285            .node_render_layouts
1286            .get(&node)
1287            .and_then(|layout| {
1288                layout
1289                    .interactive_regions
1290                    .iter()
1291                    .find(|region| region.key == anchor_key)
1292            })
1293            .unwrap_or_else(|| panic!("anchor `{anchor_key}` exists"));
1294        let handle_center_y = bounds.rect.origin.y + bounds.rect.size.height * 0.5;
1295        let anchor_center_y = anchor.rect.origin.y + anchor.rect.size.height * 0.5;
1296
1297        assert!(
1298            (handle_center_y - anchor_center_y).abs() <= 1.0,
1299            "handle `{port_key}` should track anchor `{anchor_key}`: handle_y={handle_center_y}, anchor_y={anchor_center_y}, bounds={:?}, anchor={:?}",
1300            bounds.rect,
1301            anchor.rect
1302        );
1303    }
1304
1305    fn rect_inside_node(rect: CanvasRect, node_size: CanvasSize, epsilon: f32) -> bool {
1306        rect.is_positive_finite()
1307            && rect.origin.x >= -epsilon
1308            && rect.origin.y >= -epsilon
1309            && rect.origin.x + rect.size.width <= node_size.width + epsilon
1310            && rect.origin.y + rect.size.height <= node_size.height + epsilon
1311    }
1312
1313    fn assert_shader_invalid_hover(app: &mut JellyflowEguiApp) {
1314        let graph = app.bridge.store().graph();
1315        let texture = graph
1316            .nodes()
1317            .iter()
1318            .find_map(|(node, record)| {
1319                (record.kind.0 == "demo.shader.texture_sample").then_some(*node)
1320            })
1321            .expect("texture sample exists");
1322        let mix = node_by_kind(app, "demo.shader.mix");
1323        let color = graph.nodes()[&texture]
1324            .ports
1325            .iter()
1326            .copied()
1327            .find(|port| {
1328                graph.ports()[port].key.0 == "color"
1329                    && graph.ports()[port].dir == PortDirection::Out
1330            })
1331            .expect("texture color port exists");
1332        let factor = graph.nodes()[&mix]
1333            .ports
1334            .iter()
1335            .copied()
1336            .find(|port| graph.ports()[port].key.0 == "factor")
1337            .expect("factor port exists");
1338        let color_handle = ConnectionHandleRef::new(texture, color, graph.ports()[&color].dir);
1339        let factor_handle = ConnectionHandleRef::new(mix, factor, graph.ports()[&factor].dir);
1340        let factor_bounds = app
1341            .bridge
1342            .store()
1343            .resolve_node_handle_measurement(factor_handle)
1344            .bounds
1345            .expect("factor bounds");
1346        let mix_pos = graph.nodes()[&mix].pos;
1347        let pointer = CanvasPoint {
1348            x: mix_pos.x + factor_bounds.rect.origin.x + factor_bounds.rect.size.width * 0.5,
1349            y: mix_pos.y + factor_bounds.rect.origin.y + factor_bounds.rect.size.height * 0.5,
1350        };
1351
1352        let hover = app.bridge.resolve_connection_target(pointer, color_handle);
1353        assert_eq!(hover.feedback, ConnectionHandleValidity::Invalid);
1354        assert!(!hover.is_handle_valid);
1355    }
1356
1357    fn shader_vec4() -> TypeDesc {
1358        TypeDesc::Opaque {
1359            key: "shader.vec4".to_owned(),
1360            params: Vec::new(),
1361        }
1362    }
1363}