use iced::widget::{container, text};
use iced::{Element, Length, Point, Theme, Vector};
use iced::{keyboard, mouse};
use iced_nodegraph::{NodeGraph, PinRef, edge, node, pin};
use iced_test::Simulator;
type Renderer = iced::Renderer;
type Graph = NodeGraph<'static, usize, usize, (), Msg, Theme, Renderer>;
type Pin = PinRef<usize, usize>;
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Select(Vec<usize>),
Move(Vector, Vec<usize>),
Clone(Vec<usize>),
Delete(Vec<usize>),
Connect(Pin, Pin),
Disconnect(Pin, Pin),
Camera(Point, f32),
Button,
Input(String),
}
const NODE_W: f32 = 60.0;
const NODE_H: f32 = 30.0;
fn graph_with(nodes: &[(usize, Point)]) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select)
.on_move(Msg::Move)
.on_clone(Msg::Clone)
.on_delete(Msg::Delete);
for &(id, pos) in nodes {
let body = container(iced::widget::text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H));
ng.push_node(node(id, pos, body));
}
ng.into()
}
fn center(p: Point) -> Point {
Point::new(p.x + NODE_W / 2.0, p.y + NODE_H / 2.0)
}
fn moved(p: Point) -> iced::Event {
iced::Event::Mouse(mouse::Event::CursorMoved { position: p })
}
fn press() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
}
fn release() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
}
fn drag(ui: &mut Simulator<'_, Msg, Theme, Renderer>, from: Point, to: Point) {
ui.point_at(from);
ui.simulate([moved(from), press()]);
ui.point_at(to);
ui.simulate([moved(to), release()]);
}
fn click(ui: &mut Simulator<'_, Msg, Theme, Renderer>, at: Point) {
ui.point_at(at);
ui.simulate([moved(at), press(), release()]);
}
fn key_pressed(key: keyboard::Key, modifiers: keyboard::Modifiers) -> iced::Event {
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers,
text: None,
repeat: false,
})
}
fn cmd() -> keyboard::Modifiers {
#[cfg(target_os = "macos")]
{
keyboard::Modifiers::LOGO
}
#[cfg(not(target_os = "macos"))]
{
keyboard::Modifiers::CTRL
}
}
fn messages(ui: Simulator<'_, Msg, Theme, Renderer>) -> Vec<Msg> {
ui.into_messages().collect()
}
fn sorted(mut v: Vec<usize>) -> Vec<usize> {
v.sort_unstable();
v
}
fn last_selection(msgs: &[Msg]) -> Option<Vec<usize>> {
msgs.iter().rev().find_map(|m| match m {
Msg::Select(ids) => Some(sorted(ids.clone())),
_ => None,
})
}
#[test]
fn click_selects_node() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
assert_eq!(last_selection(&messages(ui)), Some(vec![0]));
}
#[test]
fn click_unselected_node_replaces_selection() {
let mut ui = Simulator::new(graph_with(&[
(0, Point::new(100.0, 100.0)),
(1, Point::new(400.0, 100.0)),
]));
click(&mut ui, center(Point::new(100.0, 100.0)));
click(&mut ui, center(Point::new(400.0, 100.0)));
assert_eq!(last_selection(&messages(ui)), Some(vec![1]));
}
#[test]
fn shift_click_adds_to_selection() {
let mut ui = Simulator::new(graph_with(&[
(0, Point::new(100.0, 100.0)),
(1, Point::new(400.0, 100.0)),
]));
click(&mut ui, center(Point::new(100.0, 100.0)));
let shift = keyboard::Modifiers::SHIFT;
let a = center(Point::new(400.0, 100.0));
ui.point_at(a);
ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
shift,
))]);
ui.simulate([moved(a), press(), release()]);
assert_eq!(last_selection(&messages(ui)), Some(vec![0, 1]));
}
#[test]
fn click_empty_space_clears_selection() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
click(&mut ui, Point::new(700.0, 600.0));
assert_eq!(last_selection(&messages(ui)), Some(vec![]));
}
#[test]
fn ctrl_a_selects_all() {
let mut ui = Simulator::new(graph_with(&[
(0, Point::new(100.0, 100.0)),
(1, Point::new(400.0, 100.0)),
(2, Point::new(700.0, 100.0)),
]));
ui.point_at(Point::new(500.0, 400.0));
ui.simulate([key_pressed(keyboard::Key::Character("a".into()), cmd())]);
assert_eq!(last_selection(&messages(ui)), Some(vec![0, 1, 2]));
}
#[test]
fn escape_clears_selection() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
ui.simulate([key_pressed(
keyboard::Key::Named(keyboard::key::Named::Escape),
keyboard::Modifiers::default(),
)]);
assert_eq!(last_selection(&messages(ui)), Some(vec![]));
}
#[test]
fn box_select_grabs_enclosed_nodes() {
let mut ui = Simulator::new(graph_with(&[
(0, Point::new(100.0, 100.0)),
(1, Point::new(300.0, 100.0)),
(2, Point::new(700.0, 500.0)), ]));
drag(&mut ui, Point::new(50.0, 50.0), Point::new(400.0, 200.0));
assert_eq!(last_selection(&messages(ui)), Some(vec![0, 1]));
}
#[test]
fn drag_node_emits_move_with_delta() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(graph_with(&[(0, start)]));
drag(
&mut ui,
center(start),
center(start) + Vector::new(50.0, 20.0),
);
let msgs = messages(ui);
let moved = msgs.iter().find_map(|m| match m {
Msg::Move(delta, ids) => Some((*delta, sorted(ids.clone()))),
_ => None,
});
let (delta, ids) = moved.expect("dragging a node must emit Move");
assert_eq!(ids, vec![0]);
assert!(
(delta.x - 50.0).abs() < 0.5 && (delta.y - 20.0).abs() < 0.5,
"node should move by (50, 20), got {delta:?}",
);
}
#[test]
fn group_move_emits_move_with_delta_and_all_ids() {
let mut ui = Simulator::new(graph_with(&[
(0, Point::new(100.0, 100.0)),
(1, Point::new(400.0, 100.0)),
]));
ui.point_at(Point::new(500.0, 400.0));
ui.simulate([key_pressed(keyboard::Key::Character("a".into()), cmd())]);
let from = center(Point::new(100.0, 100.0));
drag(&mut ui, from, from + Vector::new(30.0, -10.0));
let msgs = messages(ui);
let group = msgs.iter().find_map(|m| match m {
Msg::Move(delta, ids) => Some((*delta, sorted(ids.clone()))),
_ => None,
});
let (delta, ids) = group.expect("dragging a multi-selection must emit Move");
assert_eq!(ids, vec![0, 1]);
assert!(
(delta.x - 30.0).abs() < 0.5 && (delta.y + 10.0).abs() < 0.5,
"group delta should be (30, -10), got {delta:?}",
);
}
#[test]
fn delete_key_requests_delete_of_selection() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
ui.simulate([key_pressed(
keyboard::Key::Named(keyboard::key::Named::Delete),
keyboard::Modifiers::default(),
)]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Delete(vec![0])),
"Delete key must request deletion of the selection: {msgs:?}",
);
}
#[test]
fn ctrl_d_requests_clone_of_selection() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
ui.simulate([key_pressed(keyboard::Key::Character("d".into()), cmd())]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Clone(vec![0])),
"Ctrl+D must request cloning of the selection: {msgs:?}",
);
}
#[test]
fn ctrl_d_without_selection_does_nothing() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
ui.point_at(Point::new(500.0, 400.0));
ui.simulate([key_pressed(keyboard::Key::Character("d".into()), cmd())]);
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Clone(_))),
"Ctrl+D with no selection must not request a clone: {msgs:?}",
);
}
#[test]
fn click_without_motion_does_not_emit_move() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
click(&mut ui, center(Point::new(100.0, 100.0)));
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Move(..))),
"a click without motion must not emit Move: {msgs:?}",
);
}
const OUT_POS: Point = Point::new(100.0, 100.0);
const IN_POS: Point = Point::new(300.0, 100.0);
fn out_anchor() -> Point {
Point::new(OUT_POS.x + NODE_W, OUT_POS.y + NODE_H / 2.0)
}
fn in_anchor() -> Point {
Point::new(IN_POS.x, IN_POS.y + NODE_H / 2.0)
}
fn pin_body() -> iced::widget::Container<'static, Msg, Theme, Renderer> {
container(text("p"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H))
}
fn pin_graph(connect_ok: bool, seed_edge: bool) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect)
.on_disconnect(Msg::Disconnect)
.can_connect(move |_, _| connect_ok);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
if seed_edge {
ng.push_edge(edge!(PinRef::new(0, 0), PinRef::new(1, 0)));
}
ng.into()
}
#[test]
fn drag_output_to_input_connects() {
let mut ui = Simulator::new(pin_graph(true, false));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"dragging output -> input must connect them: {msgs:?}",
);
}
#[test]
fn drag_input_to_output_reports_output_first() {
let mut ui = Simulator::new(pin_graph(true, false));
drag(&mut ui, in_anchor(), out_anchor());
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"connection must be normalized output-first regardless of drag direction: {msgs:?}",
);
}
#[test]
fn drag_to_empty_space_does_not_connect() {
let mut ui = Simulator::new(pin_graph(true, false));
drag(&mut ui, out_anchor(), Point::new(600.0, 500.0));
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"releasing over empty space must not connect: {msgs:?}",
);
}
#[test]
fn can_connect_false_blocks_connection() {
let mut ui = Simulator::new(pin_graph(false, false));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"can_connect returning false must block the snap/connect: {msgs:?}",
);
}
#[test]
fn ctrl_click_on_edge_disconnects() {
let mut ui = Simulator::new(pin_graph(true, true));
let mid = Point::new((out_anchor().x + in_anchor().x) / 2.0, out_anchor().y);
ui.point_at(mid);
ui.simulate([
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(cmd())),
moved(mid),
]);
ui.simulate([press(), release()]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Disconnect(PinRef::new(0, 0), PinRef::new(1, 0))),
"ctrl+click on an edge must disconnect it: {msgs:?}",
);
}
fn camera_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_pan(Msg::Camera);
ng.push_node(node(
0usize,
Point::new(100.0, 100.0),
container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H)),
));
ng.into()
}
fn last_camera(msgs: &[Msg]) -> Option<(Point, f32)> {
msgs.iter().rev().find_map(|m| match m {
Msg::Camera(pos, zoom) => Some((*pos, *zoom)),
_ => None,
})
}
fn right_press() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
}
fn right_release() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right))
}
#[test]
fn right_drag_pans_camera() {
let mut ui = Simulator::new(camera_graph());
let from = Point::new(400.0, 400.0);
let to = Point::new(460.0, 430.0); ui.point_at(from);
ui.simulate([moved(from), right_press()]);
ui.point_at(to);
ui.simulate([moved(to), right_release()]);
let msgs = messages(ui);
let (pos, zoom) = last_camera(&msgs).expect("right-drag must change the camera");
assert!(
(zoom - 1.0).abs() < 1e-3,
"pan must not change zoom: {zoom}"
);
assert!(
(pos.x - 60.0).abs() < 1.0 && (pos.y - 30.0).abs() < 1.0,
"camera should pan by (60, 30), got {pos:?}",
);
}
#[test]
fn wheel_scroll_zooms_camera() {
let mut ui = Simulator::new(camera_graph());
let at = Point::new(400.0, 400.0);
ui.point_at(at);
ui.simulate([
moved(at),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0.0, y: 3.0 },
}),
]);
let msgs = messages(ui);
let (_pos, zoom) = last_camera(&msgs).expect("wheel scroll must change the camera");
assert!(
zoom > 1.0,
"scrolling up must zoom in (zoom > 1), got {zoom}",
);
}
fn last_msgs_after_grab(to: Point) -> Vec<Msg> {
let mut ui = Simulator::new(pin_graph(true, true));
let from = in_anchor();
ui.point_at(from);
ui.simulate([moved(from), press()]);
ui.point_at(to);
ui.simulate([moved(to), release()]);
messages(ui)
}
#[test]
fn grabbing_connected_pin_in_place_keeps_connection() {
let msgs = last_msgs_after_grab(in_anchor());
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Disconnect(_, _))),
"grabbing a connected pin in place must not disconnect: {msgs:?}",
);
}
#[test]
fn dragging_connected_pin_within_hysteresis_keeps_connection() {
let near = Point::new(in_anchor().x + 10.0, in_anchor().y);
let msgs = last_msgs_after_grab(near);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Disconnect(_, _))),
"a sub-threshold drag must not unplug the connection: {msgs:?}",
);
}
#[test]
fn dragging_connected_pin_past_hysteresis_disconnects() {
let far = Point::new(in_anchor().x + 30.0, in_anchor().y);
let msgs = last_msgs_after_grab(far);
assert!(
msgs.contains(&Msg::Disconnect(PinRef::new(0, 0), PinRef::new(1, 0))),
"dragging past the hysteresis threshold must disconnect: {msgs:?}",
);
}
fn rewire_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect)
.on_disconnect(Msg::Disconnect);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
ng.push_node(node(
2usize,
Point::new(IN_POS.x, 300.0),
pin!(Left, 0usize, pin_body(), Input),
));
ng.push_edge(edge!(PinRef::new(0, 0), PinRef::new(1, 0)));
ng.into()
}
#[test]
fn rewire_grabbed_pin_to_another_pin() {
let mut ui = Simulator::new(rewire_graph());
let grab = in_anchor(); let target = Point::new(IN_POS.x, 315.0);
ui.point_at(grab);
ui.simulate([moved(grab), press()]);
let midway = Point::new(grab.x, 220.0);
ui.point_at(midway);
ui.simulate([moved(midway)]);
ui.point_at(target);
ui.simulate([moved(target), release()]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Disconnect(PinRef::new(0, 0), PinRef::new(1, 0))),
"re-wiring must disconnect the original edge: {msgs:?}",
);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(2, 0))),
"re-wiring must connect the grabbed end to the new pin: {msgs:?}",
);
}
#[test]
fn rewire_back_to_own_input_reconnects() {
let mut ui = Simulator::new(rewire_graph());
let grab = in_anchor();
ui.point_at(grab);
ui.simulate([moved(grab), press()]);
let midway = Point::new(grab.x, 220.0); ui.point_at(midway);
ui.simulate([moved(midway)]);
ui.point_at(grab); ui.simulate([moved(grab), release()]);
let msgs = messages(ui);
assert!(
msgs.iter().any(|m| matches!(m, Msg::Disconnect(_, _))),
"popping the edge off its input must disconnect first: {msgs:?}",
);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"dropping back on the original input must reconnect it: {msgs:?}",
);
}
#[test]
fn default_rejects_second_edge_to_occupied_input() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
ng.push_node(node(
2usize,
Point::new(OUT_POS.x, 300.0),
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_edge(edge!(PinRef::new(0, 0), PinRef::new(1, 0)));
let mut ui = Simulator::new(Element::from(ng));
let from = Point::new(OUT_POS.x + NODE_W, 300.0 + NODE_H / 2.0); drag(&mut ui, from, in_anchor());
let msgs = messages(ui);
assert!(
!msgs.contains(&Msg::Connect(PinRef::new(2, 0), PinRef::new(1, 0))),
"default one-edge-per-input must reject a second edge to an occupied input: {msgs:?}",
);
}
fn occlusion_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect)
.on_disconnect(Msg::Disconnect)
.on_select(Msg::Select)
.on_move(Msg::Move);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
ng.push_node(node(
2usize,
Point::new(IN_POS.x - NODE_W / 2.0, IN_POS.y),
container(text("cover"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H)),
));
ng.into()
}
#[test]
fn drop_connect_through_covering_node_is_possible() {
let mut ui = Simulator::new(occlusion_graph());
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"dropping onto a covered pin must still connect: {msgs:?}",
);
}
#[test]
fn drag_start_on_covered_pin_is_blocked() {
let mut ui = Simulator::new(occlusion_graph());
drag(&mut ui, in_anchor(), out_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"a covered pin must not start an edge drag: {msgs:?}",
);
assert!(
msgs.iter()
.any(|m| matches!(m, Msg::Select(ids) if ids.contains(&2))),
"the covering node should receive the press: {msgs:?}",
);
}
fn overlaid_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_pan(Msg::Camera);
ng.push_node(node(
0usize,
Point::new(100.0, 100.0),
container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H)),
));
let graph: Element<'static, Msg, Theme, Renderer> = ng.into();
let overlay =
iced::widget::opaque(container(text("")).width(Length::Fill).height(Length::Fill));
iced::widget::stack![graph, overlay].into()
}
#[test]
fn wheel_over_opaque_overlay_does_not_zoom_graph() {
let mut ui = Simulator::new(overlaid_graph());
let at = Point::new(400.0, 400.0);
ui.point_at(at);
ui.simulate([
moved(at),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0.0, y: 3.0 },
}),
]);
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Camera(_, _))),
"a covered graph must not zoom under the overlay: {msgs:?}",
);
}
#[test]
fn click_on_button_in_node_routes_to_button_not_node() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select);
ng.push_node(node(
0usize,
Point::new(100.0, 100.0),
iced::widget::button(text("go"))
.width(Length::Fixed(80.0))
.height(Length::Fixed(30.0))
.on_press(Msg::Button),
));
let mut ui = Simulator::new(Element::from(ng));
click(&mut ui, Point::new(140.0, 115.0));
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Button),
"the button inside the node must receive the click: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Select(_))),
"clicking a child button must not select the node: {msgs:?}",
);
}
#[test]
fn backspace_in_focused_text_input_does_not_delete_node() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select)
.on_delete(Msg::Delete);
ng.push_node(node(
0usize,
Point::new(100.0, 100.0),
iced::widget::text_input("", "abc")
.width(Length::Fixed(120.0))
.on_input(Msg::Input),
));
let mut ui = Simulator::new(Element::from(ng));
click(&mut ui, Point::new(150.0, 115.0));
ui.simulate([key_pressed(keyboard::Key::Character("a".into()), cmd())]);
ui.simulate([key_pressed(
keyboard::Key::Named(keyboard::key::Named::Backspace),
keyboard::Modifiers::default(),
)]);
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Delete(_))),
"Backspace in a focused text_input must not delete the node: {msgs:?}",
);
assert!(
msgs.iter().any(|m| matches!(m, Msg::Input(s) if s == "ab")),
"the focused text_input should have consumed Backspace (abc -> ab): {msgs:?}",
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "duplicate node id")]
fn push_node_rejects_duplicate_id_in_debug() {
let mut ng: Graph = NodeGraph::default();
let body = || {
container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H))
};
ng.push_node(node(7usize, Point::new(0.0, 0.0), body()));
ng.push_node(node(7usize, Point::new(50.0, 50.0), body()));
}
#[test]
fn output_to_output_does_not_connect() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Output)));
let mut ui = Simulator::new(Element::from(ng));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"two output pins must not connect (direction rule): {msgs:?}",
);
}
#[test]
fn cannot_connect_pin_to_itself() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
let mut ui = Simulator::new(Element::from(ng));
let a = out_anchor();
ui.point_at(a);
ui.simulate([moved(a), press()]);
let nudge = Point::new(a.x + 3.0, a.y); ui.point_at(nudge);
ui.simulate([moved(nudge), release()]);
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"a pin must not connect to itself: {msgs:?}",
);
}
#[test]
fn wheel_zoom_keeps_world_point_under_cursor() {
let mut ui = Simulator::new(camera_graph());
let at = Point::new(400.0, 300.0);
ui.point_at(at);
ui.simulate([
moved(at),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0.0, y: 4.0 },
}),
]);
let msgs = messages(ui);
let (pos, zoom) = last_camera(&msgs).expect("wheel must change the camera");
assert!(zoom > 1.0, "scroll up should zoom in: {zoom}");
let wx = at.x / zoom - pos.x;
let wy = at.y / zoom - pos.y;
assert!(
(wx - at.x).abs() < 0.5 && (wy - at.y).abs() < 0.5,
"world point under cursor drifted after zoom: was {at:?}, now ({wx}, {wy})",
);
}
const CAM_POS: Point = Point::new(50.0, 50.0);
const CAM_ZOOM: f32 = 2.0;
fn world_to_screen(world: Point) -> Point {
Point::new(
(world.x + CAM_POS.x) * CAM_ZOOM,
(world.y + CAM_POS.y) * CAM_ZOOM,
)
}
fn zoomed_pin_graph(seed_edge: bool) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.view(CAM_POS, CAM_ZOOM)
.on_connect(Msg::Connect)
.on_disconnect(Msg::Disconnect);
ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
if seed_edge {
ng.push_edge(edge!(PinRef::new(0, 0), PinRef::new(1, 0)));
}
ng.into()
}
#[test]
fn drag_connects_under_zoom_and_pan() {
let mut ui = Simulator::new(zoomed_pin_graph(false));
drag(
&mut ui,
world_to_screen(out_anchor()),
world_to_screen(in_anchor()),
);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"dragging output -> input under zoom+pan must connect them: {msgs:?}",
);
}
#[test]
fn ctrl_click_on_edge_disconnects_under_zoom_and_pan() {
let mut ui = Simulator::new(zoomed_pin_graph(true));
let mid_world = Point::new((out_anchor().x + in_anchor().x) / 2.0, out_anchor().y);
let mid = world_to_screen(mid_world);
ui.point_at(mid);
ui.simulate([
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(cmd())),
moved(mid),
]);
ui.simulate([press(), release()]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Disconnect(PinRef::new(0, 0), PinRef::new(1, 0))),
"ctrl+click on the edge under zoom+pan must disconnect it: {msgs:?}",
);
}
#[test]
fn shift_click_deselects_already_selected_node() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(100.0, 100.0))]));
let c = center(Point::new(100.0, 100.0));
click(&mut ui, c);
ui.point_at(c);
ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT,
))]);
ui.simulate([moved(c), press(), release()]);
assert_eq!(last_selection(&messages(ui)), Some(vec![]));
}
fn snapshot_node_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select)
.on_move(Msg::Move);
let body = container(text("HELLO WORLD").size(24))
.width(Length::Fixed(160.0))
.height(Length::Fixed(80.0))
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(
0.9, 0.2, 0.2,
))),
text_color: Some(iced::Color::WHITE),
..Default::default()
});
ng.push_node(node(0usize, Point::new(432.0, 344.0), body));
ng.into()
}
fn clear_golden(stem: &str) {
let dir = std::env::temp_dir();
for suffix in ["", "-wgpu", "-tiny-skia"] {
let _ = std::fs::remove_file(dir.join(format!("{stem}{suffix}.png")));
}
}
#[test]
fn node_dragged_to_edge_and_back_renders_identically() {
let mut ui = Simulator::new(snapshot_node_graph());
let origin = Point::new(512.0, 384.0);
click(&mut ui, origin); let at_origin = ui.snapshot(&Theme::Dark).expect("origin snapshot");
ui.point_at(origin);
ui.simulate([moved(origin), press()]);
let edge = Point::new(30.0, 384.0); ui.point_at(edge);
ui.simulate([moved(edge)]);
let at_edge = ui.snapshot(&Theme::Dark).expect("edge snapshot");
ui.point_at(origin);
ui.simulate([moved(origin)]); let back = ui.snapshot(&Theme::Dark).expect("round-trip snapshot");
let stem = "iced_ng_dragback_origin";
clear_golden(stem);
let golden = std::env::temp_dir().join(format!("{stem}.png"));
let _ = at_origin.matches_image(&golden).expect("write golden");
let edge_differs = !at_edge.matches_image(&golden).expect("edge vs origin");
let back_matches = back.matches_image(&golden).expect("round-trip vs origin");
clear_golden(stem);
assert!(
edge_differs,
"edge frame should differ from origin (drag/clip not exercised)",
);
assert!(
back_matches,
"node dragged to the edge and back must render identically to origin",
);
}
#[test]
fn pin_press_without_on_connect_falls_through_to_selection() {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select); ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body(), Output),
));
ng.push_node(node(1usize, IN_POS, pin!(Left, 0usize, pin_body(), Input)));
let mut ui = Simulator::new(Element::from(ng));
let near_pin = Point::new(OUT_POS.x + NODE_W - 2.0, OUT_POS.y + NODE_H / 2.0);
drag(&mut ui, near_pin, in_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"without on_connect, pressing a pin must not start an edge: {msgs:?}",
);
assert!(
msgs.iter()
.any(|m| matches!(m, Msg::Select(ids) if ids.contains(&0))),
"the pin press should fall through to selecting its node: {msgs:?}",
);
}