Skip to content

Commit 40495be

Browse files
committed
test: add tests for new globals-based architecture
16 new tests covering: - NodeEditorSetup multi-node drag (single, multi-selection, unselected, single-selected edge cases) - compute_link_path_world correctness (valid SVG, coordinate values, independence from viewport, missing pin handling) - resolve_link_endpoints consistency across all three path methods - Screen-space path with zoom and pan transformation - Box selection coordinate conversion (zoom=1, both nodes, screen→world at zoom=2 with pan offset, empty area) - wire_node_editor! macro integration verification
1 parent f6d371d commit 40495be

1 file changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
//! Level 8: Globals & Transform-Scale Tests
2+
//!
3+
//! Tests for the globals-based communication system and the transform-scale
4+
//! zoom model: NodeEditorSetup multi-node drag, compute_link_path_world,
5+
//! box selection coordinate conversion, and resolve_link_endpoints.
6+
7+
mod common;
8+
9+
use common::harness::MinimalTestHarness;
10+
use slint_node_editor::{
11+
GeometryCache, NodeEditorSetup, SimpleNodeGeometry,
12+
};
13+
use std::cell::RefCell;
14+
use std::rc::Rc;
15+
16+
/// Helper to set up geometry in the cache for testing.
17+
fn setup_test_geometry(harness: &MinimalTestHarness) {
18+
let cache = harness.ctrl.cache();
19+
let mut cache = cache.borrow_mut();
20+
21+
cache.update_node_rect(1, 100.0, 100.0, 150.0, 100.0);
22+
cache.update_node_rect(2, 400.0, 200.0, 150.0, 100.0);
23+
24+
cache.handle_pin_report(2, 1, 1, 0.0, 50.0); // Node 1 input
25+
cache.handle_pin_report(3, 1, 2, 150.0, 50.0); // Node 1 output
26+
cache.handle_pin_report(4, 2, 1, 0.0, 50.0); // Node 2 input
27+
cache.handle_pin_report(5, 2, 2, 150.0, 50.0); // Node 2 output
28+
}
29+
30+
// ============================================================================
31+
// NodeEditorSetup: multi-node drag
32+
// ============================================================================
33+
34+
#[test]
35+
fn test_setup_single_node_drag_calls_closure() {
36+
let moved: Rc<RefCell<Vec<(i32, f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
37+
let moved_clone = moved.clone();
38+
39+
let setup = NodeEditorSetup::new(move |id, dx, dy| {
40+
moved_clone.borrow_mut().push((id, dx, dy));
41+
});
42+
43+
// Simulate: start drag on node 1, then end with delta
44+
let start = setup.start_node_drag();
45+
let end = setup.end_node_drag();
46+
47+
start(1, false, 0.0, 0.0);
48+
end(50.0, 30.0);
49+
50+
let calls = moved.borrow();
51+
assert_eq!(calls.len(), 1);
52+
assert_eq!(calls[0], (1, 50.0, 30.0));
53+
}
54+
55+
#[test]
56+
fn test_setup_multi_node_drag_moves_all_selected() {
57+
let moved: Rc<RefCell<Vec<(i32, f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
58+
let moved_clone = moved.clone();
59+
60+
let setup = NodeEditorSetup::new(move |id, dx, dy| {
61+
moved_clone.borrow_mut().push((id, dx, dy));
62+
});
63+
64+
// Add nodes 1, 2, 3 to selection
65+
{
66+
let sel_rc = setup.selection();
67+
let mut sel = sel_rc.borrow_mut();
68+
sel.insert(1);
69+
sel.insert(2);
70+
sel.insert(3);
71+
}
72+
73+
// Drag node 2 (which is in the selection)
74+
let start = setup.start_node_drag();
75+
let end = setup.end_node_drag();
76+
77+
start(2, false, 0.0, 0.0);
78+
end(10.0, 20.0);
79+
80+
let calls = moved.borrow();
81+
// All 3 selected nodes should be moved
82+
assert_eq!(calls.len(), 3);
83+
let ids: Vec<i32> = calls.iter().map(|(id, _, _)| *id).collect();
84+
assert!(ids.contains(&1));
85+
assert!(ids.contains(&2));
86+
assert!(ids.contains(&3));
87+
// All with the same delta
88+
for (_, dx, dy) in calls.iter() {
89+
assert_eq!(*dx, 10.0);
90+
assert_eq!(*dy, 20.0);
91+
}
92+
}
93+
94+
#[test]
95+
fn test_setup_drag_unselected_node_moves_only_that_node() {
96+
let moved: Rc<RefCell<Vec<(i32, f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
97+
let moved_clone = moved.clone();
98+
99+
let setup = NodeEditorSetup::new(move |id, dx, dy| {
100+
moved_clone.borrow_mut().push((id, dx, dy));
101+
});
102+
103+
// Select nodes 1 and 2
104+
{
105+
let sel_rc = setup.selection();
106+
let mut sel = sel_rc.borrow_mut();
107+
sel.insert(1);
108+
sel.insert(2);
109+
}
110+
111+
// Drag node 5 (NOT in selection)
112+
let start = setup.start_node_drag();
113+
let end = setup.end_node_drag();
114+
115+
start(5, false, 0.0, 0.0);
116+
end(10.0, 20.0);
117+
118+
let calls = moved.borrow();
119+
// Only node 5 should move (not in multi-selection)
120+
assert_eq!(calls.len(), 1);
121+
assert_eq!(calls[0], (5, 10.0, 20.0));
122+
}
123+
124+
#[test]
125+
fn test_setup_single_selected_node_drag_moves_only_that_node() {
126+
let moved: Rc<RefCell<Vec<(i32, f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
127+
let moved_clone = moved.clone();
128+
129+
let setup = NodeEditorSetup::new(move |id, dx, dy| {
130+
moved_clone.borrow_mut().push((id, dx, dy));
131+
});
132+
133+
// Select only node 1
134+
{
135+
let sel_rc = setup.selection();
136+
let mut sel = sel_rc.borrow_mut();
137+
sel.insert(1);
138+
}
139+
140+
// Drag node 1 (single selection, not multi)
141+
let start = setup.start_node_drag();
142+
let end = setup.end_node_drag();
143+
144+
start(1, false, 0.0, 0.0);
145+
end(10.0, 20.0);
146+
147+
let calls = moved.borrow();
148+
assert_eq!(calls.len(), 1);
149+
assert_eq!(calls[0], (1, 10.0, 20.0));
150+
}
151+
152+
// ============================================================================
153+
// compute_link_path_world: pure world-coordinate paths
154+
// ============================================================================
155+
156+
fn make_cache_with_link() -> GeometryCache<SimpleNodeGeometry> {
157+
let mut cache = GeometryCache::<SimpleNodeGeometry>::default();
158+
cache.update_node_rect(1, 100.0, 100.0, 150.0, 100.0);
159+
cache.update_node_rect(2, 400.0, 200.0, 150.0, 100.0);
160+
cache.handle_pin_report(3, 1, 2, 150.0, 50.0); // Node 1 output
161+
cache.handle_pin_report(4, 2, 1, 0.0, 50.0); // Node 2 input
162+
cache
163+
}
164+
165+
#[test]
166+
fn test_compute_link_path_world_returns_valid_svg() {
167+
let cache = make_cache_with_link();
168+
let path = cache.compute_link_path_world(3, 4, 50.0);
169+
assert!(path.is_some());
170+
let path = path.unwrap();
171+
assert!(path.starts_with("M "), "Path should start with M: {}", path);
172+
assert!(path.contains(" C "), "Path should contain cubic bezier: {}", path);
173+
}
174+
175+
#[test]
176+
fn test_compute_link_path_world_ignores_viewport() {
177+
let cache = make_cache_with_link();
178+
179+
// World path should be identical regardless of what viewport is set
180+
let path_world = cache.compute_link_path_world(3, 4, 50.0).unwrap();
181+
182+
// Compare with compute_link_path at zoom=1.0 (should be identical)
183+
let path_z1 = cache.compute_link_path(3, 4, 1.0, 50.0).unwrap();
184+
assert_eq!(path_world, path_z1, "World path should match zoom=1.0 path");
185+
186+
// But screen path at zoom=2.0 should differ
187+
let path_screen = cache.compute_link_path_screen(3, 4, 2.0, 0.0, 0.0, 50.0).unwrap();
188+
assert_ne!(path_world, path_screen, "World path should differ from screen path at zoom=2");
189+
}
190+
191+
#[test]
192+
fn test_compute_link_path_world_missing_pin_returns_none() {
193+
let cache = make_cache_with_link();
194+
assert!(cache.compute_link_path_world(999, 4, 50.0).is_none());
195+
assert!(cache.compute_link_path_world(3, 999, 50.0).is_none());
196+
}
197+
198+
#[test]
199+
fn test_compute_link_path_world_uses_correct_coordinates() {
200+
let cache = make_cache_with_link();
201+
// Node 1 output: rect(100,100) + rel(150,50) = (250, 150)
202+
// Node 2 input: rect(400,200) + rel(0,50) = (400, 250)
203+
let path = cache.compute_link_path_world(3, 4, 50.0).unwrap();
204+
205+
// Path should start at pin 3's absolute position
206+
assert!(path.starts_with("M 250 150"), "Path should start at (250,150): {}", path);
207+
}
208+
209+
// ============================================================================
210+
// resolve_link_endpoints via compute_link_path variants
211+
// ============================================================================
212+
213+
#[test]
214+
fn test_all_path_methods_share_endpoints() {
215+
let cache = make_cache_with_link();
216+
217+
// All three methods should resolve the same start/end positions
218+
// (just transform them differently)
219+
let world = cache.compute_link_path_world(3, 4, 50.0).unwrap();
220+
let same_space = cache.compute_link_path(3, 4, 1.0, 50.0).unwrap();
221+
222+
// At zoom=1.0, pan=0: world == same_space == screen
223+
let screen = cache.compute_link_path_screen(3, 4, 1.0, 0.0, 0.0, 50.0).unwrap();
224+
225+
assert_eq!(world, same_space);
226+
assert_eq!(world, screen);
227+
}
228+
229+
#[test]
230+
fn test_screen_path_applies_zoom_and_pan() {
231+
let cache = make_cache_with_link();
232+
233+
let zoom = 2.0;
234+
let pan_x = 50.0;
235+
let pan_y = 100.0;
236+
237+
let screen = cache.compute_link_path_screen(3, 4, zoom, pan_x, pan_y, 50.0).unwrap();
238+
239+
// Node 1 output: world(250, 150) → screen(250*2+50, 150*2+100) = (550, 400)
240+
assert!(screen.starts_with("M 550 400"), "Screen path should start at (550,400): {}", screen);
241+
}
242+
243+
// ============================================================================
244+
// Box selection coordinate conversion
245+
// ============================================================================
246+
247+
#[test]
248+
fn test_box_selection_world_coords_at_zoom_1() {
249+
let harness = MinimalTestHarness::new();
250+
setup_test_geometry(&harness);
251+
harness.ctrl.set_viewport(1.0, 0.0, 0.0);
252+
253+
// Box that encloses node 1 (at 100,100 with size 150x100)
254+
let selected = harness.ctrl.cache().borrow()
255+
.nodes_in_selection_box(50.0, 50.0, 250.0, 200.0);
256+
assert!(selected.contains(&1), "Node 1 should be in selection box");
257+
assert!(!selected.contains(&2), "Node 2 should not be in selection box");
258+
}
259+
260+
#[test]
261+
fn test_box_selection_world_coords_both_nodes() {
262+
let harness = MinimalTestHarness::new();
263+
setup_test_geometry(&harness);
264+
265+
// Box that encloses both nodes
266+
let selected = harness.ctrl.cache().borrow()
267+
.nodes_in_selection_box(50.0, 50.0, 550.0, 300.0);
268+
assert!(selected.contains(&1));
269+
assert!(selected.contains(&2));
270+
}
271+
272+
#[test]
273+
fn test_box_selection_screen_to_world_conversion() {
274+
// Simulate the coordinate conversion that NodeEditor does:
275+
// world = (screen - pan) / zoom
276+
let harness = MinimalTestHarness::new();
277+
setup_test_geometry(&harness);
278+
279+
let zoom = 2.0_f32;
280+
let pan_x = 50.0_f32;
281+
let pan_y = 100.0_f32;
282+
harness.ctrl.set_viewport(zoom, pan_x, pan_y);
283+
284+
// Screen-space box: (250, 300) to (850, 700)
285+
let screen_x = 250.0_f32;
286+
let screen_y = 300.0_f32;
287+
let screen_w = 600.0_f32;
288+
let screen_h = 400.0_f32;
289+
290+
// Convert to world: world = (screen - pan) / zoom
291+
let world_x = (screen_x - pan_x) / zoom;
292+
let world_y = (screen_y - pan_y) / zoom;
293+
let world_w = screen_w / zoom;
294+
let world_h = screen_h / zoom;
295+
296+
// world box: (100, 100) to (400, 300) — should contain node 1
297+
let selected = harness.ctrl.cache().borrow()
298+
.nodes_in_selection_box(world_x, world_y, world_w, world_h);
299+
300+
assert!(selected.contains(&1),
301+
"Node 1 at (100,100) should be in world box ({},{}) {}x{}",
302+
world_x, world_y, world_w, world_h);
303+
}
304+
305+
#[test]
306+
fn test_box_selection_empty_at_wrong_coords() {
307+
let harness = MinimalTestHarness::new();
308+
setup_test_geometry(&harness);
309+
310+
// Box far from any nodes
311+
let selected = harness.ctrl.cache().borrow()
312+
.nodes_in_selection_box(1000.0, 1000.0, 100.0, 100.0);
313+
assert!(selected.is_empty());
314+
}
315+
316+
// ============================================================================
317+
// Integration: wire_node_editor! exercises the new architecture
318+
// ============================================================================
319+
320+
#[test]
321+
fn test_harness_uses_wire_node_editor_macro() {
322+
// The harness uses wire_node_editor! internally.
323+
// If the macro is broken, this test (and all others) would fail to compile.
324+
let harness = MinimalTestHarness::new();
325+
setup_test_geometry(&harness);
326+
327+
// Verify the macro wired compute_link_path correctly
328+
let cache = harness.ctrl.cache();
329+
let cache = cache.borrow();
330+
let path = cache.compute_link_path_world(3, 4, 50.0);
331+
assert!(path.is_some(), "Link path should be computable after wire_node_editor!");
332+
}
333+
334+
#[test]
335+
fn test_harness_tracks_drag_via_globals_path() {
336+
// Verify the harness tracks drags through the new GeometryCallbacks path
337+
// (not the old window.on_node_drag_started path)
338+
let harness = MinimalTestHarness::new();
339+
setup_test_geometry(&harness);
340+
341+
assert!(harness.tracker.node_drag_started.borrow().is_empty());
342+
assert!(harness.tracker.node_drag_ended.borrow().is_empty());
343+
344+
// The tracker is wired to NodeEditorInternalCallbacks.on_start_node_drag
345+
// and on_end_node_drag via the harness setup. If a real BaseNode initiated
346+
// a drag, it would fire through that path. Here we verify the wiring exists
347+
// by checking the tracker starts empty and the callbacks are accessible.
348+
}

0 commit comments

Comments
 (0)