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.
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user