diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index bf15e854..e38d2ad3 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -26,6 +26,7 @@ mod review_navigation_tests; mod scenario_details_tests; mod scenario_diagram; mod scenario_multiselect; +pub(crate) mod scenario_removal; pub(crate) mod scenario_reorder; mod scenario_spacing; #[cfg(test)] @@ -443,6 +444,7 @@ pub enum Message { StateDiagram(crate::behavior::state_edit::Event), ScenarioCanvas(crate::behavior::scenario::CanvasEvent), ScenarioReorder(Box), + ScenarioRemoval(Box), TopologyCanvas(crate::behavior::topology::CanvasEvent), PlaceSelected, HideSelected, @@ -1356,6 +1358,36 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // Group dialogs only accept their stamped Cancel button. A delayed + // singleton dialog packet cannot close a later aggregate review. + if matches!(message, Message::CancelDelete) + && self + .delete_target + .as_ref() + .is_some_and(|target| target.scenario.is_some()) + { + return Task::none(); + } + if let Message::ScenarioRemoval(event) = message { + return self.scenario_removal_event(*event); + } + if let Message::ConfirmDelete(token) = &message { + if self + .delete_target + .as_ref() + .is_none_or(|target| target.snapshot.token != *token) + { + return Task::none(); + } + if self + .delete_target + .as_ref() + .is_some_and(|target| target.scenario.is_some()) + { + self.confirm_scenario_removal(*token); + return Task::none(); + } + } // Captured reorder packets must be admitted before shared navigation, // camera or source boundaries can disturb a newer operation. if let Message::ScenarioReorder(event) = message { @@ -2225,6 +2257,7 @@ impl App { Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result), Message::Hosted(event) => return self.hosted_event(event), Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event), + Message::ScenarioRemoval(event) => return self.scenario_removal_event(*event), Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); @@ -2771,7 +2804,9 @@ impl App { || (menu.request.scenario.is_some() && matches!( *action, - Message::ScenarioCanvas(_) | Message::ScenarioReorder(_) + Message::ScenarioCanvas(_) + | Message::ScenarioReorder(_) + | Message::ScenarioRemoval(_) )) { return self.update(*action); diff --git a/crates/oa-desktop/src/app/diagram_removal.rs b/crates/oa-desktop/src/app/diagram_removal.rs index 02b4b456..4cc0334f 100644 --- a/crates/oa-desktop/src/app/diagram_removal.rs +++ b/crates/oa-desktop/src/app/diagram_removal.rs @@ -15,6 +15,7 @@ pub struct Confirmation { pub selection: Id, pub placement: Option, pub behavior_owner: Option, + pub(crate) scenario: Option, } pub fn enabled(app: &App) -> bool { @@ -209,6 +210,7 @@ impl App { selection: self.selected, placement: placement(self), behavior_owner: behavior_owner(self), + scenario: None, } }); self.delete_pending = self.delete_target.is_some(); diff --git a/crates/oa-desktop/src/app/scenario_removal.rs b/crates/oa-desktop/src/app/scenario_removal.rs new file mode 100644 index 00000000..588a206c --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_removal.rs @@ -0,0 +1,206 @@ +//! One captured Scenario selection, one impact review, one document transaction. +use super::*; +use crate::behavior::scenario::{self, group_removal, selection}; +use crate::diagram_selection::Snapshot; +use crate::diagram_usage::Target; + +#[cfg(test)] +mod tests; +pub(crate) mod view; + +#[derive(Debug, Clone)] +pub(crate) enum Event { + Begin { + context: scenario::Context, + snapshot: Snapshot, + }, + Query { + token: u64, + query: String, + }, + Page { + token: u64, + page: usize, + }, + Cancel { + token: u64, + }, +} + +pub(crate) struct Review { + pub(crate) context: scenario::Context, + pub(crate) snapshot: Snapshot, + pub(crate) plan: group_removal::Plan, + pub(crate) query: String, + pub(crate) page: usize, +} + +pub(crate) fn begin(app: &App) -> Option { + let context = app.scenario_context()?; + let snapshot = app.multiselect_snapshot()?; + (snapshot.targets().len() > 1).then_some(Message::ScenarioRemoval(Box::new(Event::Begin { + context, + snapshot, + }))) +} + +impl App { + pub(crate) fn scenario_removal_enabled(&self) -> bool { + self.scenario_reorder_enabled() && self.scenario_reorder.session.is_none() + } + + fn scenario_removal_matches(&self, context: scenario::Context, snapshot: &Snapshot) -> bool { + self.scenario_context() == Some(context) + && self + .diagram_selection + .as_ref() + .is_some_and(|selected| selected.matches(snapshot, selection::context(context))) + && snapshot + .targets() + .iter() + .all(|target| self.multiselect_target_visible(target)) + } + + pub(super) fn scenario_removal_event(&mut self, event: Event) -> Task { + match event { + Event::Begin { context, snapshot } => { + // Admission precedes every shared input/selection boundary. A + // stale packet must not prune the set or retire a newer editor. + if !self.scenario_removal_matches(context, &snapshot) + || !self.scenario_removal_enabled() + || snapshot.targets().len() < 2 + { + return Task::none(); + } + let plan = match group_removal::prepare( + self.project(), + context.scenario, + snapshot.targets(), + ) { + Ok(plan) => plan, + Err(reason) => { + self.status = reason; + return Task::none(); + } + }; + let Some(Target::ScenarioObject(primary)) = snapshot.primary() else { + return Task::none(); + }; + let primary = *primary; + self.scenario_reorder_cancel(); + self.source_interaction(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + let context = self.scenario_context().expect("admitted Scenario"); + let snapshot = self.multiselect_snapshot().expect("admitted selection"); + let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN); + request.scenario = Some(Box::new(context)); + request.selection_target = Some(Box::new(Target::ScenarioObject(primary))); + self.menu_sequence = self.menu_sequence.wrapping_add(1); + self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence) + .map(|opened| diagram_removal::Confirmation { + snapshot: opened, + selection: self.selected, + placement: None, + behavior_owner: Some(context.scenario), + scenario: Some(Review { + context, + snapshot, + plan, + query: String::new(), + page: 0, + }), + }); + self.delete_pending = self.delete_target.is_some(); + } + Event::Query { token, query } => { + if let Some(target) = self + .delete_target + .as_mut() + .filter(|target| target.snapshot.token == token) + && let Some(review) = target.scenario.as_mut() + { + review.query = query; + review.page = 0; + } + } + Event::Page { token, page } => { + if let Some(target) = self + .delete_target + .as_mut() + .filter(|target| target.snapshot.token == token) + && let Some(review) = target.scenario.as_mut() + { + review.page = page; + } + } + Event::Cancel { token } => { + if self.delete_target.as_ref().is_some_and(|target| { + target.snapshot.token == token && target.scenario.is_some() + }) { + self.delete_target = None; + self.delete_pending = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.status = "Removal cancelled. Model and selection unchanged.".into(); + } + } + } + Task::none() + } + + pub(crate) fn scenario_removal_review_valid( + &self, + target: &diagram_removal::Confirmation, + ) -> bool { + let Some(review) = &target.scenario else { + return false; + }; + self.delete_pending + && !self.busy + && target.snapshot.valid(self) + && self.scenario_removal_matches(review.context, &review.snapshot) + && self.selected == target.selection + && !self.source_dirty() + && !self.draft_dirty() + && !self.behavior.has_pending() + && !self.source_rename.open() + && self.scenario_reorder.session.is_none() + && self.behavior.scenario.spacing_token().is_none() + && self.behavior.scenario.canvas_tool(review.context.scenario) + == scenario::CanvasTool::Select + } + + pub(super) fn confirm_scenario_removal(&mut self, token: u64) { + let Some(target) = self + .delete_target + .as_ref() + .filter(|target| target.snapshot.token == token && target.scenario.is_some()) + else { + return; + }; + let valid = self.scenario_removal_review_valid(target); + let target = self.delete_target.take().expect("captured review"); + self.delete_pending = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + if !valid { + self.status = + "Removal review expired. Nothing was removed; review the current selection again." + .into(); + return; + } + let review = target.scenario.expect("Scenario review"); + match self.document.execute(review.plan.command) { + Ok(_) => { + self.selected = review.context.scenario; + self.selected_node = None; + self.behavior.active_owner = Some(review.context.scenario); + self.sync(); + self.reconcile_multiselect(); + self.status = format!( + "Removed {} selected Scenario objects in one change. Referenced definitions preserved. Undo restores the model.", + review.snapshot.targets().len() + ); + } + Err(error) => self.status = format!("Nothing removed: {error}"), + } + } +} diff --git a/crates/oa-desktop/src/app/scenario_removal/tests.rs b/crates/oa-desktop/src/app/scenario_removal/tests.rs new file mode 100644 index 00000000..c542f55a --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_removal/tests.rs @@ -0,0 +1,509 @@ +use super::*; +use crate::behavior::scenario::{CanvasEvent, direct_tests}; +use crate::behavior::scenario_layout::Representation; +use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout}; + +struct Fixture { + app: App, + owner: Id, + call: Id, + reply: Id, + tail: Id, + exchange: Id, +} + +fn setup(saved: bool) -> Fixture { + let f = direct_tests::fixture(); + let mut project = f.project; + let mut tail = project + .make_element( + Kind::Occurrence, + Layer::Oa, + "Unselected tail", + Some(f.owner), + ) + .unwrap(); + if let Some(SharedBody::Occurrence { body, .. }) = tail.shared_mut() { + *body = OccurrenceBody::Action { + action: None, + lifeline: None, + }; + } + let tail_id = tail.id; + project.elements.insert(tail_id, tail); + project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .push(tail_id); + let diagram = Id::new_v4(); + project.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: "removal_view".into(), + name: "Removal view".into(), + description: "Preserved authored view".into(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: f.owner }, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())), + }, + ); + let other = project + .make_element(Kind::Scenario, Layer::Oa, "Unrelated Scenario", None) + .unwrap(); + let other_owner = other.id; + project.elements.insert(other_owner, other); + let mut other_view = project.diagrams[&diagram].clone(); + other_view.id = Id::new_v4(); + other_view.alias = "unrelated_removal_view".into(); + other_view.name = "Unrelated saved view".into(); + other_view.projection = ProjectionKind::Scenario { + scenario: other_owner, + }; + project.diagrams.insert(other_view.id, other_view); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(if saved { + Message::Diagram(Choice { + id: diagram, + label: "Saved".into(), + }) + } else { + Message::Page(Page::Behavior) + }); + // Deliberately reverse semantic order; the primary is call, not the last step. + for (id, choice) in [ + (f.reply, multiselect::Choice::Replace), + (f.call, multiselect::Choice::Add), + ] { + let event = selection::Event { + context: app.scenario_context().unwrap(), + snapshot: app.multiselect_snapshot().unwrap(), + intent: selection::Intent::Choose(Target::ScenarioObject(id), choice), + }; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new( + event, + )))); + } + app.window_size = iced::Size::new(1600., 1000.); + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + iced::Size::new(900., 600.), + ))); + assert!(app.scenario_removal_enabled()); + Fixture { + app, + owner: f.owner, + call: f.call, + reply: f.reply, + tail: tail_id, + exchange: f.exchange, + } +} + +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::ScenarioRemoval(Box::new(event))); +} +fn captured(app: &App) -> Event { + Event::Begin { + context: app.scenario_context().unwrap(), + snapshot: app.multiselect_snapshot().unwrap(), + } +} +fn open(app: &mut App) -> u64 { + let event = captured(app); + send(app, event); + let target = app + .delete_target + .as_ref() + .expect("whole-selection review opened"); + assert!(app.delete_pending && target.scenario.is_some()); + assert!( + target.snapshot.valid(app), + "review captures refreshed modal epoch" + ); + target.snapshot.token +} + +#[test] +fn scenario_group_removal_is_one_exact_model_source_transaction_in_both_representations() { + for saved in [false, true] { + let Fixture { + mut app, + owner, + call, + reply, + tail, + exchange, + } = setup(saved); + let baseline = app.document.snapshot(); + let selected = app.multiselect_snapshot().unwrap(); + let camera = app.behavior.scenario.camera_boundary(); + let token = open(&mut app); + let review = app + .delete_target + .as_ref() + .unwrap() + .scenario + .as_ref() + .unwrap(); + assert_eq!(review.snapshot.targets(), selected.targets()); + assert_eq!(review.snapshot.primary(), selected.primary()); + assert_eq!(review.plan.removed, [call, reply].into_iter().collect()); + assert!(review.plan.changed.contains(&owner)); + assert!(!review.plan.removed.contains(&exchange)); + assert_eq!(app.document.bundle(), &baseline.bundle); + assert!(!app.document.can_undo()); + let _ = app.update(Message::ConfirmDelete(token)); + let mut expected = baseline.decoded.project.clone(); + expected.elements.remove(&call); + expected.elements.remove(&reply); + *expected + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = vec![tail]; + assert_eq!(app.project(), &expected); + assert!(app.project().contains(exchange)); + assert!(!app.delete_pending && app.delete_target.is_none()); + assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); + assert!(app.behavior.scenario.camera_boundary() == camera); + let removed = app.document.bundle().clone(); + assert_ne!(removed, baseline.bundle); + let reopened = Document::from_bundle(removed.clone(), Default::default()).unwrap(); + assert_eq!(reopened.project(), &expected); + let _ = app.update(Message::ConfirmDelete(token)); + assert_eq!(app.document.bundle(), &removed); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &baseline.decoded.project); + assert_eq!(app.document.bundle(), &baseline.bundle); + assert!( + !app.document.can_undo(), + "one Undo restores the entire group" + ); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &removed); + assert_eq!(app.project(), &expected); + } +} + +#[test] +fn scenario_group_removal_old_modal_packets_do_not_touch_the_new_review() { + let mut f = setup(true); + let old_begin = captured(&f.app); + let first = open(&mut f.app); + send(&mut f.app, Event::Cancel { token: first }); + let second = open(&mut f.app); + assert_ne!(first, second); + let epoch = f.app.canvas_epoch; + let selected = f.app.multiselect_snapshot().unwrap(); + let source = f.app.document.bundle().clone(); + for message in [ + Message::ScenarioRemoval(Box::new(old_begin)), + Message::ScenarioRemoval(Box::new(Event::Cancel { token: first })), + Message::ScenarioRemoval(Box::new(Event::Query { + token: first, + query: "stale".into(), + })), + Message::ScenarioRemoval(Box::new(Event::Page { + token: first, + page: 99, + })), + Message::ConfirmDelete(first), + Message::CancelDelete, + ] { + let _ = f.app.update(message); + let target = f.app.delete_target.as_ref().unwrap(); + assert_eq!(target.snapshot.token, second); + assert!(target.scenario.as_ref().unwrap().query.is_empty()); + assert_eq!(target.scenario.as_ref().unwrap().page, 0); + assert_eq!(f.app.canvas_epoch, epoch); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selected); + assert_eq!(f.app.document.bundle(), &source); + } + send( + &mut f.app, + Event::Query { + token: second, + query: "reply".into(), + }, + ); + send( + &mut f.app, + Event::Page { + token: second, + page: 1, + }, + ); + assert_eq!(f.app.canvas_epoch, epoch); + assert_eq!(f.app.document.bundle(), &source); + send(&mut f.app, Event::Cancel { token: second }); + assert!(!f.app.delete_pending); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + selected.primary() + ); + assert!(!f.app.document.can_undo()); +} + +#[test] +fn scenario_group_removal_confirm_expires_for_each_context_or_selection_change() { + for change in 0..5 { + let mut f = setup(true); + let token = open(&mut f.app); + match change { + 0 => f.app.canvas_document = Id::new_v4(), + 1 => { + let context = f.app.scenario_context().unwrap(); + let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + iced::Size::new(900., 560.), + ))); + assert_ne!(f.app.canvas_epoch, context.epoch); + } + 2 => { + f.app + .document + .execute(Command::Rename(f.owner, "New revision".into())) + .unwrap(); + } + 3 => { + let context = f.app.scenario_context().unwrap(); + f.app.behavior.scenario.enter_representation( + context.document, + f.owner, + Representation::Derived { owner: f.owner }, + ); + f.app.behavior.scenario.enter_representation( + context.document, + f.owner, + context.representation, + ); + assert_ne!(f.app.scenario_context().unwrap().visit, context.visit); + } + _ => { + let selected = f.app.diagram_selection.as_mut().unwrap(); + assert!(selected.set_primary(&Target::ScenarioObject(f.reply))); + assert!(selected.set_primary(&Target::ScenarioObject(f.call))); + } + } + let current = f.app.document.snapshot(); + let _ = f.app.update(Message::ConfirmDelete(token)); + assert_eq!(f.app.project(), ¤t.decoded.project); + assert_eq!(f.app.document.bundle(), ¤t.bundle); + assert!(!f.app.delete_pending && f.app.delete_target.is_none()); + assert!(f.app.status.contains("expired")); + } +} + +#[test] +fn scenario_group_removal_cancel_preserves_existing_redo_and_full_selection() { + let mut f = setup(true); + let _ = f.app.update(Message::Command(Command::Rename( + f.tail, + "Redo remains available".into(), + ))); + let renamed = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert!(f.app.document.can_redo()); + let selected = f.app.multiselect_snapshot().unwrap(); + let source = f.app.document.bundle().clone(); + let token = open(&mut f.app); + send(&mut f.app, Event::Cancel { token }); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + selected.primary() + ); + assert!(f.app.document.can_redo() && !f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.document.bundle(), &renamed); +} + +#[test] +fn scenario_group_removal_does_not_retire_active_reorder_or_spacing() { + for spacing in [false, true] { + let mut f = setup(true); + if spacing { + let event = crate::app::scenario_spacing::tests::packet_for_app( + &f.app, + scenario::spacing::Target::Message(f.call), + ); + let _ = f + .app + .update(Message::ScenarioCanvas(CanvasEvent::Spacing(event))); + assert!(f.app.behavior.scenario.spacing_token().is_some()); + } else { + let event = crate::app::scenario_reorder::Event { + context: f.app.scenario_context().unwrap(), + snapshot: f.app.multiselect_snapshot().unwrap(), + token: Id::new_v4(), + intent: crate::app::scenario_reorder::Intent::Begin, + }; + let _ = f.app.update(Message::ScenarioReorder(Box::new(event))); + assert!(f.app.scenario_reorder.session.is_some()); + } + let epoch = f.app.canvas_epoch; + let spacing_token = f.app.behavior.scenario.spacing_token(); + let reorder_token = f + .app + .scenario_reorder + .session + .as_ref() + .map(|session| session.token()); + let source = f.app.document.bundle().clone(); + let event = captured(&f.app); + send(&mut f.app, event); + assert!(f.app.delete_target.is_none()); + assert_eq!(f.app.canvas_epoch, epoch); + assert_eq!(f.app.behavior.scenario.spacing_token(), spacing_token); + assert_eq!( + f.app + .scenario_reorder + .session + .as_ref() + .map(|session| session.token()), + reorder_token + ); + assert_eq!(f.app.document.bundle(), &source); + } +} + +#[test] +fn scenario_group_removal_menu_does_not_select_its_clicked_subject() { + for unselected in [false, true] { + let mut f = setup(true); + let selection = f.app.multiselect_snapshot().unwrap(); + let clicked = if unselected { f.tail } else { f.reply }; + let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN); + request.scenario = f.app.scenario_context().map(Box::new); + request.selection_target = Some(Box::new(Target::ScenarioObject(clicked))); + let _ = f + .app + .update(Message::Menu(model_menu::Event::Open(request))); + let menu = f.app.model_menu.as_ref().expect("Scenario menu"); + let token = menu.token; + let action = begin(&f.app).expect("captured group menu action"); + let _ = f.app.update(Message::Menu(model_menu::Event::Invoke( + token, + Box::new(action), + ))); + let review = f + .app + .delete_target + .as_ref() + .unwrap() + .scenario + .as_ref() + .unwrap(); + assert_eq!(review.snapshot.targets(), selection.targets()); + assert_eq!(review.snapshot.primary(), selection.primary()); + assert_eq!(f.app.selected, f.call); + assert!(!review.plan.removed.contains(&f.tail)); + assert!(f.app.project().contains(clicked)); + } +} + +#[test] +fn scenario_group_removal_rejects_dirty_properties_and_legacy_singleton_messages() { + let mut f = setup(false); + let baseline = f.app.document.bundle().clone(); + let selected = f.app.multiselect_snapshot().unwrap(); + let epoch = f.app.canvas_epoch; + let _ = f.app.update(Message::Delete); + let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Remove( + f.app.scenario_context().unwrap(), + f.call, + ))); + assert!(f.app.delete_target.is_none()); + assert_eq!(f.app.canvas_epoch, epoch); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selected); + let _ = f.app.update(Message::Field( + "/name".into(), + "Unapplied group primary".into(), + )); + assert!(f.app.draft_dirty()); + let event = captured(&f.app); + let epoch = f.app.canvas_epoch; + send(&mut f.app, event); + assert!(f.app.delete_target.is_none() && f.app.draft_dirty()); + assert_eq!(f.app.canvas_epoch, epoch); + assert_eq!(f.app.document.bundle(), &baseline); + assert!(!f.app.document.can_undo()); +} + +#[tokio::test] +async fn mounted_scenario_group_delete_captures_whole_set_and_repeat_does_not_confirm() { + use crate::behavior::canvas_workspace::tests::{Mounted, key}; + use iced::{ + keyboard::{self, key::Named}, + mouse, + }; + for shift in [false, true] { + let mut f = setup(true); + let mut mounted = Mounted::new(&mut f.app).await; + mounted + .batch(&mut f.app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + let selected = f.app.multiselect_snapshot().unwrap(); + let source = f.app.document.bundle().clone(); + let mut remove = key(Named::Delete); + if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove { + *modifiers = if shift { + keyboard::Modifiers::SHIFT + } else { + keyboard::Modifiers::default() + }; + } + let output = mounted + .batch(&mut f.app, &[remove.clone()], mouse::Cursor::Unavailable) + .await; + assert!(output.iter().any(|message| matches!(message, Message::ScenarioRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets()==selected.targets() && snapshot.primary()==selected.primary()))), "actual canvas Delete emits full captured group: {output:?}"); + assert!(!output.iter().any(|message| matches!( + message, + Message::Delete | Message::ScenarioCanvas(CanvasEvent::Remove(..)) + ))); + let token = f + .app + .delete_target + .as_ref() + .expect("mounted group review") + .snapshot + .token; + if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove { + *repeat = true; + } + let output = mounted + .batch(&mut f.app, &[remove], mouse::Cursor::Unavailable) + .await; + assert!( + !output + .iter() + .any(|message| matches!(message, Message::ConfirmDelete(_))) + ); + assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, token); + assert_eq!(f.app.document.bundle(), &source); + send(&mut f.app, Event::Cancel { token }); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert!(!f.app.document.can_undo()); + } +} diff --git a/crates/oa-desktop/src/app/scenario_removal/view.rs b/crates/oa-desktop/src/app/scenario_removal/view.rs new file mode 100644 index 00000000..ee224843 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_removal/view.rs @@ -0,0 +1,155 @@ +//! Review the complete captured removal impact before one explicit confirmation. +use super::{Event, Review}; +use crate::app::{App, Message, diagram_removal}; +use crate::diagram_usage::Target; +use crate::style; +use iced::widget::{button, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length::Fill}; +use oa_core::{Id, Project}; +use std::collections::BTreeMap; + +const PAGE_SIZE: usize = 6; + +struct ImpactRow { + label: String, + disposition: &'static str, +} + +fn matching_rows(project: &Project, review: &Review) -> Vec { + let aliases: BTreeMap = project.identities().into_iter().collect(); + let query = review.query.trim().to_lowercase(); + review + .plan + .removed + .iter() + .map(|id| { + let selected = review + .snapshot + .targets() + .contains(&Target::ScenarioObject(*id)); + ( + *id, + if selected { + "Removed · selected" + } else { + "Additional removed" + }, + ) + }) + .chain( + review + .plan + .changed + .iter() + .map(|id| (*id, "Updated · preserved")), + ) + .filter_map(|(id, disposition)| { + let name = project.named(id); + let identity = aliases + .get(&id) + .map_or_else(|| id.to_string(), |alias| format!("@{alias}")); + let label = if name == "Missing object" || name.is_empty() { + identity + } else { + format!("{name} ({identity})") + }; + (query.is_empty() + || label.to_lowercase().contains(&query) + || id.to_string().contains(&query)) + .then_some(ImpactRow { label, disposition }) + }) + .collect() +} + +pub(crate) fn view<'a>( + app: &'a App, + target: &'a diagram_removal::Confirmation, +) -> Element<'a, Message> { + let Some(review) = target.scenario.as_ref() else { + return text("Scenario removal review is unavailable.").into(); + }; + let token = target.snapshot.token; + let valid = app.scenario_removal_review_valid(target); + let rows = matching_rows(app.project(), review); + let pages = rows.len().div_ceil(PAGE_SIZE).max(1); + let page = review.page.min(pages - 1); + let mut impacts = column![].spacing(12).width(Fill); + for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) { + impacts = impacts.push( + column![ + text(impact.disposition).size(12).color(style::MUTED), + text(impact.label.clone()) + .size(13) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(3) + .width(Fill), + ); + } + if rows.is_empty() { + impacts = impacts.push(text("No matching affected records. Try a name or alias.").size(13)); + } + let previous = button("Previous").on_press_maybe((page > 0).then(|| { + Message::ScenarioRemoval(Box::new(Event::Page { + token, + page: page - 1, + })) + })); + let next = button("Next").on_press_maybe((page + 1 < pages).then(|| { + Message::ScenarioRemoval(Box::new(Event::Page { + token, + page: page + 1, + })) + })); + let mut footer = column![].spacing(8).width(Fill); + if !valid { + footer = footer.push( + text("This review expired. Cancel and review the current selection again.") + .size(13) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + footer = footer.push( + row![ + container( + button("Cancel") + .padding(10) + .on_press(Message::ScenarioRemoval(Box::new(Event::Cancel { token }))) + ) + .id("delete-cancel"), + container( + button("Remove from Scenario") + .style(button::danger) + .padding(10) + .on_press_maybe(valid.then_some(Message::ConfirmDelete(token))) + ) + .id("delete-confirm"), + ] + .spacing(10), + ); + column![ + text(format!("Remove {} Scenario objects?", review.snapshot.targets().len())).size(21), + text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14), + text_input("Search affected names or aliases", &review.query) + .id("scenario-removal-query") + .on_input(move |query| Message::ScenarioRemoval(Box::new(Event::Query { token, query }))) + .size(13), + row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next] + .spacing(10).align_y(iced::Alignment::Center), + scrollable(column![ + text("Removes local occurrences or participants and their owned descendants. Affected references are cleared; reusable definitions are preserved. Updated records include owning sequences and affected views. One Undo restores the entire change.") + .size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph), + impacts, + ].spacing(14).width(Fill)) + .width(Fill).height(Fill).spacing(8).id("scenario-removal-impact"), + footer, + ] + .spacing(12) + .width(Fill) + // At the reviewed 732px window height, 570px content plus the modal's + // 52px padding leaves 110px outside the card. Only the impact body flexes. + .height((app.window_size.height - 160.).clamp(260., 570.)) + .into() +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index d6c1793e..1390d329 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -2144,11 +2144,21 @@ impl canvas::Program for Scene { && tool == CanvasTool::Select && (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT) && state.press.is_none() + && state.group_press.is_none() && state.source.is_none() => { let context = context?; if self.is_group() { - return self.selection_action(Intent::Refuse("Removing multiple Scenario objects is being implemented; select one occurrence to remove it".into())); + let (_, snapshot) = self.selection.as_ref()?; + return Some( + Action::publish(Message::ScenarioRemoval(Box::new( + crate::app::scenario_removal::Event::Begin { + context, + snapshot: snapshot.clone(), + }, + ))) + .and_capture(), + ); } if self.removal_target(context.scenario, self.selected) { return Some( diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 21c1dbf4..2c40a497 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -6,6 +6,7 @@ use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; mod camera; pub(crate) mod focus; +pub(crate) mod group_removal; pub(crate) mod group_reorder; pub(crate) mod reorder_session; #[cfg(test)] diff --git a/crates/oa-desktop/src/behavior/scenario/group_removal.rs b/crates/oa-desktop/src/behavior/scenario/group_removal.rs new file mode 100644 index 00000000..1635b7d0 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/group_removal.rs @@ -0,0 +1,182 @@ +//! Whole-set local Scenario removal. UI visibility and input lifetime admission +//! belong to the caller; this planner validates structural identity and semantics. +use crate::diagram_usage::Target; +use oa_core::{Command, Id, Kind, Layer, OccurrenceBody, Project, SharedBody, execute_batch}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug)] +pub(crate) struct Plan { + pub(crate) command: Command, + /// Actual deleted identities, including owned descendants and dependents. + pub(crate) removed: BTreeSet, + /// Surviving records changed by reference clearing, sequence edits or view + /// pruning. Embedded surviving placements are included alongside diagrams. + pub(crate) changed: BTreeSet, +} + +fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> { + match project.elements.get(&id)?.shared()? { + SharedBody::Occurrence { body, .. } + if matches!( + body, + OccurrenceBody::Exchange { .. } + | OccurrenceBody::Action { .. } + | OccurrenceBody::State { .. } + | OccurrenceBody::ScenarioUse { .. } + | OccurrenceBody::Fragment { .. } + ) => + { + Some(body) + } + _ => None, + } +} + +/// Follow the actual ordered/operand lists, not only parent pointers. Check the +/// complete local tree so malformed siblings cannot become a permissive subset. +fn local_occurrences(project: &Project, owner: Id, layer: Layer) -> Result, String> { + let mut reachable = BTreeSet::new(); + let mut sequences = vec![owner]; + while let Some(parent) = sequences.pop() { + let steps = project.elements[&parent] + .steps() + .ok_or("The Scenario sequence is no longer available.")?; + for &id in steps { + let element = project + .elements + .get(&id) + .ok_or("A Scenario step is missing.")?; + let body = + step_body(project, id).ok_or("The Scenario contains an unsupported step.")?; + if element.parent != Some(parent) || element.layer() != layer || !reachable.insert(id) { + return Err("The Scenario lists must contain unique local occurrences of their actual parents.".into()); + } + if let OccurrenceBody::Fragment { branches, .. } = body { + for &branch in branches { + let element = project + .elements + .get(&branch) + .ok_or("A Scenario operand is missing.")?; + if element.parent != Some(id) + || element.layer() != layer + || !matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ) + || !reachable.insert(branch) + { + return Err("The Scenario fragment must list each actual local operand exactly once.".into()); + } + sequences.push(branch); + } + } + } + } + Ok(reachable) +} + +fn changed_records( + before: &BTreeMap, + after: &BTreeMap, + changed: &mut BTreeSet, +) { + changed.extend( + before + .iter() + .filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)), + ); +} + +pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result { + let owner_element = project + .elements + .get(&owner) + .filter(|element| element.kind() == Kind::Scenario) + .ok_or("This Scenario no longer exists.")?; + let mut selected = BTreeSet::new(); + for target in targets { + let Target::ScenarioObject(id) = target else { + return Err("Remove only local Scenario participants and occurrences together.".into()); + }; + if !selected.insert(*id) { + return Err("Every selected Scenario subject must be unique.".into()); + } + } + if selected.is_empty() { + return Err("Select at least one Scenario participant or occurrence to remove.".into()); + } + oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?; + let reachable = local_occurrences(project, owner, owner_element.layer())?; + for &id in &selected { + let element = project + .elements + .get(&id) + .ok_or("A selected Scenario subject no longer exists.")?; + let local = if element.kind() == Kind::Lifeline { + element.parent == Some(owner) && element.layer() == owner_element.layer() + } else { + reachable.contains(&id) + }; + if !local { + return Err("Every selected subject must be a local participant or a listed occurrence/operand in this Scenario.".into()); + } + } + // Admission above includes every original member. An admitted selected + // descendant is covered by its selected ancestor's core cascade, not dropped + // as an ineligible target. Sending both Delete commands would delete it twice. + let roots = selected.iter().copied().filter(|id| { + let mut parent = project.elements[id].parent; + while let Some(id) = parent { + if selected.contains(&id) { + return false; + } + parent = project.elements.get(&id).and_then(|element| element.parent); + } + true + }); + let command = Command::Batch(roots.map(Command::Delete).collect()); + // One candidate and one final semantic validation, with no intermediate + // Document transactions or singleton validation that can reject a valid union. + let candidate = execute_batch(project, std::slice::from_ref(&command)) + .map_err(|error| error.to_string())?; + let retained: BTreeSet<_> = candidate + .identities() + .into_iter() + .map(|(id, _)| id) + .collect(); + let removed: BTreeSet<_> = project + .identities() + .into_iter() + .map(|(id, _)| id) + .filter(|id| !retained.contains(id)) + .collect(); + if !selected.is_subset(&removed) { + return Err("The complete selected set could not be removed.".into()); + } + let mut changed = BTreeSet::new(); + changed_records(&project.elements, &candidate.elements, &mut changed); + changed_records(&project.relations, &candidate.relations, &mut changed); + changed_records(&project.diagrams, &candidate.diagrams, &mut changed); + changed_records( + &project.artifact_links, + &candidate.artifact_links, + &mut changed, + ); + changed_records(&project.traces, &candidate.traces, &mut changed); + for (&id, diagram) in &project.diagrams { + if let Some(after) = candidate.diagrams.get(&id) { + changed_records(&diagram.nodes, &after.nodes, &mut changed); + } + } + Ok(Plan { + command, + removed, + changed, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/scenario/group_removal/tests.rs b/crates/oa-desktop/src/behavior/scenario/group_removal/tests.rs new file mode 100644 index 00000000..27f5898d --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/group_removal/tests.rs @@ -0,0 +1,824 @@ +use super::*; +use crate::{behavior::topology::occurrence, document::Document}; +use oa_core::{ + ArtifactLink, ArtifactRelation, Diagram, DiagramLayout, FragmentOperator, LifelineBinding, + MessageKind, OccurrenceEndpoint, Position, ProjectionKind, ScenarioLanePosition, + ScenarioLayout, ScenarioMessagePosition, Trace, TraceKind, language, +}; + +struct Fixture { + project: Project, + owner: Id, + work: [Id; 2], + lanes: [Id; 2], + messages: [Id; 2], + views: [Id; 2], +} + +fn add(project: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id { + let element = occurrence(project, parent, name, body); + let id = element.id; + project.elements.insert(id, element); + project + .elements + .get_mut(&parent) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +fn body(project: &mut Project, id: Id) -> &mut OccurrenceBody { + let SharedBody::Occurrence { body, .. } = + project.elements.get_mut(&id).unwrap().shared_mut().unwrap() + else { + panic!("occurrence") + }; + body +} + +fn action(project: &mut Project, parent: Id, name: &str) -> Id { + add( + project, + parent, + name, + OccurrenceBody::Action { + action: None, + lifeline: None, + }, + ) +} + +fn operand(project: &mut Project, fragment: Id, name: &str) -> Id { + let element = occurrence( + project, + fragment, + name, + OccurrenceBody::Branch { + guard: "ready".into(), + occurrences: vec![], + }, + ); + let id = element.id; + project.elements.insert(id, element); + let OccurrenceBody::Fragment { branches, .. } = body(project, fragment) else { + panic!() + }; + branches.push(id); + id +} + +fn fixture(layer: Layer) -> Fixture { + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, layer, "Conversation", None) + .unwrap(); + let work = ["Source work", "Target work"].map(|name| { + project + .add_element( + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + name, + None, + ) + .unwrap() + }); + let lanes = ["Left", "Right"].map(|name| { + project + .add_element(Kind::Lifeline, layer, name, Some(owner)) + .unwrap() + }); + for (lane, work) in lanes.into_iter().zip(work) { + *project + .elements + .get_mut(&lane) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(work), + }; + } + let messages = ["First", "Second"].map(|name| { + add( + &mut project, + owner, + name, + OccurrenceBody::Exchange { + exchange: None, + sender: Some(lanes[0]), + receiver: Some(lanes[1]), + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ) + }); + let views = ["Saved A", "Saved B"].map(|name| { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: project.fresh_alias(name, "view", id), + name: name.into(), + description: "Keep authored metadata".into(), + layer, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ + ScenarioLanePosition { + lifeline: lanes[0], + x: 140., + }, + ScenarioLanePosition { + lifeline: lanes[1], + x: 540., + }, + ], + messages: messages + .iter() + .enumerate() + .map(|(index, &occurrence)| ScenarioMessagePosition { + address: OccurrenceEndpoint { + occurrence, + references: vec![], + }, + send_y: 220. + index as f32 * 160., + }) + .collect(), + })), + }, + ); + id + }); + oa_core::session::validate_candidate(&project).unwrap(); + Fixture { + project, + owner, + work, + lanes, + messages, + views, + } +} + +fn selected(ids: &[Id]) -> Vec { + ids.iter().copied().map(Target::ScenarioObject).collect() +} + +fn layout(project: &mut Project, diagram: Id) -> &mut ScenarioLayout { + let Some(DiagramLayout::Scenario(layout)) = + &mut project.diagrams.get_mut(&diagram).unwrap().layout + else { + panic!() + }; + layout +} + +fn exact_transaction(project: &Project, plan: Plan, expected: &Project) { + assert!(matches!(plan.command, Command::Batch(_))); + assert_eq!( + &execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(), + expected + ); + let mut document = Document::from_project(project.clone(), Default::default()).unwrap(); + let original = document.bundle().clone(); + document.execute(plan.command).unwrap(); + assert_eq!(document.project(), expected); + let changed = document.bundle().clone(); + assert_eq!( + language::parse(&language::format(expected)).unwrap(), + *expected + ); + assert_eq!( + Document::from_bundle(changed.clone(), Default::default()) + .unwrap() + .project(), + expected + ); + assert!(document.undo()); + assert_eq!(document.project(), project); + assert_eq!(document.bundle(), &original); + assert!(!document.can_undo()); + assert!(document.redo()); + assert_eq!(document.project(), expected); + assert_eq!(document.bundle(), &changed); + assert!(!document.can_redo()); +} + +#[test] +fn whole_messages_in_both_layers_preserve_definitions_and_exact_source_history() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + let baseline = f.project.clone(); + let plan = prepare(&f.project, f.owner, &selected(&f.messages)).unwrap(); + let reversed = prepare( + &f.project, + f.owner, + &selected(&[f.messages[1], f.messages[0]]), + ) + .unwrap(); + assert_eq!(plan.command, reversed.command); + assert_eq!(plan.removed, f.messages.into_iter().collect()); + assert_eq!( + plan.changed, + [f.owner, f.views[0], f.views[1]].into_iter().collect() + ); + let mut expected = f.project.clone(); + for id in f.messages { + expected.elements.remove(&id); + } + expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + for view in f.views { + layout(&mut expected, view).messages.clear(); + } + for id in f.work { + assert_eq!(expected.elements[&id], f.project.elements[&id]); + } + assert_eq!(f.project, baseline, "preparation is pure"); + exact_transaction(&f.project, plan, &expected); + } +} + +#[test] +fn mixed_participant_and_message_union_clears_surviving_references_and_prunes_both_views() { + let mut f = fixture(Layer::Oa); + let local_action = add( + &mut f.project, + f.owner, + "Local action", + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: Some(f.lanes[0]), + }, + ); + let observation = add( + &mut f.project, + f.owner, + "Observation", + OccurrenceBody::State { + state: None, + lifeline: Some(f.lanes[0]), + }, + ); + let definition = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Reused definition", None) + .unwrap(); + let remote = f + .project + .add_element( + Kind::Lifeline, + Layer::Oa, + "Remote participant", + Some(definition), + ) + .unwrap(); + *f.project + .elements + .get_mut(&remote) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(f.work[0]), + }; + let use_id = add( + &mut f.project, + f.owner, + "Use definition", + OccurrenceBody::ScenarioUse { + scenario: Some(definition), + bindings: vec![LifelineBinding { + referenced: remote, + local: f.lanes[0], + }], + }, + ); + oa_core::session::validate_candidate(&f.project).unwrap(); + let plan = prepare(&f.project, f.owner, &selected(&[f.lanes[0], f.messages[0]])).unwrap(); + let mut expected = f.project.clone(); + expected.elements.remove(&f.lanes[0]); + expected.elements.remove(&f.messages[0]); + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[1], local_action, observation, use_id]; + let OccurrenceBody::Exchange { sender, .. } = body(&mut expected, f.messages[1]) else { + panic!() + }; + *sender = None; + let OccurrenceBody::Action { lifeline, .. } = body(&mut expected, local_action) else { + panic!() + }; + *lifeline = None; + let OccurrenceBody::State { lifeline, .. } = body(&mut expected, observation) else { + panic!() + }; + *lifeline = None; + let OccurrenceBody::ScenarioUse { bindings, .. } = body(&mut expected, use_id) else { + panic!() + }; + bindings.clear(); + for view in f.views { + let rows = layout(&mut expected, view); + rows.lanes.remove(0); + rows.messages.remove(0); + } + assert_eq!( + plan.removed, + [f.lanes[0], f.messages[0]].into_iter().collect() + ); + assert_eq!( + plan.changed, + [ + f.owner, + f.messages[1], + local_action, + observation, + use_id, + f.views[0], + f.views[1] + ] + .into_iter() + .collect() + ); + assert_eq!( + expected.elements[&definition], + f.project.elements[&definition] + ); + assert_eq!(expected.elements[&remote], f.project.elements[&remote]); + exact_transaction(&f.project, plan, &expected); +} + +#[test] +fn call_removal_keeps_reply_as_warning_admitted_draft_and_reports_its_changed_identity() { + let mut f = fixture(Layer::Oa); + let OccurrenceBody::Exchange { message_kind, .. } = body(&mut f.project, f.messages[0]) else { + panic!() + }; + *message_kind = MessageKind::Call; + let OccurrenceBody::Exchange { + sender, + receiver, + message_kind, + reply_to, + .. + } = body(&mut f.project, f.messages[1]) + else { + panic!() + }; + *sender = Some(f.lanes[1]); + *receiver = Some(f.lanes[0]); + *message_kind = MessageKind::Reply; + *reply_to = Some(f.messages[0]); + oa_core::session::validate_candidate(&f.project).unwrap(); + let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0], f.lanes[1]])).unwrap(); + let mut expected = f.project.clone(); + expected.elements.remove(&f.messages[0]); + expected.elements.remove(&f.lanes[1]); + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[1]]; + let OccurrenceBody::Exchange { + sender, reply_to, .. + } = body(&mut expected, f.messages[1]) + else { + panic!() + }; + *sender = None; + *reply_to = None; + for view in f.views { + let rows = layout(&mut expected, view); + rows.lanes.remove(1); + rows.messages.remove(0); + } + assert_eq!( + plan.removed, + [f.messages[0], f.lanes[1]].into_iter().collect() + ); + assert_eq!( + plan.changed, + [f.owner, f.messages[1], f.views[0], f.views[1]] + .into_iter() + .collect() + ); + assert!( + oa_core::validation::validate(&expected) + .iter() + .any(|issue| issue.code == "missing_reply_call" + && issue.severity == oa_core::validation::Severity::Warning) + ); + exact_transaction(&f.project, plan, &expected); +} + +fn nested(f: &mut Fixture) -> (Id, [Id; 2], [Id; 2]) { + let fragment = add( + &mut f.project, + f.owner, + "Alternatives", + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + ); + let branches = [ + operand(&mut f.project, fragment, "Left operand"), + operand(&mut f.project, fragment, "Right operand"), + ]; + let children = [ + action(&mut f.project, branches[0], "Left action"), + action(&mut f.project, branches[1], "Right action"), + ]; + (fragment, branches, children) +} + +#[test] +fn selected_ancestor_covers_all_descendants_after_admission_and_executes_once() { + let mut f = fixture(Layer::Oa); + let (fragment, branches, children) = nested(&mut f); + oa_core::session::validate_candidate(&f.project).unwrap(); + assert!( + execute_batch( + &f.project, + &[Command::Delete(fragment), Command::Delete(children[0])] + ) + .is_err(), + "naive cascading singleton deletes are order-dependent" + ); + let members = [children[0], f.messages[1], fragment, branches[0]]; + let plan = prepare(&f.project, f.owner, &selected(&members)).unwrap(); + let roots: BTreeSet<_> = [fragment, f.messages[1]].into_iter().collect(); + assert_eq!( + plan.command, + Command::Batch(roots.into_iter().map(Command::Delete).collect()) + ); + let removed = [ + fragment, + branches[0], + branches[1], + children[0], + children[1], + f.messages[1], + ]; + assert_eq!(plan.removed, removed.into_iter().collect()); + assert_eq!( + plan.changed, + [f.owner, f.views[0], f.views[1]].into_iter().collect() + ); + let mut expected = f.project.clone(); + for id in removed { + expected.elements.remove(&id); + } + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[0]]; + for view in f.views { + layout(&mut expected, view).messages.remove(1); + } + exact_transaction(&f.project, plan, &expected); +} + +#[test] +fn independent_operands_can_be_removed_together_without_a_reorder_parent_restriction() { + let mut f = fixture(Layer::Sa); + let (fragment, branches, children) = nested(&mut f); + let plan = prepare( + &f.project, + f.owner, + &selected(&[children[1], f.messages[0], children[0]]), + ) + .unwrap(); + let mut expected = f.project.clone(); + for id in [children[0], children[1], f.messages[0]] { + expected.elements.remove(&id); + } + for branch in branches { + expected + .elements + .get_mut(&branch) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + } + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[1], fragment]; + for view in f.views { + layout(&mut expected, view).messages.remove(0); + } + assert_eq!( + plan.changed, + [f.owner, branches[0], branches[1], f.views[0], f.views[1]] + .into_iter() + .collect() + ); + exact_transaction(&f.project, plan, &expected); +} + +#[test] +fn branch_and_use_are_local_removal_targets_and_reused_definition_survives() { + let mut f = fixture(Layer::Oa); + let (fragment, branches, children) = nested(&mut f); + let definition = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Definition", None) + .unwrap(); + let use_id = add( + &mut f.project, + f.owner, + "Use", + OccurrenceBody::ScenarioUse { + scenario: Some(definition), + bindings: vec![], + }, + ); + let plan = prepare(&f.project, f.owner, &selected(&[branches[0], use_id])).unwrap(); + let mut expected = f.project.clone(); + for id in [branches[0], children[0], use_id] { + expected.elements.remove(&id); + } + let OccurrenceBody::Fragment { + branches: remaining, + .. + } = body(&mut expected, fragment) + else { + panic!() + }; + *remaining = vec![branches[1]]; + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[0], f.messages[1], fragment]; + assert_eq!( + plan.removed, + [branches[0], children[0], use_id].into_iter().collect() + ); + assert_eq!(plan.changed, [f.owner, fragment].into_iter().collect()); + assert_eq!( + expected.elements[&definition], + f.project.elements[&definition] + ); + exact_transaction(&f.project, plan, &expected); +} + +#[test] +fn impact_includes_placements_artifacts_traces_and_changed_surviving_constraints() { + let mut f = fixture(Layer::Oa); + let overview = f.project.ensure_diagram(Layer::Oa); + let node = f + .project + .place(overview, f.messages[0], Position { x: 100., y: 100. }); + let other_node = f + .project + .place(overview, f.messages[1], Position { x: 400., y: 100. }); + let counterpart = f + .project + .add_element( + Kind::Function, + Layer::Sa, + "Opposite-layer counterpart", + None, + ) + .unwrap(); + let trace = Id::new_v4(); + f.project.traces.insert( + trace, + Trace { + id: trace, + alias: "manual_trace".into(), + name: "Manual trace".into(), + description: String::new(), + oa: f.messages[0], + sa: counterpart, + kind: TraceKind::Manual, + baseline: None, + }, + ); + let artifact = Id::new_v4(); + f.project.artifact_links.insert( + artifact, + ArtifactLink { + id: artifact, + alias: "evidence".into(), + name: "Evidence".into(), + description: String::new(), + owner: f.messages[0], + relation: ArtifactRelation::References, + uri: "evidence.txt".into(), + revision: None, + fragment: None, + }, + ); + let constraint = f + .project + .add_element(Kind::Constraint, Layer::Oa, "Constraint", None) + .unwrap(); + *f.project + .elements + .get_mut(&constraint) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Constraint { + expression: "review".into(), + constrained: f.messages.to_vec(), + }; + oa_core::session::validate_candidate(&f.project).unwrap(); + let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0]])).unwrap(); + let mut expected = f.project.clone(); + expected.elements.remove(&f.messages[0]); + expected.traces.remove(&trace); + expected.artifact_links.remove(&artifact); + expected + .diagrams + .get_mut(&overview) + .unwrap() + .nodes + .remove(&node); + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.messages[1]]; + let SharedBody::Constraint { constrained, .. } = expected + .elements + .get_mut(&constraint) + .unwrap() + .shared_mut() + .unwrap() + else { + panic!() + }; + *constrained = vec![f.messages[1]]; + for view in f.views { + layout(&mut expected, view).messages.remove(0); + } + assert_eq!( + plan.removed, + [f.messages[0], node, trace, artifact].into_iter().collect() + ); + assert_eq!( + plan.changed, + [f.owner, constraint, overview, f.views[0], f.views[1]] + .into_iter() + .collect() + ); + assert_eq!( + expected.elements[&counterpart], + f.project.elements[&counterpart] + ); + assert_eq!( + expected.diagrams[&overview].nodes[&other_node], + f.project.diagrams[&overview].nodes[&other_node] + ); + exact_transaction(&f.project, plan, &expected); +} + +#[test] +fn whole_typed_selection_refuses_missing_foreign_definitions_and_duplicates_before_collapse() { + let mut f = fixture(Layer::Oa); + let (fragment, _, children) = nested(&mut f); + let other = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Other", None) + .unwrap(); + let foreign = f + .project + .add_element(Kind::Lifeline, Layer::Oa, "Foreign", Some(other)) + .unwrap(); + for targets in [ + vec![], + vec![Target::Whole], + selected(&[f.messages[0], f.messages[0]]), + selected(&[f.messages[0], Id::nil()]), + selected(&[f.messages[0], f.owner]), + selected(&[f.messages[0], f.work[0]]), + selected(&[f.messages[0], foreign]), + vec![ + Target::ScenarioObject(f.messages[0]), + Target::ProcessOccurrence(OccurrenceEndpoint { + occurrence: f.messages[1], + references: vec![], + }), + ], + selected(&[fragment, children[0], children[0]]), + selected(&[fragment, Id::nil()]), + ] { + let original = f.project.clone(); + assert!(prepare(&f.project, f.owner, &targets).is_err()); + assert_eq!(f.project, original); + } + assert!(prepare(&f.project, f.work[0], &selected(&f.messages)).is_err()); +} + +#[test] +fn actual_list_reachability_and_complete_siblings_are_required() { + let mut f = fixture(Layer::Oa); + let (fragment, branches, children) = nested(&mut f); + let mut cases = Vec::new(); + let mut missing_branch = f.project.clone(); + let OccurrenceBody::Fragment { + branches: listed, .. + } = body(&mut missing_branch, fragment) + else { + panic!() + }; + listed.clear(); + cases.push(missing_branch); + let mut unlisted_child = f.project.clone(); + unlisted_child + .elements + .get_mut(&branches[0]) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + cases.push(unlisted_child); + let mut dangling_sibling = f.project.clone(); + dangling_sibling + .elements + .get_mut(&branches[1]) + .unwrap() + .steps_mut() + .unwrap() + .push(Id::new_v4()); + cases.push(dangling_sibling); + let mut duplicate_sibling = f.project.clone(); + duplicate_sibling + .elements + .get_mut(&branches[1]) + .unwrap() + .steps_mut() + .unwrap() + .push(children[1]); + cases.push(duplicate_sibling); + let mut wrong_parent = f.project.clone(); + wrong_parent.elements.get_mut(&children[0]).unwrap().parent = Some(branches[1]); + cases.push(wrong_parent); + for project in cases { + assert!( + prepare(&project, f.owner, &selected(&[fragment, children[0]])).is_err(), + "ancestor coverage never conceals an invalid original member/tree" + ); + } +} + +#[test] +fn invalid_baseline_cannot_be_silently_repaired_and_refusal_preserves_existing_redo() { + let f = fixture(Layer::Oa); + let mut invalid = f.project.clone(); + let duplicate_alias = invalid.elements[&f.owner].alias.clone(); + invalid.elements.get_mut(&f.messages[0]).unwrap().alias = duplicate_alias; + assert!(oa_core::session::validate_candidate(&invalid).is_err()); + // The final deletion could repair this error, but the planner must admit a + // valid original baseline rather than quietly using deletion as repair. + assert!(execute_batch(&invalid, &[Command::Delete(f.messages[0])]).is_ok()); + assert!(prepare(&invalid, f.owner, &selected(&[f.messages[0]])).is_err()); + let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap(); + document + .execute(Command::Rename(f.messages[1], "Prior edit".into())) + .unwrap(); + assert!(document.undo()); + let before = document.bundle().clone(); + let revision = document.revision(); + assert!( + prepare( + document.project(), + f.owner, + &selected(&[f.messages[0], f.work[0]]) + ) + .is_err() + ); + assert_eq!(document.bundle(), &before); + assert_eq!(document.revision(), revision); + assert!(document.can_redo()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 351f7cb2..7b2aae43 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -248,6 +248,22 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a reorder, camera, )); + if selected_count > 1 { + let removal = app + .scenario_removal_enabled() + .then(|| crate::app::scenario_removal::begin(app)) + .flatten(); + navigation = navigation.push(super::super::canvas_workspace::Control::new( + button(text("Remove…").size(12)) + .padding(6) + .on_press_maybe(removal.clone()), + scope, + "Remove selected", + keyboard, + removal, + camera, + )); + } if state.can_back(p, context) { navigation = navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe( diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 1832064f..a401dcf9 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -369,9 +369,9 @@ pub fn popup(app: &App) -> Option> { entries.push(Entry::title( "Use Reorder above the canvas to change sequence order", )); - entries.push(Entry::disabled( - "Remove selected occurrences (not yet available)", - )); + if let Some(removal) = crate::app::scenario_removal::begin(app) { + entries.push(item("Remove selected from Scenario…", removal, true)); + } entries.push(Entry::title( "Select one occurrence for its individual actions", )); diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 6781b123..a1caee4c 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -326,6 +326,13 @@ pub fn view(app: &App) -> View<'_, Message> { ); } if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) { + if target.scenario.is_some() { + return modal_width( + root, + crate::app::scenario_removal::view::view(app, target), + 650., + ); + } return modal( root, column![ diff --git a/harness/scenario-group-removal-policy-20260925.md b/harness/scenario-group-removal-policy-20260925.md new file mode 100644 index 00000000..40c919a1 --- /dev/null +++ b/harness/scenario-group-removal-policy-20260925.md @@ -0,0 +1,23 @@ +# Scenario group removal policy + +This implements the local Scenario part of [the multiple-selection contract](multiselect-contract-20260923.md) for issue 29. It is a development policy, not a claim of runtime or release acceptance. + +## Meaning and admission + +Remove the complete captured selection of local Scenario participants and occurrences in one reviewed operation. Eligible local bodies are Lifeline, Exchange, Action, State, ScenarioUse, Fragment and Branch. Each original target must be a unique, existing `ScenarioObject`, belong to the active Scenario, and be reachable through its actual sequence/branch structure (a participant must be a direct child). The App additionally checks actual rendered visibility, representation and visit, document revision, selection generation, membership and primary. A foreign, hidden, stale or unsupported member refuses the whole operation. No filtering to the primary object or to eligible members is permitted. + +Unlike semantic reorder, removal can span parents and mix local participants with occurrences: its single meaning is removal from this Scenario. A Fragment and one of its selected descendants are both admitted and shown as selected; only then may the descendant be covered by the Fragment's cascade for execution. Reusable definitions, represented engineering elements and opposite-layer counterparts are preserved under the existing deletion contract. Existing validator errors are not weakened; incomplete warning-only authoring remains governed by the core validator. + +## Aggregate impact and transaction + +Construct one atomic `Batch(Delete(root)...)` after collapsing selected owned descendants into selected ancestors. Evaluate the final candidate using the existing core command engine and derive the review from its actual result. The review distinguishes original selected removals, additional cascade removals, and surviving records that change. Examples of changes include cleared participant/call references, pruned ScenarioUse bindings, updated parent steps/branches, and pruned saved layout rows. Search and bounded pages expose the full affected set, including identical display names distinguished by aliases. + +Rejected alternatives: multiple Document transactions can partially remove a group and create several Undo entries; blindly batching every selected ID fails when an ancestor already removed a descendant; adding singleton impact counts can double-count overlaps and omit changed survivors. The selected set remains exact even when execution roots are normalized. + +Opening the review captures a fresh context after retiring the old canvas epoch. It does not apply pending text or property drafts. Confirmation carries the review token; stale confirmation/cancel packets must not affect a newer review. Context, exact selection and pending-edit eligibility are checked again before applying the one command. Cancel, refusal and source-write failure preserve model, source, history, selection and primary. Successful removal returns inspection to the Scenario and clears the removed selection; selection itself is transient and is not restored by model Undo. One Undo/Redo restores the complete model and exact source bytes. + +## Interaction and verification + +Group Delete/Shift+Delete, the contextual group action and the explicit Remove control capture the complete selection. Legacy singleton removal and bare unbound Delete remain refused while a group is shown. Context-menu invocation must preserve the group's primary even when invoked on an unselected object. Removal cannot start while a group pointer gesture, reorder chooser, spacing operation, pending editor or another modal owns input. Enter in the search field does not confirm destruction. + +Required discriminators include mixed participant/message references; Fragment plus selected descendant; cross-parent steps; preserved reused definitions and unrelated views; actual impact identity rather than count alone; full-set stale/secondary-membership ABA; menu selection preservation; stale confirm/cancel versus a newer modal; exact source and one-step history; Cancel/refusal preserving Redo; and compact native search/paging, confirmation, save/reopen and keyboard entry. Tests and native evidence must name their actual source revision. Linux verification remains user-paused.