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.
375 lines
13 KiB
Rust
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);
|
|
}
|