Files
ARCADIA-Studio/crates/oa-desktop/src/app/state_multiselect_focus_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

375 lines
13 KiB
Rust

//! State selection layout and modifier admission on the mounted production App.
use super::*;
use crate::behavior::{
canvas_workspace::tests::{Mounted, key},
state_edit,
};
use crate::diagram_usage::Target;
use iced::advanced::widget::{Operation, operation};
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
struct Fixture {
app: App,
diagram: Id,
a: Id,
b: Id,
a_element: Id,
b_element: Id,
edge: Id,
action: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
.unwrap();
let a_element = p
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
.unwrap();
let b_element = p
.add_element(
Kind::State,
Layer::Oa,
&format!("Second state {}", "with a long primary name ".repeat(20)),
Some(region),
)
.unwrap();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
.unwrap();
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
let edge = p.relate(
RelationKind::StateTransition,
"Next state",
a_element,
b_element,
);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
let mut placements = vec![];
// All State elements, including machine/Region, are initially placed.
// A fixture already missing its containers would not test picker insertion.
for (element, x, y, width, height) in [
(machine, 0., 0., 800., 500.),
(region, 20., 70., 760., 410.),
(a_element, 60., 160., 230., 180.),
(b_element, 430., 160., 260., 180.),
] {
let node = p.place(diagram, element, Position { x, y });
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.size = oa_core::Size { width, height };
placements.push(node);
}
let a = placements[2];
let b = placements[3];
validate_candidate(&p).unwrap();
assert!(state_edit::unplaced(&p, diagram).is_empty());
let mut app = App::with_project(p);
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(machine);
app.behavior.diagram = crate::behavior::state_view::State {
diagram: Some(diagram),
fit: true,
..Default::default()
};
app.selected = diagram;
app.selected_node = None;
app.window_size = Size::new(1442., 932.);
app.refresh_draft();
app.reconcile_multiselect();
assert_eq!(app.state_context().unwrap().diagram, diagram);
Fixture {
app,
diagram,
a,
b,
a_element,
b_element,
edge,
action,
}
}
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
let geometry = mounted.geometry(app);
let (_, bounds, focused) = geometry
.focused
.into_iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("real State focus::Owner rectangle must be measurable");
(bounds, focused)
}
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
let state = &app.behavior.diagram;
(state.zoom, state.pan, state.fit, state.viewport)
}
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(app, task).await;
mounted.settle(app).await;
}
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
let bounds = owner(mounted, &f.app).0;
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
let state = &f.app.behavior.diagram;
Point::new(
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
)
}
fn modifiers(shift: bool) -> iced::Event {
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::default()
}))
}
struct FocusField(Rectangle);
impl Operation for FocusField {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&iced::advanced::widget::Id>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if bounds == self.0 {
state.focus();
} else {
state.unfocus();
}
}
}
#[tokio::test]
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
let baseline = f.app.document.snapshot();
let bounds = owner(&mut mounted, &f.app).0;
let fitted = camera(&f.app);
assert!(
fitted.2,
"keep Fit active to expose any resize-triggered camera jump"
);
assert!(fitted.3.is_some());
assert!(bounds.width > 400. && bounds.height > 200.);
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
for (target, expected) in [
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
(
Some(Target::Placement(f.b)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::Edge(f.edge)),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::Edge(f.edge),
],
),
(
Some(Target::Edge(f.edge)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::StateRow {
placement: f.a,
relation: f.action,
}),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::StateRow {
placement: f.a,
relation: f.action,
},
],
),
(None, vec![]),
] {
selection(&mut mounted, &mut f.app, target).await;
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"selection strip changed the measured State canvas rectangle"
);
assert_eq!(
camera(&f.app),
fitted,
"selection feedback silently refitted the camera"
);
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
let captured = f.app.multiselect_snapshot().unwrap();
let task = f
.app
.update(Message::DiagramSelection(multiselect::Event::Hide(
captured,
)));
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
.into_iter()
.collect();
assert_eq!(
missing,
std::collections::BTreeSet::from([f.a_element, f.b_element])
);
let mut expected = baseline.decoded.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.a);
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.b);
assert_eq!(f.app.project(), &expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"the first unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let hidden_source = f.app.document.bundle().clone();
let task = f.app.update(Message::Undo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"removing the unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let task = f.app.update(Message::Redo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &hidden_source);
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
assert_eq!(camera(&f.app), fitted);
}
#[tokio::test]
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
let field = mounted
.geometry(&f.app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("State Inspector field is mounted");
mounted.operate(&f.app, &mut FocusField(field));
assert!(!owner(&mut mounted, &f.app).1);
let before = f.app.document.snapshot();
mounted
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
.await;
assert!(
!owner(&mut mounted, &f.app).1,
"passive Shift must not acquire State canvas focus"
);
let at = node_point(&mut mounted, &f, f.b);
let messages = mounted.click(&mut f.app, at).await;
assert!(messages.iter().any(|message| matches!(message,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
)), "StateCanvas must map its typed selection event to the common App boundary");
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
assert_eq!(f.app.selected_node, None);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
#[tokio::test]
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
// Explicitly revoke focus rather than relying on incidental initial focus.
mounted.operate(
&f.app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
let before = f.app.document.snapshot();
let at = node_point(&mut mounted, &f, f.b);
f.app.busy = true;
mounted.settle(&mut f.app).await;
mounted
.batch(
&mut f.app,
&[modifiers(true), key(keyboard::key::Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut f.app, at).await;
assert!(!owner(&mut mounted, &f.app).1);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a)]
);
assert_eq!(f.app.document.bundle(), &before.bundle);
f.app.busy = false;
mounted.settle(&mut f.app).await;
assert!(!owner(&mut mounted, &f.app).1);
// No second modifier event: the passive suspended update must survive.
mounted.click(&mut f.app, at).await;
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.delete_pending);
}