|
| 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