Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/state_bend_tests.rs
T
sorokin.oleg.edu 2a3b9ba822 Add exact State multiselection, atomic movement and placement Hide
Preserve the desktop-2 captured source (1286 passed, 6 existing ignored). Three menu payload blobs are staged from that verified capture without replacing working files; the strict-lint size correction follows in a separate commit. Native acceptance remains pending.
2026-09-23 03:37:01 -07:00

657 lines
25 KiB
Rust

// Included in state_canvas::tests: exercise the real canvas event path.
fn bend_fixture(authored: bool) -> (Fixture, Id) {
let mut f = fixture();
let relation = f.project.relate(
RelationKind::StateTransition,
"Standby to stopped",
f.b,
f.c,
);
if authored {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![Position {
x: 650.25,
y: 340.75,
}],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
label: Some(EdgeLabel {
fraction: 0.43,
offset: Position { x: 28., y: -16. },
}),
});
}
validate_candidate(&f.project).unwrap();
(f, relation)
}
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
let edge = &scene.routed.edges[&relation];
edge.points
.windows(2)
.enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| {
(
i,
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
)
})
.filter(|(_, p, length)| {
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(i, p, _)| (i, p))
.expect("fixture has an exposed interior route segment")
}
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
motion(canvas, state, at, at);
let event = input(
canvas,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"press alone cannot commit"
);
}
#[test]
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
for zoom in [0.5, 1., 2.] {
let (f, relation) = bend_fixture(false);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let (_, midpoint) = bend_segment(&baseline, relation);
let start = screen(&canvas, midpoint);
let destination = Position {
x: ((midpoint.x + 45.) / 5.).round() * 5.,
y: ((midpoint.y + 30.) / 5.).round() * 5.,
};
let release = screen(&canvas, point(destination));
let later_batch_cursor = release + Vector::new(137., 83.);
motion(&canvas, &mut state, start, later_batch_cursor);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
later_batch_cursor,
);
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
motion(&canvas, &mut state, release, later_batch_cursor);
assert_eq!(
state.bend_preview.as_ref().unwrap().waypoints,
vec![destination]
);
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
vec![destination]
);
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
);
assert!(state.bend_preview.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor
)
.is_none()
);
let after = prepare_command(
&f.project,
&state_edit::plan(&f.project, canvas.context, &event)
.unwrap()
.command,
)
.unwrap()
.project;
let stored = &after.diagrams[&f.diagram].routes[0];
assert_eq!(stored.waypoints, vec![destination]);
assert!(
stored.source_attachment.is_none()
&& stored.target_attachment.is_none()
&& stored.label.is_none()
);
assert_eq!(after.relations, f.project.relations);
}
}
#[test]
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
for zoom in [0.5, 1., 2.] {
let (mut f, relation) = bend_fixture(true);
let mut other = f.project.diagrams[&f.diagram].clone();
other.id = Id::new_v4();
other.alias = "bend_reference_view".into();
other.nodes = other
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("bend_reference_{}", node.alias);
(node.id, node)
})
.collect();
let other_id = other.id;
f.project.diagrams.insert(other_id, other.clone());
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let start = screen(&canvas, point(original.waypoints[0]));
let destination = Position { x: 690., y: 365. };
let release = screen(&canvas, point(destination));
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let preview = state.bend_preview.as_ref().unwrap();
let mut intended = original.clone();
intended.waypoints[0] = destination;
assert_eq!(preview, &intended);
assert_eq!(
f.project.diagrams[&f.diagram].routes[0], original,
"preview is transient"
);
let preview_scene = canvas.scene(&state).unwrap();
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
);
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
let before = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.project().diagrams[&other_id], other);
let committed_view = projection::project_view(document.project(), f.diagram);
let committed = routing::route_diagram(
document.project(),
&document.project().diagrams[&f.diagram],
&committed_view,
);
let preview_edge = &preview_scene.routed.edges[&relation];
let committed_edge = &committed.edges[&relation];
assert_eq!(preview_edge.points, committed_edge.points);
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
assert_eq!(preview_edge.warnings, committed_edge.warnings);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &f.project);
assert_eq!(document.bundle(), &before);
assert!(!document.undo(), "one drag adds one history operation");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited);
assert!(!document.redo());
}
}
#[test]
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
for authored in [false, true] {
for zoom in [0.5, 1., 2.] {
for travel in [false, true] {
let (f, relation) = bend_fixture(authored);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if authored {
point(scene.routed.edges[&relation].pins[0])
} else {
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
bend_press(&canvas, &mut state, start);
if travel {
let away = start + Vector::new(45., 30.) * zoom;
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_some());
motion(&canvas, &mut state, start, start);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
scene.routed.edges[&relation].points
);
assert_eq!(
f.project.diagrams[&f.diagram].routes.len(),
usize::from(authored)
);
if authored {
assert_eq!(
f.project.diagrams[&f.diagram].routes[0].waypoints,
vec![Position {
x: 650.25,
y: 340.75
}]
);
}
}
}
}
}
#[test]
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
for cancellation in 0..16 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let start = screen(&canvas, point(original.waypoints[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
assert!(state.bend_preview.is_some(), "case {cancellation}");
let mut release_bounds = bounds();
match cancellation {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.context.revision += 1,
4 => canvas.context.document = Id::new_v4(),
5 => canvas.tool = Tool::Transition,
6 => canvas.editable = false,
7 | 8 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancellation == 7 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
release,
);
}
9 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(release),
);
}
10 => {
input(
&canvas,
&mut state,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
release,
);
}
11 => {
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
}
12 => {
motion(&canvas, &mut state, Point::new(-100., -100.), release);
}
13 => release_bounds.x += 25.,
15 => canvas.selected = f.b,
_ => {
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let event = canvas::Program::update(
&canvas,
&mut state,
&escape,
bounds(),
mouse::Cursor::Available(release),
)
.and_then(|action| action.into_inner().0);
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
}
}
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
release_bounds,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"late release committed case {cancellation}"
);
assert!(
state.bend_preview.is_none(),
"stale preview survived case {cancellation}"
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
motion(&canvas, &mut state, pin, pin);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
if context == canvas.context && object == relation && anchor == pin)
);
let (index, midpoint) = bend_segment(&scene, relation);
let at = screen(&canvas, midpoint);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
);
}
#[test]
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
for internal in [false, true] {
let mut f = fixture();
let relation = if internal {
let id = f
.project
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
id
} else {
let id = f.project.relate(
RelationKind::StateTransition,
"Hidden child to standby",
f.leaf,
f.b,
);
let parent = placement(&f, f.a);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
id
};
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if internal {
assert!(!scene.routed.edges.contains_key(&relation));
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
internal_rows(&f.project, node)
.into_iter()
.find(|(r, _)| r.id == relation)
.unwrap()
.1
.center()
} else {
assert!(scene.routed.edges[&relation].custom_suspended);
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
motion(&canvas, &mut state, start, start);
assert!(matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
start
),
Some(Event::ContextMenu {
bend: None,
route_segment: None,
..
})
));
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(30., 20.);
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_none());
assert!(!matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
away
),
Some(Event::SetBends { .. })
));
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
for click_pin in [true, false] {
let (mut f, relation) = bend_fixture(true);
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let edge = &baseline.routed.edges[&relation];
let candidates: Vec<_> = edge.points.windows(2).enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
.collect();
let (_, insertion_at) = candidates
.iter()
.copied()
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
.expect("exposed segment before both authored pins");
let menu_segment = candidates
.iter()
.copied()
.find(|(_, p)| p.distance(insertion_at) > 30.)
.expect("a distinct committed segment for the menu control");
let start = screen(&canvas, insertion_at);
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(45., 30.);
motion(&canvas, &mut state, away, away);
let preview = state.bend_preview.as_ref().unwrap();
assert_eq!(preview.waypoints.len(), 3);
assert_eq!(
&preview.waypoints[1..],
original.waypoints.as_slice(),
"provisional insertion shifts both stored indices"
);
// With the left gesture still active, moving to the last stored pin also
// moves provisional pin0 there. The preview's first hit is therefore
// pin0, but a menu built after cancellation must identify stored pin1.
let menu_world = if click_pin {
point(original.waypoints[1])
} else {
menu_segment.1
};
let menu_window = screen(&canvas, menu_world);
motion(&canvas, &mut state, menu_window, menu_window);
assert!(state.bend_preview.is_some());
let menu = input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
menu_window,
)
.unwrap();
let Event::ContextMenu {
context,
object,
bend,
route_segment,
..
} = menu
else {
panic!("expected context menu after cancelling drag")
};
assert_eq!(context, canvas.context);
assert_eq!(object, relation);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
if click_pin {
assert_eq!(
bend,
Some(1),
"menu index must address the stored route, not the provisional route"
);
assert!(route_segment.is_none());
let remove = Event::RemoveBend {
context,
relation,
index: bend.unwrap(),
};
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
let after = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
.waypoints
.remove(1);
assert_eq!(
after, expected,
"remove menu action must delete exactly the addressed original pin"
);
} else {
assert!(
bend.is_none(),
"provisional pin under cursor must not leak into menu"
);
let (segment, at) = route_segment.expect("committed route segment remains actionable");
assert_eq!(segment, menu_segment.0);
assert!(point(at).distance(menu_world) < 0.001);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
menu_window
)
.is_none()
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
for change in 0..4 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_ne!(
displayed_preview.routed.edges[&relation].pins,
baseline.routed.edges[&relation].pins
);
let mut changed_bounds = bounds();
match change {
0 => canvas.zoom = 2.,
1 => canvas.pan.y += 25.,
2 => changed_bounds.x += 35.,
_ => changed_bounds.width -= 90.,
}
// No update event between camera/layout change and this draw-time read.
let shown = canvas
.scene_with_bounds(&state, Some(changed_bounds))
.unwrap();
assert_eq!(
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
"stale draw-time pins for case {change}"
);
assert_eq!(
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
"stale cached preview for case {change}"
);
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
changed_bounds,
);
assert!(!matches!(event, Some(Event::SetBends { .. })));
assert!(state.bend_preview.is_none());
}
}