WIP: save unverified diagram Delete candidate at user pause
This commit is contained in:
@@ -291,7 +291,7 @@ fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
|
||||
Message::BrowseGroup(crate::explorer::Group::Work),
|
||||
Message::Field("/name".into(), "Overwrite properties".into()),
|
||||
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
|
||||
Message::ConfirmDelete,
|
||||
Message::ConfirmDelete(0),
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Captured diagram removal and model-confirmation lifetimes.
|
||||
use super::*;
|
||||
use crate::{model_menu, route_edit::Context};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub context: Context,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub model: bool,
|
||||
}
|
||||
|
||||
pub struct Confirmation {
|
||||
pub snapshot: model_menu::Opened,
|
||||
pub selection: Id,
|
||||
pub placement: Option<Id>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
crate::behavior::canvas_workspace::keyboard_enabled(app)
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& !app.behavior.has_pending()
|
||||
&& app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline())
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn remove_from_diagram(&mut self, intent: Intent) {
|
||||
if !enabled(self) || self.connection.is_some() || self.selected_bend.is_some() {
|
||||
return;
|
||||
}
|
||||
let current = match self.page {
|
||||
Page::Diagram if self.architecture.as_ref().is_none_or(|s| !s.saved.placing) => {
|
||||
self.route_context()
|
||||
}
|
||||
Page::Behavior
|
||||
if self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select =>
|
||||
{
|
||||
self.state_context()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if current != Some(intent.context) || self.selected != intent.object {
|
||||
return;
|
||||
}
|
||||
let view = projection::project_view(self.project(), intent.context.diagram);
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if intent.placement != placement || intent.object == intent.context.diagram {
|
||||
return;
|
||||
}
|
||||
let visible = if let Some(id) = intent.placement {
|
||||
self.project().diagrams[&intent.context.diagram]
|
||||
.nodes
|
||||
.get(&id)
|
||||
.is_some_and(|node| node.element == intent.object)
|
||||
&& view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id && node.element == intent.object)
|
||||
} else {
|
||||
view.edges.iter().any(|edge| edge.id == intent.object)
|
||||
|| view.ports.iter().any(|port| port.element == intent.object)
|
||||
};
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
if !intent.model
|
||||
&& let Some(placement) = intent.placement
|
||||
{
|
||||
if self.execute(Command::Delete(placement)) {
|
||||
self.selected_node = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.selected_bend = None;
|
||||
self.status = "Hidden from this diagram · Model element preserved · Undo restores the placement".into();
|
||||
}
|
||||
} else {
|
||||
self.begin_model_delete(Some(intent.context));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_model_delete(&mut self, canvas: Option<Context>) {
|
||||
if self.selected == self.project().id || self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
let mut request = model_menu::Request::object(self.selected, iced::Point::ORIGIN);
|
||||
request.canvas = canvas;
|
||||
request.placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
// Opening a modal retires any pending canvas intent before capturing
|
||||
// the confirmation; every new dialog receives its own replay token.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if canvas.is_some() {
|
||||
request.canvas = if self.page == Page::Behavior {
|
||||
self.state_context()
|
||||
} else {
|
||||
self.route_context()
|
||||
};
|
||||
}
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target =
|
||||
model_menu::Opened::new(self, request, self.menu_sequence).map(|snapshot| {
|
||||
Confirmation {
|
||||
snapshot,
|
||||
selection: self.selected,
|
||||
placement: if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
},
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
|
||||
pub(super) fn confirm_model_delete(&mut self, token: u64) {
|
||||
let Some(target) = self.delete_target.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if target.snapshot.token != token {
|
||||
return;
|
||||
}
|
||||
let valid = self.delete_pending
|
||||
&& target.snapshot.valid(self)
|
||||
&& target.selection == self.selected
|
||||
&& target.placement
|
||||
== if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
}
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty();
|
||||
let id = target.snapshot.request.object;
|
||||
self.delete_pending = false;
|
||||
self.delete_target = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if valid {
|
||||
self.execute(Command::Delete(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
//! Real App tree regression tests; no private canvas focus or gesture seeding.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Event as Input, Point, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
object: Id,
|
||||
node: Id,
|
||||
other: Id,
|
||||
diagram: Id,
|
||||
copy: Id,
|
||||
}
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
};
|
||||
let object = p.add_element(kind, layer, "Target", None).unwrap();
|
||||
let other = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
},
|
||||
layer,
|
||||
"Other",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let node = p.place(diagram, object, Position { x: 80., y: 80. });
|
||||
p.place(diagram, other, Position { x: 500., y: 80. });
|
||||
let mut second = p.diagrams[&diagram].clone();
|
||||
second.id = Id::new_v4();
|
||||
second.name = "Other diagram".into();
|
||||
second.alias = "other_diagram".into();
|
||||
second.nodes.clear();
|
||||
second.routes.clear();
|
||||
let copy = second.id;
|
||||
p.diagrams.insert(copy, second);
|
||||
p.place(copy, object, Position { x: 70., y: 70. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
Fixture {
|
||||
app,
|
||||
object,
|
||||
node,
|
||||
other,
|
||||
diagram,
|
||||
copy,
|
||||
}
|
||||
}
|
||||
async fn mount(layer: Layer) -> (Fixture, Mounted) {
|
||||
let mut f = fixture(layer);
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
select(&mut mounted, &mut f).await;
|
||||
(f, mounted)
|
||||
}
|
||||
async fn select(mounted: &mut Mounted, f: &mut Fixture) {
|
||||
let bounds = mounted
|
||||
.geometry(&f.app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let node = projection::project_view(f.app.project(), f.diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|node| node.id == f.node)
|
||||
.unwrap();
|
||||
let point = Point::new(
|
||||
bounds.x + (node.position.x + 40.) * f.app.zoom + f.app.pan.x,
|
||||
bounds.y + (node.position.y + 35.) * f.app.zoom + f.app.pan.y,
|
||||
);
|
||||
mounted.click(&mut f.app, point).await;
|
||||
assert_eq!(f.app.selected, f.object);
|
||||
assert_eq!(f.app.selected_node, Some(f.node));
|
||||
}
|
||||
fn modified(named: keyboard::key::Named, modifiers: keyboard::Modifiers) -> Input {
|
||||
let mut input = key(named);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
modifiers: value, ..
|
||||
}) = &mut input
|
||||
{
|
||||
*value = modifiers;
|
||||
}
|
||||
input
|
||||
}
|
||||
async fn delete(mounted: &mut Mounted, app: &mut App, shift: bool) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[modified(
|
||||
keyboard::key::Named::Delete,
|
||||
if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
},
|
||||
)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
fn consistent(app: &App) {
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_oa_sa_delete_hides_exact_placement_and_round_trips_source_and_model() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, mut mounted) = mount(layer).await;
|
||||
let before = f.app.document.snapshot();
|
||||
let messages = delete(&mut mounted, &mut f.app, false).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramRemove(_)))
|
||||
);
|
||||
assert!(f.app.project().contains(f.object));
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.node)
|
||||
);
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.copy]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.element == f.object)
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
consistent(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let task = f.app.update(Message::Undo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo(), "one removal, one undo unit");
|
||||
let task = f.app.update(Message::Redo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.project(), &after.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
consistent(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_delete_captures_target_cancel_escape_and_confirmation_replay() {
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
let before = f.app.document.snapshot();
|
||||
delete(&mut mounted, &mut f.app, true).await;
|
||||
let target = f.app.delete_target.as_ref().unwrap();
|
||||
assert_eq!(target.snapshot.request.object, f.object);
|
||||
let stale_token = target.snapshot.token;
|
||||
let task = f.app.update(Message::CancelDelete);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
let task = f.app.update(Message::ConfirmDelete(stale_token));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
select(&mut mounted, &mut f).await;
|
||||
delete(&mut mounted, &mut f.app, true).await;
|
||||
let token = f.app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
let task = f.app.update(Message::ConfirmDelete(stale_token));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert!(
|
||||
f.app.delete_pending,
|
||||
"old dialog must not accept a new confirmation"
|
||||
);
|
||||
let task = f.app.update(Message::ConfirmDelete(token));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert!(!f.app.project().contains(f.object));
|
||||
assert!(f.app.project().contains(f.other));
|
||||
consistent(&f.app);
|
||||
let task = f.app.update(Message::Undo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
select(&mut mounted, &mut f).await;
|
||||
delete(&mut mounted, &mut f.app, true).await;
|
||||
let task = f.app.update(Message::Escape);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_clean_text_programmatic_focus_and_tab_remove_canvas_keyboard_ownership() {
|
||||
for programmatic in [true, false] {
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
let before = f.app.document.snapshot();
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
if programmatic {
|
||||
mounted.operate(&f.app, &mut FocusField(field));
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
key(keyboard::key::Named::Tab),
|
||||
key(keyboard::key::Named::Delete),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramRemove(_))),
|
||||
"Tab retires canvas admission before its queued focus operation runs"
|
||||
);
|
||||
}
|
||||
let geometry = mounted.geometry(&f.app);
|
||||
assert!(
|
||||
geometry
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| *bounds == field && *focused),
|
||||
"clean Inspector receives real focus"
|
||||
);
|
||||
assert!(!f.app.draft_dirty());
|
||||
assert!(
|
||||
delete(&mut mounted, &mut f.app, true)
|
||||
.await
|
||||
.iter()
|
||||
.all(|message| !matches!(message, Message::DiagramRemove(_)))
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Home)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let messages = delete(&mut mounted, &mut f.app, false).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::Field(path, _) if path == "/name"))
|
||||
);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "arget");
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(f.app.project().contains(f.node));
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_removal_intents_reject_stale_selection_revision_owner_and_document() {
|
||||
for boundary in 0..5 {
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
let messages = mounted.input(
|
||||
&f.app,
|
||||
&[key(keyboard::key::Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
let intent = messages
|
||||
.into_iter()
|
||||
.find(|message| matches!(message, Message::DiagramRemove(_)))
|
||||
.unwrap();
|
||||
match boundary {
|
||||
0 => {
|
||||
let _ = f.app.update(Message::Select(f.other));
|
||||
}
|
||||
1 => {
|
||||
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
2 => {
|
||||
f.app.active_diagram = Some(f.copy);
|
||||
}
|
||||
3 => {
|
||||
f.app.canvas_document = Id::new_v4();
|
||||
}
|
||||
_ => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Command(Command::Rename(f.other, "Renamed".into())));
|
||||
}
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let task = f.app.update(intent);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(f.app.project().contains(f.node));
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_window_modal_dirty_and_modifier_boundaries_retire_delete_focus() {
|
||||
for boundary in 0..5 {
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
let before = f.app.document.snapshot();
|
||||
match boundary {
|
||||
0 => {
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
1 => {
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Open(
|
||||
crate::model_menu::Request::object(f.object, Point::ORIGIN),
|
||||
)));
|
||||
mounted.settle(&mut f.app).await;
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
|
||||
}
|
||||
2 => {
|
||||
let _ = f.app.update(Message::Field("/name".into(), "Dirty".into()));
|
||||
}
|
||||
3 => {
|
||||
let _ = f.app.update(Message::Delete);
|
||||
}
|
||||
_ => {
|
||||
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
delete(&mut mounted, &mut f.app, false).await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(f.app.project().contains(f.node));
|
||||
}
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
let before = f.app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[modified(
|
||||
keyboard::key::Named::Delete,
|
||||
keyboard::Modifiers::CTRL,
|
||||
)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.delete_pending);
|
||||
// Refusal is temporary: a fresh live canvas click owns Delete again.
|
||||
select(&mut mounted, &mut f).await;
|
||||
delete(&mut mounted, &mut f.app, false).await;
|
||||
assert!(!f.app.project().contains(f.node));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let p = execute_batch(
|
||||
&p,
|
||||
&[Command::SetEdgeRoute {
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![Position { x: 400., y: -100. }],
|
||||
label: None,
|
||||
},
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(edge));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::new(100., 250.);
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let pin = Point::new(bounds.x + 500., bounds.y + 150.);
|
||||
mounted.click(&mut app, pin).await;
|
||||
assert_eq!(
|
||||
app.selected_bend,
|
||||
Some((edge, 0)),
|
||||
"real pointer selects route pin"
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let messages = delete(&mut mounted, &mut app, false).await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Route(crate::route_edit::Event::RemoveBend)
|
||||
)));
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramRemove(_)))
|
||||
);
|
||||
assert!(app.project().contains(edge) && app.project().contains(a) && app.project().contains(b));
|
||||
assert!(
|
||||
crate::route_edit::stored_route(&app.project().diagrams[&diagram], edge)
|
||||
.waypoints
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
consistent(&app);
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
// Select a real edge segment away from its handle, then Delete opens the
|
||||
// same semantic confirmation instead of inventing an edge hide operation.
|
||||
let view = projection::project_view(app.project(), diagram);
|
||||
let routed = routing::route_diagram(app.project(), &app.project().diagrams[&diagram], &view);
|
||||
let route = &routed.edges[&edge];
|
||||
let segment = route
|
||||
.points
|
||||
.windows(2)
|
||||
.max_by(|a, b| {
|
||||
let len = |s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
|
||||
len(a).total_cmp(&len(b))
|
||||
})
|
||||
.unwrap();
|
||||
let at = Point::new(
|
||||
bounds.x + (segment[0].x + segment[1].x) / 2. + app.pan.x,
|
||||
bounds.y + (segment[0].y + segment[1].y) / 2. + app.pan.y,
|
||||
);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(app.selected, edge);
|
||||
assert!(app.selected_bend.is_none());
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.request.object,
|
||||
edge
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_delete_uses_state_placement_and_preserves_architecture_placement() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = p
|
||||
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let architecture = p.ensure_diagram(Layer::Sa);
|
||||
let architecture_node = p.place(architecture, state, Position { x: 50., y: 50. });
|
||||
let plan = crate::behavior::state_edit::create_view(&p, machine).unwrap();
|
||||
let diagram = plan.diagram;
|
||||
let p = execute_batch(&p, &[plan.command]).unwrap();
|
||||
let node = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == state)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut app = App::with_project(p);
|
||||
app.layer = Layer::Sa;
|
||||
app.page = Page::Behavior;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram.diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(state));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.unwrap()
|
||||
.1;
|
||||
let projected = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|n| n.id == node)
|
||||
.unwrap();
|
||||
let at = Point::new(
|
||||
bounds.x
|
||||
+ (projected.position.x + 35.) * app.behavior.diagram.zoom
|
||||
+ app.behavior.diagram.pan.x,
|
||||
bounds.y
|
||||
+ (projected.position.y + 35.) * app.behavior.diagram.zoom
|
||||
+ app.behavior.diagram.pan.y,
|
||||
);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(app.selected, state);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(node));
|
||||
let before = app.document.snapshot();
|
||||
let messages = delete(&mut mounted, &mut app, false).await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::StateDiagram(crate::behavior::state_edit::Event::Remove(_))
|
||||
)));
|
||||
assert!(!app.project().contains(node));
|
||||
assert!(app.project().contains(architecture_node) && app.project().contains(state));
|
||||
consistent(&app);
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captured_confirmation_rejects_selection_document_revision_and_placement_changes() {
|
||||
for boundary in 0..5 {
|
||||
let (mut f, mut mounted) = mount(Layer::Oa).await;
|
||||
delete(&mut mounted, &mut f.app, true).await;
|
||||
let token = f.app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
// Model asynchronous adoption / retained stale UI using a changed App
|
||||
// state while preserving the old confirmation payload deliberately.
|
||||
match boundary {
|
||||
0 => {
|
||||
f.app.selected = f.other;
|
||||
}
|
||||
1 => {
|
||||
f.app.canvas_document = Id::new_v4();
|
||||
}
|
||||
2 => {
|
||||
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
3 => {
|
||||
f.app.selected_node = None;
|
||||
}
|
||||
_ => {
|
||||
assert!(
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.other, "New revision".into()))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let task = f.app.update(Message::ConfirmDelete(token));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(f.app.project().contains(f.object));
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_programmatic_canvas_entry_needs_no_mouse_focus_boolean() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let _ = f.app.update(Message::Select(f.object));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let task =
|
||||
iced::widget::operation::focus(iced::advanced::widget::Id::from("diagram-keyboard-owner"));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
let messages = delete(&mut mounted, &mut f.app, false).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramRemove(_)))
|
||||
);
|
||||
assert!(!f.app.project().contains(f.node));
|
||||
assert!(f.app.project().contains(f.object));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_allocation_work_click_then_delete_without_redraw_removes_placement_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let performer = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Performer",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
"Work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
p.place(diagram, performer, Position { x: 100., y: 100. });
|
||||
let node = p.place(diagram, work, Position { x: 100., y: 500. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let point = Point::new(bounds.x + 140., bounds.y + 535.);
|
||||
let messages = mounted.click(&mut app, point).await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::Press(_))
|
||||
)));
|
||||
assert_eq!(app.selected, work);
|
||||
assert_eq!(app.selected_node, Some(node));
|
||||
let before = app.document.snapshot();
|
||||
// Deliberately do not settle/redraw between completed click and key.
|
||||
let messages = delete(&mut mounted, &mut app, false).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::DiagramRemove(_))),
|
||||
"retired allocation press cannot consume first Delete"
|
||||
);
|
||||
assert!(!app.project().contains(node));
|
||||
assert!(app.project().contains(work) && app.project().contains(performer));
|
||||
consistent(&app);
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
|
||||
Reference in New Issue
Block a user