Add captured Scenario group removal and aggregate impact review

This commit is contained in:
2026-09-24 18:38:05 -07:00
parent cdd5a0462b
commit ed6eaaa73e
13 changed files with 1975 additions and 5 deletions
+36 -1
View File
@@ -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<scenario_reorder::Event>),
ScenarioRemoval(Box<scenario_removal::Event>),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
@@ -1356,6 +1358,36 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// 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);
@@ -15,6 +15,7 @@ pub struct Confirmation {
pub selection: Id,
pub placement: Option<Id>,
pub behavior_owner: Option<Id>,
pub(crate) scenario: Option<super::scenario_removal::Review>,
}
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();
@@ -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<Message> {
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<Message> {
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}"),
}
}
}
@@ -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(), &current.decoded.project);
assert_eq!(f.app.document.bundle(), &current.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());
}
}
@@ -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<ImpactRow> {
let aliases: BTreeMap<Id, &str> = 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()
}
+11 -1
View File
@@ -2144,11 +2144,21 @@ impl canvas::Program<Message> 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(
@@ -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)]
@@ -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<Id>,
/// Surviving records changed by reference clearing, sequence edits or view
/// pruning. Embedded surviving placements are included alongside diagrams.
pub(crate) changed: BTreeSet<Id>,
}
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<BTreeSet<Id>, 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<T: PartialEq>(
before: &BTreeMap<Id, T>,
after: &BTreeMap<Id, T>,
changed: &mut BTreeSet<Id>,
) {
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<Plan, String> {
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;
@@ -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<Target> {
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());
}
@@ -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(
+3 -3
View File
@@ -369,9 +369,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
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",
));
+7
View File
@@ -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![