Add captured Scenario group removal and aggregate impact review
This commit is contained in:
@@ -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(), ¤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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
));
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user