1#![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}