use iced::widget::{container, text};
use iced::{Element, Length, Point, Size, Theme, Vector};
use iced::{keyboard, mouse};
use iced_nodegraph::{
AnchorStatus, DragInfo, Ids, Minimap, NodeGraph, PinRef, anchor, default_anchor_style, edge,
node, pin,
};
use iced_test::Simulator;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct SimIds;
impl Ids for SimIds {
type NodeId = usize;
type PinId = usize;
type EdgeId = usize;
type AnchorId = usize;
type Payload = ();
}
type Renderer = iced::Renderer;
type Graph = NodeGraph<'static, SimIds, Msg, Theme, Renderer>;
type Pin = PinRef<SimIds>;
#[derive(Debug, Clone, PartialEq)]
enum Msg {
Select(Vec<usize>),
Move(Vector, Vec<usize>),
Resize(usize, Size),
Clone(Vec<usize>),
Delete(Vec<usize>),
Connect(Pin, Pin),
Disconnect(Pin, Pin),
Refused(Pin, Pin),
Camera(Point, f32),
DragStart(DragInfo<SimIds>),
DragUpdate(Point),
DragEnd,
Button,
Input(String),
EdgeDelete(Vec<usize>),
AnchorMove(usize, Point),
AnchorCreated(usize, Point),
RouteAttached(usize, usize),
RouteDetached(usize, usize),
AnchorDeleted(usize),
InnerConnect(Pin, Pin),
InnerCamera(Point, f32),
InnerSelect(Vec<usize>),
}
const NODE_W: f32 = 60.0;
const NODE_H: f32 = 30.0;
fn graph_with(nodes: &[(usize, Point)]) -> Element<'static, Msg, Theme, Renderer> {
graph_with_selected(nodes, &[])
}
fn graph_with_selected(
nodes: &[(usize, Point)],
selected: &[usize],
) -> Element<'static, Msg, Theme, Renderer> {
graph_of(nodes, selected, false).into()
}
fn resizable_graph(nodes: &[(usize, Point)]) -> Element<'static, Msg, Theme, Renderer> {
graph_of(nodes, &[], true).into()
}
fn snap_graph(
nodes: &[(usize, Point)],
selected: &[usize],
) -> Element<'static, Msg, Theme, Renderer> {
graph_of(nodes, selected, false).snap_grid(GRID).into()
}
fn graph_of(nodes: &[(usize, Point)], selected: &[usize], resizable: bool) -> Graph {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select)
.on_move(Msg::Move)
.on_resize(Msg::Resize)
.on_clone(Msg::Clone)
.on_delete(Msg::Delete)
.on_drag_start(Msg::DragStart)
.on_drag_update(Msg::DragUpdate)
.on_drag_end(|| Msg::DragEnd);
for &(id, pos) in nodes {
let body = container(iced::widget::text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H));
ng = ng.push_node(
node(id, pos, body)
.selected(selected.contains(&id))
.resizable(resizable),
);
}
ng
}
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_selected(
&[(0, Point::new(100.0, 100.0)), (1, Point::new(400.0, 100.0))],
&[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_selected(&[(0, Point::new(100.0, 100.0))], &[0]));
ui.point_at(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 selection_box_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_selected(
&[(0, Point::new(100.0, 100.0)), (1, Point::new(400.0, 100.0))],
&[0, 1],
));
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:?}",
);
}
const GRID: f32 = 40.0;
fn first_move(msgs: &[Msg]) -> (Vector, Vec<usize>) {
msgs.iter()
.find_map(|m| match m {
Msg::Move(delta, ids) => Some((*delta, sorted(ids.clone()))),
_ => None,
})
.unwrap_or_else(|| panic!("expected a Move, got {msgs:?}"))
}
#[test]
fn snapped_drag_reports_the_delta_that_lands_on_the_grid() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(snap_graph(&[(0, start)], &[]));
drag(
&mut ui,
center(start),
center(start) + Vector::new(50.0, 20.0),
);
let (delta, ids) = first_move(&messages(ui));
assert_eq!(ids, vec![0]);
assert_eq!((delta.x, delta.y), (60.0, 20.0), "got {delta:?}");
}
#[test]
fn snap_override_modifier_reports_the_raw_delta() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(snap_graph(&[(0, start)], &[]));
ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::ALT,
))]);
drag(
&mut ui,
center(start),
center(start) + Vector::new(50.0, 20.0),
);
let (delta, ids) = first_move(&messages(ui));
assert_eq!(ids, vec![0]);
assert_eq!((delta.x, delta.y), (50.0, 20.0), "got {delta:?}");
}
#[test]
fn snapped_group_move_shares_the_grabbed_node_delta() {
let (a, b) = (Point::new(100.0, 100.0), Point::new(413.0, 100.0));
let mut ui = Simulator::new(snap_graph(&[(0, a), (1, b)], &[0, 1]));
drag(&mut ui, center(a), center(a) + Vector::new(50.0, 20.0));
let (delta, ids) = first_move(&messages(ui));
assert_eq!(ids, vec![0, 1]);
assert_eq!((delta.x, delta.y), (60.0, 20.0), "got {delta:?}");
}
#[test]
fn anchor_drag_lands_on_the_snap_grid() {
let at = Point::new(105.0, 105.0);
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.snap_grid(GRID)
.on_anchor_move(Msg::AnchorMove);
ng = ng.push_anchor(anchor(ANCHOR_A, at));
let mut ui = Simulator::new(Element::from(ng));
drag(&mut ui, at, at + Vector::new(37.0, 12.0));
assert_eq!(
messages(ui),
vec![Msg::AnchorMove(ANCHOR_A, Point::new(160.0, 120.0))]
);
}
#[test]
fn resize_snaps_the_far_corner() {
let start = Point::new(10.0, 10.0);
let mut ui = Simulator::new(Element::from(
graph_of(&[(0, start)], &[], true).snap_grid(GRID),
));
drag(&mut ui, grip(start), grip(start) + Vector::new(107.0, 63.0));
let (id, size) = last_resize(&messages(ui)).expect("dragging the grip must emit Resize");
assert_eq!(id, 0);
assert_eq!(size, Size::new(150.0, 110.0));
}
const FRAME_ID: usize = 3;
const INSIDE_ID: usize = 1;
const OUTSIDE_ID: usize = 2;
const FRAME_SIZE: Size = Size::new(400.0, 300.0);
fn frame_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng = graph_of(
&[
(INSIDE_ID, Point::new(50.0, 50.0)),
(OUTSIDE_ID, Point::new(600.0, 600.0)),
],
&[],
false,
);
let body = container(text("frame"))
.width(Length::Fixed(FRAME_SIZE.width))
.height(Length::Fixed(FRAME_SIZE.height));
ng = ng.push_node(node(FRAME_ID, Point::ORIGIN, body).frame());
ng.into()
}
#[test]
fn dragging_a_frame_carries_the_nodes_inside_it() {
let mut ui = Simulator::new(frame_graph());
let empty_area = Point::new(300.0, 250.0);
drag(&mut ui, empty_area, empty_area + Vector::new(30.0, 10.0));
let (delta, ids) = first_move(&messages(ui));
assert_eq!(
ids,
vec![INSIDE_ID, FRAME_ID],
"the frame must carry the node inside it and leave the one outside",
);
assert_eq!((delta.x, delta.y), (30.0, 10.0), "got {delta:?}");
}
#[test]
fn pressing_a_node_inside_a_frame_drags_only_that_node() {
let mut ui = Simulator::new(frame_graph());
let from = center(Point::new(50.0, 50.0));
drag(&mut ui, from, from + Vector::new(30.0, 10.0));
let (delta, ids) = first_move(&messages(ui));
assert_eq!(
ids,
vec![INSIDE_ID],
"the node over the frame must take the press for itself",
);
assert_eq!((delta.x, delta.y), (30.0, 10.0), "got {delta:?}");
}
#[test]
fn delete_key_requests_delete_of_selection() {
let mut ui = Simulator::new(graph_with_selected(&[(0, Point::new(100.0, 100.0))], &[0]));
ui.point_at(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 a_node_marked_selected_is_acted_on_without_any_prior_interaction() {
let mut ui = Simulator::new(graph_with_selected(&[(7, Point::new(100.0, 100.0))], &[7]));
ui.point_at(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![7])),
"a host-marked selection must be live on the first frame: {msgs:?}",
);
}
#[test]
fn a_second_click_composes_with_one_the_host_has_not_applied_yet() {
let (a, b) = (Point::new(100.0, 100.0), Point::new(400.0, 100.0));
let mut ui = Simulator::new(graph_with(&[(0, a), (1, b)]));
ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT,
))]);
ui.point_at(center(a));
ui.simulate([moved(center(a)), press(), release()]);
ui.point_at(center(b));
ui.simulate([moved(center(b)), press(), release()]);
let msgs = messages(ui);
assert_eq!(
last_selection(&msgs),
Some(vec![0, 1]),
"the second shift-click must extend the first, not replace it: {msgs:?}",
);
}
#[test]
fn a_host_selection_overrides_what_the_widget_reported() {
let (a, b) = (Point::new(100.0, 100.0), Point::new(400.0, 100.0));
let mut ui = Simulator::new(graph_with_selected(&[(0, a), (1, b)], &[1]));
ui.simulate([iced::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT,
))]);
ui.point_at(center(a));
ui.simulate([moved(center(a)), press(), release()]);
let msgs = messages(ui);
assert_eq!(
last_selection(&msgs),
Some(vec![0, 1]),
"an interaction must build on the host's selection: {msgs:?}",
);
}
#[test]
fn ctrl_d_requests_clone_of_selection() {
let mut ui = Simulator::new(graph_with_selected(&[(0, Point::new(100.0, 100.0))], &[0]));
ui.point_at(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<M: 'static>() -> iced::widget::Container<'static, M, Theme, Renderer> {
container(text("p"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H))
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "a pin's id and payload types")]
fn a_pin_over_foreign_id_types_asserts_in_debug() {
let mut ng: NodeGraph<'static, iced_nodegraph::Indexed, Msg, Theme, Renderer> =
NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, "out", pin_body::<_>(), Output),
));
let mut ui = Simulator::new(Element::from(ng));
click(&mut ui, center(OUT_POS));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "resolved to an infinite size")]
fn a_fill_body_asserts_in_debug() {
let mut ui = Simulator::new(graph_of(&[], &[], false).push_node(node(
0usize,
OUT_POS,
container(text("wide")).width(Length::Fill),
)));
click(&mut ui, center(OUT_POS));
}
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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
if seed_edge {
ng = ng.push_edge(edge(0, 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:?}",
);
}
fn refusal_graph(connect_ok: bool) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect)
.on_connect_refused(Msg::Refused)
.can_connect(move |_, _| connect_ok);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng.into()
}
fn refusals(msgs: &[Msg]) -> Vec<(Pin, Pin)> {
msgs.iter()
.filter_map(|m| match m {
Msg::Refused(from, to) => Some((*from, *to)),
_ => None,
})
.collect()
}
#[test]
fn a_refused_drop_reports_the_pair_once() {
let mut ui = Simulator::new(refusal_graph(false));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert_eq!(
refusals(&msgs),
vec![(PinRef::new(0, 0), PinRef::new(1, 0))],
"a drop onto a pin can_connect refuses must be reported exactly once: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(_, _))),
"a refused drop must not also connect: {msgs:?}",
);
}
#[test]
fn a_refused_drop_reports_the_drag_order() {
let mut ui = Simulator::new(refusal_graph(false));
drag(&mut ui, in_anchor(), out_anchor());
assert_eq!(
refusals(&messages(ui)),
vec![(PinRef::new(1, 0), PinRef::new(0, 0))],
);
}
#[test]
fn a_drop_on_empty_canvas_reports_no_refusal() {
let mut ui = Simulator::new(refusal_graph(false));
drag(&mut ui, out_anchor(), Point::new(600.0, 500.0));
let msgs = messages(ui);
assert_eq!(
refusals(&msgs),
vec![],
"empty canvas is not a refusal: {msgs:?}"
);
}
#[test]
fn a_drop_back_on_the_source_pin_reports_no_refusal() {
let mut ui = Simulator::new(refusal_graph(false));
drag(&mut ui, out_anchor(), out_anchor());
let msgs = messages(ui);
assert_eq!(
refusals(&msgs),
vec![],
"releasing on the source pin is not a refusal: {msgs:?}",
);
}
#[test]
fn an_accepted_drop_reports_no_refusal() {
let mut ui = Simulator::new(refusal_graph(true));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::Connect(PinRef::new(0, 0), PinRef::new(1, 0))),
"the drop must connect: {msgs:?}",
);
assert_eq!(
refusals(&msgs),
vec![],
"a connection that was accepted must not also be reported refused: {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:?}",
);
}
#[test]
fn cutting_an_edge_reports_its_host_id() {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct WireIds;
impl Ids for WireIds {
type NodeId = usize;
type PinId = usize;
type EdgeId = &'static str;
type AnchorId = usize;
type Payload = ();
}
#[derive(Debug, Clone, PartialEq)]
enum M {
Disconnect(PinRef<WireIds>, PinRef<WireIds>),
Cut(Vec<&'static str>),
}
let mut ng: NodeGraph<'_, WireIds, M, Theme, Renderer> = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_disconnect(M::Disconnect)
.on_edge_delete(M::Cut);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_edge(edge("wire-7", PinRef::new(0, 0), PinRef::new(1, 0)));
let mut ui = Simulator::new(Element::from(ng));
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: Vec<M> = ui.into_messages().collect();
assert!(
msgs.contains(&M::Cut(vec!["wire-7"])),
"a cut must name the edge id the host supplied: {msgs:?}",
);
}
fn camera_graph() -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_camera(Msg::Camera);
ng = 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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_node(node(
2usize,
Point::new(IN_POS.x, 300.0),
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_edge(edge(0, 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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_node(node(
2usize,
Point::new(OUT_POS.x, 300.0),
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_edge(edge(0, 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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = 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_camera(Msg::Camera);
ng = 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:?}",
);
}
const INNER_SIZE: Size = Size::new(200.0, 150.0);
const ON_INNER_CANVAS: Point = Point::new(400.0, 200.0);
const ON_OUTER_CANVAS: Point = Point::new(700.0, 500.0);
fn nested_graph(
inner: impl Into<Element<'static, Msg, Theme, Renderer>>,
) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_connect(Msg::Connect)
.on_camera(Msg::Camera)
.on_select(Msg::Select);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
container(inner)
.width(INNER_SIZE.width)
.height(INNER_SIZE.height),
));
ng.into()
}
fn inner_graph() -> Graph {
let ng: Graph = NodeGraph::default()
.on_connect(Msg::InnerConnect)
.on_camera(Msg::InnerCamera)
.on_select(Msg::InnerSelect);
ng.push_node(node(
0usize,
Point::ORIGIN,
pin!(Left, 0usize, pin_body::<_>(), Input),
))
}
fn wheel_at(ui: &mut Simulator<'_, Msg, Theme, Renderer>, at: Point) {
ui.point_at(at);
ui.simulate([
moved(at),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0.0, y: 3.0 },
}),
]);
}
#[test]
fn nested_graph_pins_are_not_outer_pins() {
let mut ui = Simulator::new(nested_graph(inner_graph()));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(..))),
"the outer graph must not connect to a pin of the nested graph: {msgs:?}",
);
}
#[test]
fn nested_graph_over_other_ids_does_not_assert() {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct InnerIds;
impl Ids for InnerIds {
type NodeId = usize;
type PinId = &'static str;
type EdgeId = usize;
type AnchorId = usize;
type Payload = ();
}
let inner: NodeGraph<'static, InnerIds, Msg, Theme, Renderer> = NodeGraph::default();
let inner = inner.push_node(node(
0usize,
Point::ORIGIN,
pin!(Left, "in", pin_body::<_>(), Input),
));
let mut ui = Simulator::new(nested_graph(inner));
drag(&mut ui, out_anchor(), in_anchor());
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Connect(..))),
"the outer graph must not connect to a pin of the nested graph: {msgs:?}",
);
}
#[test]
fn wheel_over_nested_graph_zooms_inner_only() {
let mut ui = Simulator::new(nested_graph(inner_graph()));
wheel_at(&mut ui, ON_INNER_CANVAS);
let msgs = messages(ui);
assert!(
msgs.iter().any(|m| matches!(m, Msg::InnerCamera(..))),
"a wheel tick over the nested graph must zoom it: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Camera(..))),
"a wheel tick the nested graph took must not zoom the outer one: {msgs:?}",
);
let mut ui = Simulator::new(nested_graph(inner_graph()));
wheel_at(&mut ui, ON_OUTER_CANVAS);
let msgs = messages(ui);
assert!(
msgs.iter().any(|m| matches!(m, Msg::Camera(..))),
"a wheel tick on the outer canvas must zoom the outer graph: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::InnerCamera(..))),
"a wheel tick off the nested graph must not zoom it: {msgs:?}",
);
}
#[test]
fn select_all_reaches_innermost_graph_only() {
let mut ui = Simulator::new(nested_graph(inner_graph()));
ui.point_at(ON_OUTER_CANVAS);
ui.simulate([key_pressed(keyboard::Key::Character("a".into()), cmd())]);
let msgs = messages(ui);
assert!(
msgs.iter().any(|m| matches!(m, Msg::InnerSelect(..))),
"select-all must reach the nested graph: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Select(..))),
"a shortcut the nested graph took must not act on the outer one: {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 = 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 = 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 body = || {
container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H))
};
let _: Graph = NodeGraph::default()
.push_node(node(7, Point::new(0.0, 0.0), body()))
.push_node(node(7, Point::new(50.0, 50.0), body()));
}
#[test]
fn a_node_and_an_anchor_may_share_a_number() {
let mut ng: Graph = NodeGraph::default();
ng = ng.push_node(node(
7usize,
Point::new(0.0, 0.0),
container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H)),
));
ng = ng.push_anchor(anchor(7usize, Point::new(120.0, 120.0)));
let ui = Simulator::new(Element::from(ng));
let _ = ui.into_messages();
}
#[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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = 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 = 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)
.camera(CAM_POS, CAM_ZOOM)
.on_connect(Msg::Connect)
.on_disconnect(Msg::Disconnect);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
if seed_edge {
ng = ng.push_edge(edge(0, 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_selected(&[(0, Point::new(100.0, 100.0))], &[0]));
let c = center(Point::new(100.0, 100.0));
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 drag_infos(msgs: &[Msg]) -> Vec<DragInfo<SimIds>> {
msgs.iter()
.filter_map(|m| match m {
Msg::DragStart(info) => Some(info.clone()),
_ => None,
})
.collect()
}
#[test]
fn node_drag_is_bracketed_by_start_and_end() {
let pos = Point::new(100.0, 100.0);
let mut ui = Simulator::new(graph_with(&[(0, pos)]));
drag(&mut ui, center(pos), Point::new(300.0, 260.0));
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::Node { node_id: 0 }],
"one node drag must report exactly one start, naming the node's user id: {msgs:?}",
);
assert_eq!(
msgs.iter().filter(|m| **m == Msg::DragEnd).count(),
1,
"the drag must end exactly once: {msgs:?}",
);
}
#[test]
fn drag_update_reports_the_world_cursor() {
let pos = Point::new(100.0, 100.0);
let target = Point::new(400.0, 300.0);
let mut ui = Simulator::new(graph_with(&[(0, pos)]));
drag(&mut ui, center(pos), target);
let updates: Vec<Point> = messages(ui)
.into_iter()
.filter_map(|m| match m {
Msg::DragUpdate(p) => Some(p),
_ => None,
})
.collect();
assert_eq!(updates.last(), Some(&target));
}
#[test]
fn dragging_a_multi_selection_reports_the_whole_group() {
let (a, b) = (Point::new(100.0, 100.0), Point::new(400.0, 100.0));
let mut ui = Simulator::new(graph_with_selected(&[(0, a), (1, b)], &[0, 1]));
drag(&mut ui, center(b), Point::new(600.0, 300.0));
let msgs = messages(ui);
let group = drag_infos(&msgs)
.into_iter()
.find_map(|info| match info {
DragInfo::Group { node_ids } => Some(sorted(node_ids)),
_ => None,
})
.unwrap_or_else(|| panic!("dragging a selected node must report a group: {msgs:?}"));
assert_eq!(group, vec![0, 1]);
}
#[test]
fn selection_box_drag_reports_its_anchor() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(400.0, 400.0))]));
let anchor = Point::new(50.0, 60.0);
drag(&mut ui, anchor, Point::new(500.0, 500.0));
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::SelectionBox {
start_x: anchor.x,
start_y: anchor.y,
}],
"a drag on empty canvas must report the selection box anchor: {msgs:?}",
);
}
fn drag_ends(msgs: &[Msg]) -> usize {
msgs.iter().filter(|m| **m == Msg::DragEnd).count()
}
#[test]
fn anchor_drag_reports_the_anchor() {
let at = Point::new(200.0, 150.0);
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_anchor_move(Msg::AnchorMove)
.on_drag_start(Msg::DragStart)
.on_drag_end(|| Msg::DragEnd);
ng = ng.push_anchor(anchor(ANCHOR_A, at));
let mut ui = Simulator::new(Element::from(ng));
drag(
&mut ui,
at + CORE_NUDGE,
at + CORE_NUDGE + Vector::new(60.0, 40.0),
);
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::Anchor {
anchor_id: ANCHOR_A
}],
"an anchor drag must report exactly one start naming the anchor: {msgs:?}",
);
assert_eq!(
drag_ends(&msgs),
1,
"the drag must end exactly once: {msgs:?}"
);
}
#[test]
fn route_drag_reports_the_edge() {
let ng = routing_graph()
.on_drag_start(Msg::DragStart)
.on_drag_end(|| Msg::DragEnd);
let mut ui = Simulator::new(route_scene(ng, View::IDENTITY, &[], &[]));
drag(&mut ui, bare_mid(), Point::new(330.0, 400.0));
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::Route {
edge_id: ROUTE_EDGE
}],
"a grab of the cable's run must report exactly one start naming the edge: {msgs:?}",
);
assert_eq!(
drag_ends(&msgs),
1,
"the drag must end exactly once: {msgs:?}"
);
}
#[test]
fn resize_drag_reports_the_node() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(resizable_graph(&[(0, start)]));
drag(&mut ui, grip(start), grip(start) + Vector::new(40.0, 10.0));
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::Resize { node_id: 0 }],
"a grip drag must report exactly one start naming the node: {msgs:?}",
);
assert_eq!(
drag_ends(&msgs),
1,
"the drag must end exactly once: {msgs:?}"
);
}
#[test]
fn edge_cut_drag_reports_the_cutting_tool() {
let mut ui = Simulator::new(graph_with(&[(0, Point::new(400.0, 400.0))]));
let from = Point::new(50.0, 60.0);
ui.point_at(from);
ui.simulate([
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(cmd())),
moved(from),
]);
drag(&mut ui, from, Point::new(300.0, 300.0));
let msgs = messages(ui);
assert_eq!(
drag_infos(&msgs),
vec![DragInfo::EdgeCut],
"a cut across the canvas must report exactly one start: {msgs:?}",
);
assert_eq!(
drag_ends(&msgs),
1,
"the drag must end exactly once: {msgs:?}"
);
}
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 = 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 = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = 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:?}",
);
}
#[test]
fn selection_works_without_the_host_marking_anything() {
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])),
"an unmarked host must still get a working selection: {msgs:?}",
);
}
fn grip(p: Point) -> Point {
Point::new(p.x + NODE_W - 6.0, p.y + NODE_H - 6.0)
}
fn last_resize(msgs: &[Msg]) -> Option<(usize, Size)> {
msgs.iter().rev().find_map(|m| match m {
Msg::Resize(id, size) => Some((*id, *size)),
_ => None,
})
}
fn any_move(msgs: &[Msg]) -> bool {
msgs.iter().any(|m| matches!(m, Msg::Move(..)))
}
#[test]
fn grip_drag_on_resizable_node_reports_the_new_size() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(resizable_graph(&[(0, start)]));
drag(&mut ui, grip(start), grip(start) + Vector::new(40.0, 10.0));
let msgs = messages(ui);
let (id, size) = last_resize(&msgs).expect("dragging the grip must emit Resize");
assert_eq!(id, 0);
assert!(
(size.width - (NODE_W + 40.0)).abs() < 0.5 && (size.height - (NODE_H + 10.0)).abs() < 0.5,
"grip drag should report (100, 40), got {size:?}",
);
assert!(
!any_move(&msgs),
"a resize must not move the node: {msgs:?}",
);
}
#[test]
fn grip_drag_on_non_resizable_node_moves_it_instead() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(graph_with(&[(0, start)]));
drag(&mut ui, grip(start), grip(start) + Vector::new(40.0, 10.0));
let msgs = messages(ui);
assert_eq!(last_resize(&msgs), None, "unmarked node must not resize");
let moved = msgs.iter().find_map(|m| match m {
Msg::Move(delta, ids) => Some((*delta, sorted(ids.clone()))),
_ => None,
});
let (delta, ids) = moved.expect("the corner of an unmarked node still drags it");
assert_eq!(ids, vec![0]);
assert!(
(delta.x - 40.0).abs() < 0.5 && (delta.y - 10.0).abs() < 0.5,
"node should move by (40, 10), got {delta:?}",
);
}
#[test]
fn grip_drag_clamps_to_the_minimum_size() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(resizable_graph(&[(0, start)]));
drag(&mut ui, grip(start), start + Vector::new(10.0, 10.0));
let msgs = messages(ui);
let (_, size) = last_resize(&msgs).expect("dragging the grip must emit Resize");
assert_eq!(size, Size::new(32.0, 24.0));
}
#[test]
fn body_drag_on_a_resizable_node_still_moves_it() {
let start = Point::new(100.0, 100.0);
let mut ui = Simulator::new(resizable_graph(&[(0, start)]));
drag(
&mut ui,
center(start),
center(start) + Vector::new(50.0, 20.0),
);
let msgs = messages(ui);
assert_eq!(
last_resize(&msgs),
None,
"a body drag is a move, not a resize: {msgs:?}",
);
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 resizable node's body 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:?}",
);
}
#[derive(Debug, Clone, Copy)]
struct View {
position: Point,
zoom: f32,
origin: Vector,
}
impl View {
const IDENTITY: Self = Self {
position: Point::ORIGIN,
zoom: 1.0,
origin: Vector::ZERO,
};
const fn below_toolbar(self) -> Self {
Self {
position: self.position,
zoom: self.zoom,
origin: Vector::new(0.0, TOOLBAR),
}
}
fn screen(self, world: Point) -> Point {
Point::new(
self.origin.x + (world.x + self.position.x) * self.zoom,
self.origin.y + (world.y + self.position.y) * self.zoom,
)
}
}
const HALF_SCALE: View = View {
position: Point::ORIGIN,
zoom: 0.5,
origin: Vector::ZERO,
};
const DOUBLE_SCALE: View = View {
position: Point::new(-100.0, -100.0),
zoom: 2.0,
origin: Vector::ZERO,
};
const OFF_UNIT_VIEWS: [View; 2] = [HALF_SCALE, DOUBLE_SCALE];
const CORE_NUDGE: Vector = Vector::new(2.0, 1.0);
fn orbit_0() -> f32 {
default_anchor_style(&Theme::Dark, AnchorStatus::Idle).orbit_offset
}
const ANCHOR_A: usize = 7;
const ANCHOR_B: usize = 8;
fn graph_with_anchor(view: View, at: Point) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.camera(view.position, view.zoom)
.on_anchor_move(Msg::AnchorMove);
ng = ng.push_anchor(anchor(ANCHOR_A, at));
ng.into()
}
#[test]
fn dragging_an_anchor_reports_its_new_position() {
let at = Point::new(200.0, 150.0);
let travel = Vector::new(60.0, 40.0);
let mut ui = Simulator::new(graph_with_anchor(View::IDENTITY, at));
drag(&mut ui, at + CORE_NUDGE, at + CORE_NUDGE + travel);
assert_eq!(messages(ui), vec![Msg::AnchorMove(ANCHOR_A, at + travel)]);
}
#[test]
fn clicking_an_anchor_reports_no_move() {
let at = Point::new(200.0, 150.0);
let mut ui = Simulator::new(graph_with_anchor(View::IDENTITY, at));
click(&mut ui, at);
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::AnchorMove(..))),
"a motionless click reported a move: {msgs:?}",
);
}
#[test]
fn an_anchor_is_not_grabbable_without_the_callback() {
let at = Point::new(200.0, 150.0);
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_select(Msg::Select);
ng = ng.push_anchor(anchor(ANCHOR_A, at));
let mut ui = Simulator::new(Element::from(ng));
drag(&mut ui, at, Point::new(260.0, 190.0));
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::AnchorMove(..))),
"an anchor moved without a handler to report it to: {msgs:?}",
);
}
type RouteGraph = Graph;
const ROUTE_EDGE: usize = 0;
const ROUTE_IN_POS: Point = Point::new(500.0, 100.0);
const A_AT: Point = Point::new(330.0, 200.0);
const B_AT: Point = Point::new(330.0, 400.0);
fn route_in_anchor() -> Point {
Point::new(ROUTE_IN_POS.x, ROUTE_IN_POS.y + NODE_H / 2.0)
}
fn bare_mid() -> Point {
Point::new((out_anchor().x + route_in_anchor().x) / 2.0, out_anchor().y)
}
fn wrap_point(at: Point) -> Point {
Point::new(at.x, at.y + orbit_0())
}
fn route_scene(
ng: RouteGraph,
view: View,
anchors: &[(usize, Point)],
route: &[usize],
) -> Element<'static, Msg, Theme, Renderer> {
let mut ng = ng.camera(view.position, view.zoom);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
ROUTE_IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
for &(id, at) in anchors {
ng = ng.push_anchor(anchor(id, at));
}
ng = ng.push_edge(
edge(ROUTE_EDGE, PinRef::new(0, 0), PinRef::new(1, 0)).route(route.iter().copied()),
);
ng.into()
}
fn routing_graph() -> RouteGraph {
NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_anchor_create(Msg::AnchorCreated)
.on_route_attach(Msg::RouteAttached)
.on_route_detach(Msg::RouteDetached)
}
fn pan_press() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
}
fn pan_release() -> iced::Event {
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right))
}
#[test]
fn grabbing_a_cable_mid_run_creates_an_anchor_on_release() {
let mut ui = Simulator::new(route_scene(routing_graph(), View::IDENTITY, &[], &[]));
let dropped_at = Point::new(330.0, 400.0);
drag(&mut ui, bare_mid(), dropped_at);
assert_eq!(
messages(ui),
vec![Msg::AnchorCreated(ROUTE_EDGE, dropped_at)],
);
}
#[test]
fn a_route_drag_snaps_onto_an_anchor_immediately() {
let snapped = || {
let mut ui = Simulator::new(route_scene(
routing_graph(),
View::IDENTITY,
&[(ANCHOR_B, B_AT)],
&[],
));
ui.point_at(bare_mid());
ui.simulate([moved(bare_mid()), press()]);
ui.point_at(B_AT);
ui.simulate([moved(B_AT)]);
ui
};
assert_eq!(
messages(snapped()),
vec![Msg::RouteAttached(ROUTE_EDGE, ANCHOR_B)],
"the attachment must be published on snap, not held back for the release",
);
let mut released = snapped();
released.simulate([release()]);
assert_eq!(
messages(released),
vec![Msg::RouteAttached(ROUTE_EDGE, ANCHOR_B)],
"releasing on the anchor must add nothing: it is already attached",
);
}
#[test]
fn leaving_the_anchor_detaches_again() {
let mut ui = Simulator::new(route_scene(
routing_graph(),
View::IDENTITY,
&[(ANCHOR_B, B_AT)],
&[],
));
ui.point_at(bare_mid());
ui.simulate([moved(bare_mid()), press()]);
ui.point_at(B_AT);
ui.simulate([moved(B_AT)]);
let away = Point::new(B_AT.x, B_AT.y - 100.0);
ui.point_at(away);
ui.simulate([moved(away)]);
assert_eq!(
messages(ui),
vec![
Msg::RouteAttached(ROUTE_EDGE, ANCHOR_B),
Msg::RouteDetached(ROUTE_EDGE, ANCHOR_B),
],
);
}
#[test]
fn a_wrap_grab_detaches_only_past_the_unsnap_threshold() {
let wrap = wrap_point(A_AT);
let scene = || {
route_scene(
routing_graph(),
View::IDENTITY,
&[(ANCHOR_A, A_AT)],
&[ANCHOR_A],
)
};
let mut wiggled = Simulator::new(scene());
let nudge = Point::new(A_AT.x, A_AT.y + orbit_0() + 13.0);
wiggled.point_at(wrap);
wiggled.simulate([moved(wrap), press()]);
wiggled.point_at(nudge);
wiggled.simulate([moved(nudge), release()]);
assert_eq!(
messages(wiggled),
Vec::<Msg>::new(),
"a wrap grab that never left the ring must keep the anchor",
);
let mut pulled = Simulator::new(scene());
let away = Point::new(A_AT.x, A_AT.y + 300.0);
pulled.point_at(wrap);
pulled.simulate([moved(wrap), press()]);
pulled.point_at(away);
pulled.simulate([moved(away), release()]);
assert_eq!(
messages(pulled),
vec![
Msg::RouteDetached(ROUTE_EDGE, ANCHOR_A),
Msg::AnchorCreated(ROUTE_EDGE, away),
],
"pulling the cable off must report the detachment, then the anchor the \
release leaves behind",
);
}
#[test]
fn a_click_of_the_pan_button_on_a_core_deletes() {
let scene = || {
route_scene(
routing_graph()
.on_anchor_delete(Msg::AnchorDeleted)
.on_camera(Msg::Camera),
View::IDENTITY,
&[(ANCHOR_A, A_AT)],
&[ANCHOR_A],
)
};
let mut clicked = Simulator::new(scene());
clicked.point_at(A_AT);
clicked.simulate([moved(A_AT), pan_press(), pan_release()]);
assert_eq!(
messages(clicked),
vec![Msg::AnchorDeleted(ANCHOR_A)],
"a travel-free pan-button click on a core deletes it and pans nothing",
);
let mut dragged = Simulator::new(scene());
let to = Point::new(A_AT.x + 60.0, A_AT.y);
dragged.point_at(A_AT);
dragged.simulate([moved(A_AT), pan_press()]);
dragged.point_at(to);
dragged.simulate([moved(to), pan_release()]);
assert_eq!(
messages(dragged),
vec![Msg::Camera(Point::new(60.0, 0.0), 1.0)],
"a pan-button press that travelled is a pan seeded at the press point, \
and never a delete",
);
}
#[test]
fn a_click_of_the_pan_button_on_a_wrap_detaches() {
let mut ui = Simulator::new(route_scene(
routing_graph().on_camera(Msg::Camera),
View::IDENTITY,
&[(ANCHOR_A, A_AT)],
&[ANCHOR_A],
));
let wrap = wrap_point(A_AT);
ui.point_at(wrap);
ui.simulate([moved(wrap), pan_press(), pan_release()]);
assert_eq!(
messages(ui),
vec![Msg::RouteDetached(ROUTE_EDGE, ANCHOR_A)],
"a pan-button click on a wrap detaches that cable and pans nothing",
);
}
#[test]
fn route_gestures_without_callbacks_fall_through() {
let ng: RouteGraph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_drag_start(Msg::DragStart);
let mut ui = Simulator::new(route_scene(ng, View::IDENTITY, &[], &[]));
let at = bare_mid();
ui.point_at(at);
ui.simulate([moved(at), press()]);
assert_eq!(
messages(ui),
vec![Msg::DragStart(DragInfo::SelectionBox {
start_x: at.x,
start_y: at.y,
})],
"the mid-run zone must not swallow a press it has no callback for",
);
}
#[test]
fn cutting_a_routed_cable_kills_one_edge() {
let mut ng: RouteGraph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.on_disconnect(Msg::Disconnect)
.on_edge_delete(Msg::EdgeDelete);
let shared = Point::new(330.0, 300.0);
for (id, at) in [(0usize, OUT_POS), (2usize, Point::new(OUT_POS.x, 500.0))] {
ng = ng.push_node(node(id, at, pin!(Right, 0usize, pin_body::<_>(), Output)));
}
for (id, at) in [
(1usize, ROUTE_IN_POS),
(3usize, Point::new(ROUTE_IN_POS.x, 500.0)),
] {
ng = ng.push_node(node(id, at, pin!(Left, 0usize, pin_body::<_>(), Input)));
}
ng = ng.push_anchor(anchor(ANCHOR_A, shared));
ng = ng.push_edge(edge(0usize, PinRef::new(0, 0), PinRef::new(1, 0)).route([ANCHOR_A]));
ng = ng.push_edge(edge(1usize, PinRef::new(2, 0), PinRef::new(3, 0)).route([ANCHOR_A]));
let mut ui = Simulator::new(Element::from(ng));
let on_upper_cable = Point::new(228.0, 160.0);
ui.point_at(on_upper_cable);
ui.simulate([
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(cmd())),
moved(on_upper_cable),
]);
ui.simulate([press(), release()]);
assert_eq!(
messages(ui),
vec![
Msg::Disconnect(PinRef::new(0, 0), PinRef::new(1, 0)),
Msg::EdgeDelete(vec![0]),
],
"a cut through one routed cable must take that edge and only that edge",
);
}
#[test]
fn a_route_drag_snaps_onto_an_occupied_anchor() {
let scene = || {
let mut ng = routing_graph();
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
ROUTE_IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_node(node(
2usize,
Point::new(OUT_POS.x, 500.0),
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
3usize,
Point::new(ROUTE_IN_POS.x, 500.0),
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_anchor(anchor(ANCHOR_A, A_AT));
ng = ng.push_edge(edge(0usize, PinRef::new(0, 0), PinRef::new(1, 0)).route([ANCHOR_A]));
ng = ng.push_edge(edge(1usize, PinRef::new(2, 0), PinRef::new(3, 0)));
Element::from(ng)
};
let lower_mid = Point::new(bare_mid().x, 515.0);
let mut ui = Simulator::new(scene());
ui.point_at(lower_mid);
ui.simulate([moved(lower_mid), press()]);
ui.point_at(A_AT);
ui.simulate([moved(A_AT)]);
assert_eq!(
messages(ui),
vec![Msg::RouteAttached(1, ANCHOR_A)],
"a second cable must attach on snap, not wait for the release",
);
}
#[test]
fn a_detached_cable_can_be_attached_to_another_anchor() {
let mut ui = Simulator::new(route_scene(
routing_graph(),
View::IDENTITY,
&[(ANCHOR_A, A_AT), (ANCHOR_B, B_AT)],
&[ANCHOR_A],
));
let wrap = wrap_point(A_AT);
ui.point_at(wrap);
ui.simulate([moved(wrap), press()]);
let between = Point::new(A_AT.x, (A_AT.y + B_AT.y) / 2.0);
ui.point_at(between);
ui.simulate([moved(between)]);
ui.point_at(B_AT);
ui.simulate([moved(B_AT)]);
assert_eq!(
messages(ui),
vec![
Msg::RouteDetached(ROUTE_EDGE, ANCHOR_A),
Msg::RouteAttached(ROUTE_EDGE, ANCHOR_B),
],
"a cable pulled off one anchor must be able to land on another",
);
}
const CLEAR_OF_EVERYTHING: Point = Point::new(330.0, 300.0);
const EIGHTH_SCALE: View = View {
position: Point::ORIGIN,
zoom: 0.125,
origin: Vector::ZERO,
};
#[test]
fn a_wrap_grab_detaches_at_any_zoom() {
for view in OFF_UNIT_VIEWS {
let zoom = view.zoom;
let mut ui = Simulator::new(route_scene(
routing_graph().on_anchor_move(Msg::AnchorMove),
view,
&[(ANCHOR_A, A_AT)],
&[ANCHOR_A],
));
drag(
&mut ui,
view.screen(wrap_point(A_AT)),
view.screen(CLEAR_OF_EVERYTHING),
);
assert_eq!(
messages(ui),
vec![
Msg::RouteDetached(ROUTE_EDGE, ANCHOR_A),
Msg::AnchorCreated(ROUTE_EDGE, CLEAR_OF_EVERYTHING),
],
"the wrap must take the press and report the detach at zoom {zoom}",
);
}
}
#[test]
fn a_pan_button_click_on_a_wrap_detaches_at_any_zoom() {
for view in OFF_UNIT_VIEWS {
let zoom = view.zoom;
let mut ui = Simulator::new(route_scene(
routing_graph()
.on_anchor_delete(Msg::AnchorDeleted)
.on_camera(Msg::Camera),
view,
&[(ANCHOR_A, A_AT)],
&[ANCHOR_A],
));
let wrap = view.screen(wrap_point(A_AT));
ui.point_at(wrap);
ui.simulate([moved(wrap), pan_press(), pan_release()]);
assert_eq!(
messages(ui),
vec![Msg::RouteDetached(ROUTE_EDGE, ANCHOR_A)],
"the click must detach that cable and leave the anchor at zoom {zoom}",
);
}
}
#[test]
fn an_anchor_core_is_grabbable_at_any_zoom() {
let at = Point::new(200.0, 150.0);
let travel = Vector::new(60.0, 40.0);
for view in OFF_UNIT_VIEWS {
let zoom = view.zoom;
let mut ui = Simulator::new(graph_with_anchor(view, at));
drag(
&mut ui,
view.screen(at + CORE_NUDGE),
view.screen(at + CORE_NUDGE + travel),
);
assert_eq!(
messages(ui),
vec![Msg::AnchorMove(ANCHOR_A, at + travel)],
"the core must grab and report the same move at zoom {zoom}",
);
}
}
#[test]
fn a_cable_mid_run_creates_an_anchor_at_any_zoom() {
for view in [HALF_SCALE, DOUBLE_SCALE, EIGHTH_SCALE] {
let zoom = view.zoom;
let mut ui = Simulator::new(route_scene(routing_graph(), view, &[], &[]));
drag(
&mut ui,
view.screen(bare_mid()),
view.screen(CLEAR_OF_EVERYTHING),
);
assert_eq!(
messages(ui),
vec![Msg::AnchorCreated(ROUTE_EDGE, CLEAR_OF_EVERYTHING)],
"the cable's run must take the press and name the anchor at zoom {zoom}",
);
}
}
const TOOLBAR: f32 = 100.0;
const OFFSET_EDGE: usize = 7;
const OFFSET_ANCHOR: usize = 9;
const OFFSET_ANCHOR_AT: Point = Point::new(230.0, 300.0);
const OFFSET_VIEW: View = View::IDENTITY.below_toolbar();
fn drawn_at(world: Point) -> Point {
OFFSET_VIEW.screen(world)
}
fn offset_scene(view: View, route: &[usize]) -> Element<'static, Msg, Theme, Renderer> {
use iced::widget::column;
let mut ng: RouteGraph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.camera(view.position, view.zoom)
.on_select(Msg::Select)
.on_camera(Msg::Camera)
.on_anchor_move(Msg::AnchorMove)
.on_anchor_create(Msg::AnchorCreated)
.on_anchor_delete(Msg::AnchorDeleted)
.on_route_attach(Msg::RouteAttached)
.on_route_detach(Msg::RouteDetached);
ng = ng.push_node(node(
0usize,
OUT_POS,
pin!(Right, 0usize, pin_body::<_>(), Output),
));
ng = ng.push_node(node(
1usize,
IN_POS,
pin!(Left, 0usize, pin_body::<_>(), Input),
));
ng = ng.push_anchor(anchor(OFFSET_ANCHOR, OFFSET_ANCHOR_AT));
ng =
ng.push_edge(edge(OFFSET_EDGE, PinRef::new(0, 0), PinRef::new(1, 0)).route(route.to_vec()));
column![
container(text(""))
.width(Length::Fill)
.height(Length::Fixed(TOOLBAR)),
ng
]
.into()
}
#[test]
fn an_anchor_core_is_grabbable_where_it_is_drawn() {
let travel = Vector::new(50.0, 40.0);
let press_at = OFFSET_ANCHOR_AT + CORE_NUDGE;
let mut ui = Simulator::new(offset_scene(OFFSET_VIEW, &[]));
drag(&mut ui, drawn_at(press_at), drawn_at(press_at + travel));
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::AnchorMove(OFFSET_ANCHOR, OFFSET_ANCHOR_AT + travel)),
"the drawn core must grab and report a world position: {msgs:?}",
);
}
#[test]
fn nothing_is_grabbable_at_the_unshifted_world_position() {
let mut ui = Simulator::new(offset_scene(OFFSET_VIEW, &[]));
drag(&mut ui, OFFSET_ANCHOR_AT, Point::new(280.0, 340.0));
let msgs = messages(ui);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::AnchorMove(..))),
"the anchor must not answer where it is not drawn: {msgs:?}",
);
}
#[test]
fn a_pan_button_click_on_the_drawn_core_deletes_it() {
let mut ui = Simulator::new(offset_scene(OFFSET_VIEW, &[]));
let drawn = drawn_at(OFFSET_ANCHOR_AT);
ui.point_at(drawn);
ui.simulate([moved(drawn), pan_press(), pan_release()]);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::AnchorDeleted(OFFSET_ANCHOR)),
"the drawn core must take the delete click: {msgs:?}",
);
}
#[test]
fn a_created_anchor_is_reported_in_world_space() {
let mut ui = Simulator::new(offset_scene(OFFSET_VIEW, &[]));
let mid_run = drawn_at(Point::new(230.0, out_anchor().y));
drag(&mut ui, mid_run, drawn_at(Point::new(500.0, 400.0)));
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::AnchorCreated(OFFSET_EDGE, Point::new(500.0, 400.0))),
"a created anchor is reported where the host would store it: {msgs:?}",
);
}
#[test]
fn a_wrap_is_grabbable_where_it_is_drawn() {
let mut ui = Simulator::new(offset_scene(OFFSET_VIEW, &[OFFSET_ANCHOR]));
let ring_bottom = drawn_at(wrap_point(OFFSET_ANCHOR_AT));
drag(&mut ui, ring_bottom, drawn_at(Point::new(600.0, 500.0)));
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::RouteDetached(OFFSET_EDGE, OFFSET_ANCHOR)),
"the drawn wrap must take the press and report the anchor it came off: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::Select(_))),
"the wrap took the press, so no selection box opened: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::AnchorMove(..))),
"the wrap took the press, so the core never moved: {msgs:?}",
);
}
#[test]
fn a_wrap_is_grabbable_where_it_is_drawn_at_any_zoom() {
for camera in OFF_UNIT_VIEWS {
let view = camera.below_toolbar();
let zoom = view.zoom;
let dropped_at = Point::new(400.0, 400.0);
let mut ui = Simulator::new(offset_scene(view, &[OFFSET_ANCHOR]));
drag(
&mut ui,
view.screen(wrap_point(OFFSET_ANCHOR_AT)),
view.screen(dropped_at),
);
let msgs = messages(ui);
assert!(
msgs.contains(&Msg::RouteDetached(OFFSET_EDGE, OFFSET_ANCHOR)),
"the drawn wrap must report the detach at zoom {zoom}: {msgs:?}",
);
assert!(
msgs.contains(&Msg::AnchorCreated(OFFSET_EDGE, dropped_at)),
"the pulled-off cable must leave an anchor at zoom {zoom}: {msgs:?}",
);
assert!(
!msgs.iter().any(|m| matches!(m, Msg::AnchorMove(..))),
"the wrap took the press, so the core never moved at zoom {zoom}: {msgs:?}",
);
}
}
const MAP_CENTER: Point = Point::new(1024.0 - 12.0 - 100.0, 768.0 - 12.0 - 75.0);
fn minimap_graph(
nodes: &[(usize, Point)],
anchors: &[(usize, Point)],
) -> Element<'static, Msg, Theme, Renderer> {
let mut ng: Graph = NodeGraph::default()
.width(Length::Fill)
.height(Length::Fill)
.minimap(Minimap::default())
.on_camera(Msg::Camera)
.on_select(Msg::Select)
.on_move(Msg::Move);
for &(id, pos) in nodes {
let body = container(text("n"))
.width(Length::Fixed(NODE_W))
.height(Length::Fixed(NODE_H));
ng = ng.push_node(node(id, pos, body));
}
for &(id, at) in anchors {
ng = ng.push_anchor(anchor(id, at));
}
ng.into()
}
fn in_viewport(p: Point, position: Point, zoom: f32) -> bool {
let on_screen = Point::new((p.x + position.x) * zoom, (p.y + position.y) * zoom);
(0.0..=1024.0).contains(&on_screen.x) && (0.0..=768.0).contains(&on_screen.y)
}
#[test]
fn a_press_at_the_minimap_center_brings_the_graph_into_view() {
const NEAR: Point = Point::new(100.0, 100.0);
const FAR: Point = Point::new(2000.0, 1500.0);
let mut ui = Simulator::new(minimap_graph(&[(0, NEAR), (1, FAR)], &[]));
click(&mut ui, MAP_CENTER);
let msgs = messages(ui);
let (position, zoom) = last_camera(&msgs).expect("a map press must publish a camera");
let center = Point::new(
(NEAR.x + FAR.x + NODE_W) / 2.0,
(NEAR.y + FAR.y + NODE_H) / 2.0,
);
assert!(
in_viewport(center, position, zoom),
"the graph's center must land in the viewport, got camera {position:?} at zoom {zoom}",
);
}
#[test]
fn the_minimap_bounds_anchors_beyond_every_node() {
const NEAR: Point = Point::new(100.0, 100.0);
const FAR_ANCHOR: Point = Point::new(2000.0, 1500.0);
let mut ui = Simulator::new(minimap_graph(&[(0, NEAR)], &[(ANCHOR_A, FAR_ANCHOR)]));
click(&mut ui, MAP_CENTER);
let msgs = messages(ui);
let (position, zoom) = last_camera(&msgs).expect("a map press must publish a camera");
let center = Point::new((NEAR.x + FAR_ANCHOR.x) / 2.0, (NEAR.y + FAR_ANCHOR.y) / 2.0);
assert!(
in_viewport(center, position, zoom),
"the node-and-anchor center must land in the viewport, got camera {position:?} \
at zoom {zoom}",
);
}
#[test]
fn a_press_on_the_minimap_never_reaches_the_node_beneath_it() {
let covered = Point::new(MAP_CENTER.x - NODE_W / 2.0, MAP_CENTER.y - NODE_H / 2.0);
let mut ui = Simulator::new(minimap_graph(&[(0, covered)], &[]));
drag(
&mut ui,
MAP_CENTER,
Point::new(MAP_CENTER.x + 20.0, MAP_CENTER.y + 10.0),
);
let msgs = messages(ui);
assert!(
!msgs
.iter()
.any(|m| matches!(m, Msg::Move(..) | Msg::Select(_))),
"the covered node must neither move nor be selected: {msgs:?}",
);
assert!(
msgs.iter().any(|m| matches!(m, Msg::Camera(..))),
"the press must still steer the camera: {msgs:?}",
);
}