Add atomic Process group removal with explicit reuse and ordering impact
This commit is contained in:
@@ -19,6 +19,7 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
pub(crate) mod process_removal;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
@@ -445,6 +446,7 @@ pub enum Message {
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||
ScenarioRemoval(Box<scenario_removal::Event>),
|
||||
ProcessRemoval(Box<process_removal::Event>),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
@@ -1364,7 +1366,19 @@ impl App {
|
||||
&& self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.scenario.is_some())
|
||||
.is_some_and(|target| target.scenario.is_some() || target.process.is_some())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::ProcessRemoval(event) = message {
|
||||
return self.process_removal_event(*event);
|
||||
}
|
||||
// Process review Escape is captured with its token by the root widget.
|
||||
if matches!(message, Message::Escape)
|
||||
&& self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.process.is_some())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1387,6 +1401,14 @@ impl App {
|
||||
self.confirm_scenario_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.process.is_some())
|
||||
{
|
||||
self.confirm_process_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
// Captured reorder packets must be admitted before shared navigation,
|
||||
// camera or source boundaries can disturb a newer operation.
|
||||
@@ -1400,14 +1422,14 @@ impl App {
|
||||
// A delayed Scenario menu cannot retire the current reveal/gesture even
|
||||
// when it uses the same semantic owner after a representation round trip.
|
||||
if let Message::Menu(crate::model_menu::Event::Open(request)) = &message
|
||||
&& request.scenario.is_some()
|
||||
&& (request.scenario.is_some() || request.process.is_some())
|
||||
&& crate::model_menu::Opened::new(self, request.clone(), self.menu_sequence).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::Menu(crate::model_menu::Event::Invoke(token, _)) = &message
|
||||
&& let Some(menu) = &self.model_menu
|
||||
&& menu.request.scenario.is_some()
|
||||
&& (menu.request.scenario.is_some() || menu.request.process.is_some())
|
||||
&& (menu.token != *token || !menu.valid(self))
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1423,6 +1445,17 @@ impl App {
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context().is_some()
|
||||
&& self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
&& matches!(&message, Message::Delete)
|
||||
{
|
||||
self.status =
|
||||
"Use Remove selected above the canvas to review the complete Process selection."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
let scenario_selection_before = self.scenario_context().and_then(|context| {
|
||||
@@ -2260,6 +2293,7 @@ impl App {
|
||||
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::ProcessRemoval(event) => return self.process_removal_event(*event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
@@ -2802,7 +2836,10 @@ impl App {
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::TopologyCanvas(_) | Message::ProcessRemoval(_)
|
||||
))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
@@ -3724,7 +3761,7 @@ impl App {
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = if self.topology_context().is_some() {
|
||||
"Group removal needs an aggregate review. Select one contribution to remove it individually."
|
||||
"Use Remove selected above the canvas to review the complete Process selection."
|
||||
} else {
|
||||
"Group model-deletion review is not available yet. Select one element or use Hide selected."
|
||||
}.into();
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct Confirmation {
|
||||
pub placement: Option<Id>,
|
||||
pub behavior_owner: Option<Id>,
|
||||
pub(crate) scenario: Option<super::scenario_removal::Review>,
|
||||
pub(crate) process: Option<super::process_removal::Review>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
@@ -211,6 +212,7 @@ impl App {
|
||||
placement: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
scenario: None,
|
||||
process: None,
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
|
||||
@@ -24,6 +24,15 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
&& app.architecture.is_none()));
|
||||
Element::new(Bridge {
|
||||
content,
|
||||
removal_cancel: app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| app.delete_pending && target.process.is_some())
|
||||
.map(|target| {
|
||||
Message::ProcessRemoval(Box::new(super::process_removal::Event::Cancel {
|
||||
token: target.snapshot.token,
|
||||
}))
|
||||
}),
|
||||
reorder_active: app.scenario_reorder_enabled() && app.scenario_reorder.session.is_some(),
|
||||
reorder_apply: crate::behavior::scenario::reorder_view::apply_message(app),
|
||||
messages: [
|
||||
@@ -43,6 +52,7 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
struct Bridge<'a, R> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
messages: [Option<Message>; 2],
|
||||
removal_cancel: Option<Message>,
|
||||
reorder_active: bool,
|
||||
reorder_apply: Option<Message>,
|
||||
}
|
||||
@@ -147,6 +157,11 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
// Escape would otherwise reach focused fields underneath it. Offer only
|
||||
// Escape to the current overlay before updating the ordinary tree.
|
||||
if escape {
|
||||
if let Some(cancel) = &self.removal_cancel {
|
||||
shell.publish(cancel.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if let Some(element) = self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! One captured Process selection, one impact review, one document transaction.
|
||||
use super::multiselect::process_context;
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, group_removal};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
input_generation: u64,
|
||||
context: topology::Context,
|
||||
snapshot: Box<Snapshot>,
|
||||
},
|
||||
Query {
|
||||
token: u64,
|
||||
query: String,
|
||||
},
|
||||
Page {
|
||||
token: u64,
|
||||
page: usize,
|
||||
},
|
||||
Cancel {
|
||||
token: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Review {
|
||||
input_generation: u64,
|
||||
pub(crate) context: topology::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 input_generation = app.behavior.topology.removal_generation()?;
|
||||
let context = app.topology_context()?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::ProcessRemoval(Box::new(Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn process_removal_enabled(&self) -> bool {
|
||||
diagram_removal::enabled(self)
|
||||
&& !self.source_rename.open()
|
||||
&& self.behavior.topology.selecting()
|
||||
&& self.behavior.topology.removal_generation().is_some()
|
||||
&& self.topology_context().is_some()
|
||||
}
|
||||
|
||||
fn process_removal_matches(&self, context: topology::Context, snapshot: &Snapshot) -> bool {
|
||||
if self.topology_context() != Some(context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selected| selected.matches(snapshot, process_context(context)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
}
|
||||
|
||||
pub(super) fn process_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot,
|
||||
} => {
|
||||
// Admission precedes every shared input/selection boundary. A
|
||||
// stale packet must not prune the set or retire a newer editor.
|
||||
if self.behavior.topology.removal_generation() != Some(input_generation)
|
||||
|| !self.process_removal_matches(context, &snapshot)
|
||||
|| !self.process_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan =
|
||||
match group_removal::prepare(self.project(), context.owner, snapshot.targets())
|
||||
{
|
||||
Ok(plan) => plan,
|
||||
Err(reason) => {
|
||||
self.status = reason;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let Some(primary_target) = snapshot.primary().cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let primary = match &primary_target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
Target::ProcessLink(id) => *id,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.topology_context().expect("admitted Process");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.process = Some(Box::new(context));
|
||||
request.selection_target = Some(Box::new(primary_target));
|
||||
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.owner),
|
||||
scenario: None,
|
||||
process: Some(Review {
|
||||
input_generation,
|
||||
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.process.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.process.as_mut()
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.snapshot.token == token && target.process.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 process_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.process else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.behavior.topology.removal_generation() == Some(review.input_generation)
|
||||
&& self.process_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.behavior.topology.selecting()
|
||||
}
|
||||
|
||||
pub(super) fn confirm_process_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.process.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.process_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.process.expect("Process review");
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected = review.context.owner;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(review.context.owner);
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Removed {} selected Process contributions or links 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,640 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{CanvasEvent, CanvasTool, direct_tests};
|
||||
use oa_core::source_project::SourceBundle;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: multiselect::Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context, target, choice,
|
||||
)));
|
||||
}
|
||||
fn open_project(project: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.reconcile_multiselect();
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
app
|
||||
}
|
||||
fn fixture(layer: Layer) -> (App, direct_tests::Fixture) {
|
||||
let f = direct_tests::fixture(layer);
|
||||
let mut app = open_project(f.p.clone(), f.owner);
|
||||
choose(
|
||||
&mut app,
|
||||
Target::ProcessLink(f.link),
|
||||
multiselect::Choice::Replace,
|
||||
);
|
||||
choose(&mut app, local(f.nodes[0]), multiselect::Choice::Add);
|
||||
assert!(app.process_removal_enabled());
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets().len(), 2);
|
||||
(app, f)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ProcessRemoval(Box::new(event)));
|
||||
}
|
||||
fn open(app: &mut App) -> u64 {
|
||||
let message = begin(app).unwrap();
|
||||
let _ = app.update(message);
|
||||
let target = app.delete_target.as_ref().expect("aggregate review");
|
||||
assert!(app.process_removal_review_valid(target));
|
||||
target.snapshot.token
|
||||
}
|
||||
fn native_bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: [("project.arcadia".into(), source.into())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
const BASELINE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/process-removal-baseline-20260925.arcadia"
|
||||
));
|
||||
const REMOVED: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/process-removal-removed-20260925.arcadia"
|
||||
));
|
||||
fn native() -> App {
|
||||
let document = Document::from_bundle(native_bundle(BASELINE), Default::default()).unwrap();
|
||||
let owner = alias(document.project(), "assessment");
|
||||
let mut app = open_project(document.project().clone(), owner);
|
||||
app.document = document;
|
||||
app.sync();
|
||||
app.reconcile_multiselect();
|
||||
app
|
||||
}
|
||||
fn alias(project: &Project, name: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_literal_native_model_and_source_oracle_undoes_once() {
|
||||
let mut app = native();
|
||||
for (index, name) in ["a", "c", "approved"].iter().enumerate() {
|
||||
let id = alias(app.project(), name);
|
||||
let target = if *name == "approved" {
|
||||
Target::ProcessLink(id)
|
||||
} else {
|
||||
local(id)
|
||||
};
|
||||
choose(
|
||||
&mut app,
|
||||
target,
|
||||
if index == 0 {
|
||||
multiselect::Choice::Replace
|
||||
} else {
|
||||
multiselect::Choice::Add
|
||||
},
|
||||
);
|
||||
}
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert_eq!(review.snapshot.primary(), selected.primary());
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains("@caller")
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains("@outer")
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
assert!(rows.iter().any(|row| {
|
||||
row.label
|
||||
.contains("Implicit listed order; 1 adjacent ordered pairs")
|
||||
&& row.disposition.starts_with("After")
|
||||
}));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let expected = Document::from_bundle(native_bundle(REMOVED), Default::default()).unwrap();
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
expected.bundle(),
|
||||
"literal independently authored source oracle"
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), *expected.bundle());
|
||||
assert_eq!(app.selected, alias(app.project(), "assessment"));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
let removed = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_card_link_is_one_exact_transaction_in_both_layers() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut app, f) = fixture(layer);
|
||||
let before = app.document.snapshot();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
review.plan.removed,
|
||||
[f.nodes[0], f.link].into_iter().collect()
|
||||
);
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
let mut expected = f.p.clone();
|
||||
expected.elements.remove(&f.nodes[0]);
|
||||
expected.relations.remove(&f.link);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.nodes[1], f.nodes[2]];
|
||||
assert_eq!(app.project(), &expected);
|
||||
for id in f.work.into_iter().chain([f.exchange]) {
|
||||
assert!(app.project().contains(id));
|
||||
}
|
||||
let removed = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
Document::from_bundle(removed.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_cancel_stale_packets_and_failed_commit_preserve_redo_source_and_selection()
|
||||
{
|
||||
for failure in [false, true] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
app.document
|
||||
.execute(Command::Rename(f.nodes[2], "Future name".into()))
|
||||
.unwrap();
|
||||
let future = app.document.bundle().clone();
|
||||
assert!(app.document.undo());
|
||||
app.sync();
|
||||
app.reconcile_multiselect();
|
||||
let baseline = app.document.snapshot();
|
||||
let source = app.source_editor.text();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let old_begin = begin(&app).unwrap();
|
||||
let first = open(&mut app);
|
||||
send(&mut app, Event::Cancel { token: first });
|
||||
let second = open(&mut app);
|
||||
let epoch = app.canvas_epoch;
|
||||
for message in [
|
||||
old_begin,
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
Message::Escape,
|
||||
Message::ProcessRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::ProcessRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::ProcessRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 90,
|
||||
})),
|
||||
] {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, second);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
if failure {
|
||||
// Force a genuine atomic engine failure after the admitted deletion.
|
||||
// Neither the successful first candidate command nor history leaks.
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_mut()
|
||||
.unwrap();
|
||||
review.plan.command = Command::Batch(vec![
|
||||
Command::Delete(f.nodes[0]),
|
||||
Command::Delete(Id::new_v4()),
|
||||
]);
|
||||
let _ = app.update(Message::ConfirmDelete(second));
|
||||
assert!(app.status.starts_with("Nothing removed:"));
|
||||
} else {
|
||||
send(&mut app, Event::Cancel { token: second });
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
assert!(app.document.can_redo() && !app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &future);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_stale_secondary_and_context_are_rejected_before_any_boundary() {
|
||||
for change in 0..4 {
|
||||
let (mut app, f) = fixture(Layer::Sa);
|
||||
let message = begin(&app).unwrap();
|
||||
match change {
|
||||
0 => {
|
||||
app.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.remove(&Target::ProcessLink(f.link));
|
||||
}
|
||||
1 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
2 => {
|
||||
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
_ => {
|
||||
app.document
|
||||
.execute(Command::Rename(f.nodes[2], "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let source = app.document.bundle().clone();
|
||||
let selected = app.multiselect_snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(message);
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot(), selected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let token = open(&mut app);
|
||||
app.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.remove(&Target::ProcessLink(f.link));
|
||||
let current = app.document.snapshot();
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert!(app.status.contains("expired"));
|
||||
assert!(std::sync::Arc::ptr_eq(¤t, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_pending_inputs_and_legacy_singleton_messages_preserve_all_members() {
|
||||
for pending in ["clean", "properties", "source", "tool", "busy", "form"] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = app.update(Message::Field("/name".into(), "Unapplied".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// pending", app.source_workspace.text());
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(std::sync::Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"tool" => app.behavior.topology.choose(CanvasTool::Connect),
|
||||
"busy" => app.busy = true,
|
||||
"form" => {
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let baseline = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let source = app.source_workspace.bundle();
|
||||
if pending != "clean" {
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(message);
|
||||
}
|
||||
let _ = app.update(Message::Delete);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Remove(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_qualified_member_refuses_whole_set_without_path_stripping() {
|
||||
let mut app = native();
|
||||
let owner = alias(app.project(), "caller");
|
||||
let root = alias(app.project(), "r1");
|
||||
let other = alias(app.project(), "r2");
|
||||
let leaf = alias(app.project(), "a");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Expand(context, root)));
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![root],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut app, qualified.clone(), multiselect::Choice::Replace);
|
||||
choose(&mut app, local(other), multiselect::Choice::Add);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets(), &[qualified, local(other)]);
|
||||
let before = app.document.snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_unselected_context_subject_does_not_replace_the_group() {
|
||||
for unselected in [false, true] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let clicked = if unselected { f.nodes[2] } else { f.nodes[0] };
|
||||
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(local(clicked)));
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(message),
|
||||
)));
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert!(!review.plan.removed.contains(&f.nodes[2]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_review_lists_transitive_callers_even_when_all_modes_stay_explicit() {
|
||||
let mut app = native();
|
||||
let a = alias(app.project(), "a");
|
||||
let link = alias(app.project(), "approved");
|
||||
choose(&mut app, local(a), multiselect::Choice::Replace);
|
||||
choose(
|
||||
&mut app,
|
||||
Target::ProcessLink(link),
|
||||
multiselect::Choice::Add,
|
||||
);
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
review.plan.interpretation.is_empty(),
|
||||
"remaining Control keeps the definition explicit"
|
||||
);
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
for alias in ["@caller", "@outer"] {
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains(alias)
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
Event::Query {
|
||||
token,
|
||||
query: "@outer".into(),
|
||||
},
|
||||
);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].label.contains("@assessment"));
|
||||
assert!(rows[0].label.contains("03 Mission using Response"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_delete_and_shift_delete_capture_group_escape_is_stamped() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
keyboard::{self, key::Named},
|
||||
mouse,
|
||||
};
|
||||
for shift in [false, true] {
|
||||
let (mut app, _) = fixture(Layer::Oa);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
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 app, &[remove.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{output:?}");
|
||||
let token = app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
|
||||
*repeat = true;
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut app, &[remove], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!output
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ConfirmDelete(_)))
|
||||
);
|
||||
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, token);
|
||||
let output = mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Cancel { token: captured } if *captured == token))), "{output:?}");
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_removal_queued_delete_is_revoked_by_later_press_in_same_batch() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let local = app
|
||||
.behavior
|
||||
.topology
|
||||
.removal_test_point(&app, f.owner, f.nodes[0], None);
|
||||
let at = bounds.position() + iced::Vector::new(local.x, local.y);
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
key(Named::Delete),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "the first event queues a real review packet: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"later press must revoke that packet before App consumes it"
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"refusal does not retire the active gesture"
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.process_removal_enabled());
|
||||
assert!(
|
||||
begin(&app).is_none(),
|
||||
"active gesture cannot capture another review"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(Named::Escape)],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
// Escape leaves viewport focus; F6 restores keyboard ownership and sends
|
||||
// the canvas cancellation boundary before a fresh Delete.
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(app.process_removal_enabled());
|
||||
// Cancelling the pointer gesture retains the group and permits a fresh review.
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Delete)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(app.delete_target.is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_removal_queued_delete_is_revoked_by_camera_pan_start() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
for button in [mouse::Button::Middle, mouse::Button::Left] {
|
||||
let (mut app, _) = fixture(Layer::Sa);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let at = iced::Point::new(
|
||||
bounds.x + bounds.width - 12.,
|
||||
bounds.y + bounds.height - 12.,
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
key(Named::Delete),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(button)),
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "real Delete is queued: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"camera input retires earlier review packet"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! 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, BTreeSet};
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(super) struct ImpactRow {
|
||||
pub(super) label: String,
|
||||
pub(super) disposition: String,
|
||||
}
|
||||
|
||||
pub(super) fn matching_rows(project: &Project, review: &Review) -> Vec<ImpactRow> {
|
||||
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
|
||||
let label = |id: Id| {
|
||||
let name = project.named(id);
|
||||
let identity = aliases
|
||||
.get(&id)
|
||||
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
|
||||
if name == "Missing object" || name.is_empty() {
|
||||
identity
|
||||
} else {
|
||||
format!("{name} ({identity})")
|
||||
}
|
||||
};
|
||||
let selected = |id: Id| {
|
||||
review.snapshot.targets().iter().any(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
endpoint.references.is_empty() && endpoint.occurrence == id
|
||||
}
|
||||
Target::ProcessLink(link) => *link == id,
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
let mut rows = vec![];
|
||||
for id in &review.plan.removed {
|
||||
rows.push(ImpactRow {
|
||||
label: label(*id),
|
||||
disposition: if selected(*id) {
|
||||
"Removed · selected"
|
||||
} else {
|
||||
"Additional removed"
|
||||
}
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
for id in &review.plan.changed {
|
||||
rows.push(ImpactRow {
|
||||
label: label(*id),
|
||||
disposition: "Updated · preserved".into(),
|
||||
});
|
||||
}
|
||||
// Interpretation is separate from stored record deltas: callers may keep
|
||||
// identical records while their reused definition or analysis changes.
|
||||
for impact in &review.plan.interpretation {
|
||||
let owner = label(impact.owner);
|
||||
let shared_order = impact.before.local_order == impact.before.expanded_order
|
||||
&& impact.after.local_order == impact.after.expanded_order;
|
||||
for (dimension, before, after, shown) in [
|
||||
(
|
||||
if shared_order {
|
||||
"Listed and reused order"
|
||||
} else {
|
||||
"Local listed order"
|
||||
},
|
||||
&impact.before.local_order,
|
||||
&impact.after.local_order,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"Order inside reused definitions",
|
||||
&impact.before.expanded_order,
|
||||
&impact.after.expanded_order,
|
||||
!shared_order,
|
||||
),
|
||||
] {
|
||||
if !shown || before == after {
|
||||
continue;
|
||||
}
|
||||
for (when, order) in [("Before", before), ("After", after)] {
|
||||
let summary = match order {
|
||||
None => "Explicit topology; no implicit listed order".to_owned(),
|
||||
Some(ids) if ids.is_empty() => {
|
||||
"Empty order; no ordered pair; contextual flow may be incomplete".to_owned()
|
||||
}
|
||||
Some(ids) if ids.len() == 1 => {
|
||||
"One item in this order; no ordered pair".to_owned()
|
||||
}
|
||||
Some(ids) => format!(
|
||||
"Implicit listed order; {} adjacent ordered pairs",
|
||||
ids.len() - 1
|
||||
),
|
||||
};
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{owner}: {summary}"),
|
||||
disposition: format!("{when} · {dimension}"),
|
||||
});
|
||||
if let Some(ids) = order {
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{owner}: #{} {}", index + 1, label(*id)),
|
||||
disposition: format!("{when} · {dimension} · listed position"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if impact.before.contextual_analysis != impact.after.contextual_analysis {
|
||||
rows.push(ImpactRow {
|
||||
label: format!(
|
||||
"{owner}: contextual analysis {} → {}",
|
||||
if impact.before.contextual_analysis {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if impact.after.contextual_analysis {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
),
|
||||
disposition:
|
||||
"Analysis mode changes; this is not a claim of complete graph connectivity"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut affected = review.plan.affected_uses.clone();
|
||||
for impact in &review.plan.interpretation {
|
||||
affected
|
||||
.entry(impact.owner)
|
||||
.or_default()
|
||||
.extend(impact.callers.iter().copied());
|
||||
}
|
||||
for (definition, callers) in affected {
|
||||
for caller in callers {
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{} uses {}", label(caller), label(definition)),
|
||||
disposition: "Uses changed definition · affected caller definition".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let query = review.query.trim().to_lowercase();
|
||||
rows.into_iter()
|
||||
.filter(|row| {
|
||||
query.is_empty()
|
||||
|| row.label.to_lowercase().contains(&query)
|
||||
|| row.disposition.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.process.as_ref() else {
|
||||
return text("Process removal review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.process_removal_review_valid(target);
|
||||
let rows = matching_rows(app.project(), review);
|
||||
let callers: BTreeSet<_> = review
|
||||
.plan
|
||||
.affected_uses
|
||||
.values()
|
||||
.flatten()
|
||||
.chain(
|
||||
review
|
||||
.plan
|
||||
.interpretation
|
||||
.iter()
|
||||
.flat_map(|impact| impact.callers.iter()),
|
||||
)
|
||||
.copied()
|
||||
.collect();
|
||||
let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() {
|
||||
"No listed-order or analysis-mode changes; no affected caller definitions.".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.",
|
||||
review.plan.interpretation.len(),
|
||||
callers.len()
|
||||
)
|
||||
};
|
||||
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.clone())
|
||||
.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 effects. Try a name, alias or effect.").size(13));
|
||||
}
|
||||
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
|
||||
Message::ProcessRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::ProcessRemoval(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::ProcessRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Remove selected contributions / links")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Remove {} selected contributions / links?", review.snapshot.targets().len())).size(21),
|
||||
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
|
||||
text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
text_input("Search affected names, aliases or effects", &review.query)
|
||||
.id("process-removal-query")
|
||||
.on_input(move |query| Message::ProcessRemoval(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 selected local contributions or maintained links and the additional records listed below. Referenced definitions, work and exchanges are preserved. Reused topology, entry/exit connections and diagnostics may change. Review every listed order and affected caller definition; this review does not compute complete caller graphs. 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("process-removal-impact"),
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill)
|
||||
// Keep confirmation and search outside the flexible, scrollable impact body.
|
||||
.height((app.window_size.height - 160.).clamp(260., 570.))
|
||||
.into()
|
||||
}
|
||||
@@ -107,6 +107,7 @@ impl App {
|
||||
selection: self.selected,
|
||||
placement: None,
|
||||
behavior_owner: Some(context.scenario),
|
||||
process: None,
|
||||
scenario: Some(Review {
|
||||
context,
|
||||
snapshot,
|
||||
|
||||
@@ -5,7 +5,9 @@ use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
pub(crate) mod group_removal;
|
||||
pub(crate) mod group_reorder;
|
||||
mod removal_input;
|
||||
mod usages;
|
||||
mod view;
|
||||
#[cfg(test)]
|
||||
@@ -80,6 +82,7 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_input: removal_input::Lifetime,
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
@@ -128,6 +131,10 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn removal_generation(&self) -> Option<u64> {
|
||||
self.removal_input.idle()
|
||||
}
|
||||
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.tool,
|
||||
@@ -1104,6 +1111,7 @@ const PAINT_PASSES: [PaintPass; 5] = [
|
||||
];
|
||||
mod contextual_routing;
|
||||
struct Graph<'a> {
|
||||
removal_input: removal_input::Lifetime,
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
camera_pan: iced::Vector,
|
||||
@@ -1401,6 +1409,7 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
removal_input: removal_input::Lifetime::default(),
|
||||
interaction: None,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
pub(super) struct GroupPress {
|
||||
_removal_lease: super::super::removal_input::Lease,
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
@@ -108,6 +109,12 @@ impl Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
) {
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
match event {
|
||||
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
|
||||
@@ -205,21 +212,46 @@ impl Graph<'_> {
|
||||
return None;
|
||||
}
|
||||
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
repeat: false,
|
||||
..
|
||||
})
|
||||
) && (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
if let canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
modifiers,
|
||||
repeat,
|
||||
..
|
||||
}) = event
|
||||
&& (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
{
|
||||
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
|
||||
if *repeat
|
||||
|| tool != CanvasTool::Select
|
||||
|| !(modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
|| state.press.is_some()
|
||||
|| state.source.is_some()
|
||||
{
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if snapshot.targets().len() > 1 {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::ProcessRemoval(Box::new(
|
||||
crate::app::process_removal::Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot.clone()),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
return publish(SelectionEvent::Refuse(
|
||||
snapshot.clone(),
|
||||
"Open the referenced definition to remove qualified contributions.".into(),
|
||||
));
|
||||
}
|
||||
let pointer = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
@@ -228,6 +260,9 @@ impl Graph<'_> {
|
||||
if !bounds.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Mouse(mouse::Event::ButtonPressed(_))) {
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
let world = Point::new(
|
||||
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
@@ -310,7 +345,11 @@ impl Graph<'_> {
|
||||
state.tool = tool;
|
||||
state.bounds = Some(bounds);
|
||||
state.pointer = Some(pointer);
|
||||
let Some(lease) = self.removal_input.acquire() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
state.group = Some(GroupPress {
|
||||
_removal_lease: lease,
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: pointer,
|
||||
|
||||
@@ -171,7 +171,7 @@ fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
|
||||
fn whole_mixed_set_reviews_delete_and_refuses_drag_and_connect_without_singleton_bypass() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
@@ -187,7 +187,7 @@ fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
|
||||
};
|
||||
assert!(matches!(
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if snapshot.targets().len() == 2)
|
||||
));
|
||||
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||
assert!(matches!(
|
||||
@@ -401,3 +401,99 @@ fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_hand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_delete_modifiers_repeat_and_active_gesture_never_emit_singleton_or_confirmation() {
|
||||
use iced::keyboard::{Event as KeyEvent, Modifiers, key::Named};
|
||||
let f = fixture(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::CTRL,
|
||||
Modifiers::ALT,
|
||||
] {
|
||||
for repeat in [false, true] {
|
||||
let iced::Event::Keyboard(mut key) =
|
||||
crate::behavior::canvas_workspace::tests::key(Named::Delete)
|
||||
else {
|
||||
panic!("key")
|
||||
};
|
||||
if let KeyEvent::KeyPressed {
|
||||
modifiers: value,
|
||||
repeat: value_repeat,
|
||||
..
|
||||
} = &mut key
|
||||
{
|
||||
*value = modifiers;
|
||||
*value_repeat = repeat;
|
||||
}
|
||||
let message = send(
|
||||
&graph,
|
||||
&mut Input::default(),
|
||||
canvas::Event::Keyboard(key),
|
||||
start,
|
||||
);
|
||||
if !repeat && (modifiers.is_empty() || modifiers == Modifiers::SHIFT) {
|
||||
assert!(
|
||||
matches!(message, Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if Some(&**snapshot) == graph.selection.as_ref()))
|
||||
);
|
||||
} else {
|
||||
assert!(message.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut input = Input::default();
|
||||
press(&graph, &mut input, start);
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(Named::Delete)
|
||||
else {
|
||||
panic!("key")
|
||||
};
|
||||
assert!(send(&graph, &mut input, canvas::Event::Keyboard(key), start).is_none());
|
||||
assert!(input.group.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_removal_gesture_lease_retires_on_release_cancel_and_widget_state_drop() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for end in ["release", "escape", "drop", "unfocus"] {
|
||||
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
let before = graph.removal_input.idle().unwrap();
|
||||
let at = point(&graph, f.nodes[0], false);
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, at).is_none());
|
||||
assert!(input.group.is_some());
|
||||
assert!(graph.removal_input.idle().is_none());
|
||||
match end {
|
||||
"release" => {
|
||||
release(&graph, &mut input, at);
|
||||
}
|
||||
"escape" => {
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
) else {
|
||||
panic!("key")
|
||||
};
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), at);
|
||||
}
|
||||
"unfocus" => {
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
at,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
drop(input);
|
||||
}
|
||||
}
|
||||
assert!(graph.removal_input.idle().is_some());
|
||||
assert_ne!(
|
||||
graph.removal_input.idle(),
|
||||
Some(before),
|
||||
"{end} cannot restore an old Begin generation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Exact local Process/FunctionalChain removal and definition-level interpretation
|
||||
//! effects. Visibility, source drafts and captured UI lifetime belong to the App.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
pub(crate) interpretation: Vec<InterpretationChange>,
|
||||
/// Definitions whose authored topology changed, including changes that do
|
||||
/// not switch interpretation. Values are surviving transitive callers.
|
||||
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct InterpretationChange {
|
||||
pub(crate) owner: Id,
|
||||
pub(crate) before: Interpretation,
|
||||
pub(crate) after: Interpretation,
|
||||
pub(crate) callers: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Interpretation {
|
||||
/// None means explicit; Some includes the empty legacy order.
|
||||
pub(crate) local_order: Option<Vec<Id>>,
|
||||
/// Definition-level implicit order used within contextual expansion. This
|
||||
/// does not claim complete expanded connectivity or boundary analysis.
|
||||
pub(crate) expanded_order: Option<Vec<Id>>,
|
||||
pub(crate) contextual_analysis: bool,
|
||||
}
|
||||
|
||||
fn exchange(project: &Project, id: Id) -> bool {
|
||||
matches!(
|
||||
project.elements.get(&id).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn interpretations(project: &Project) -> BTreeMap<Id, Interpretation> {
|
||||
let index = TopologyIndex::new(project);
|
||||
// This is exactly TopologyIndex::with_cache_capacity's relation ownership
|
||||
// rule. Its link_definitions is private; do not expand repeated reuse trees
|
||||
// just to answer this finite definition-level question.
|
||||
let linked: BTreeSet<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|relation| {
|
||||
relation.contextual_owner().or_else(|| {
|
||||
(relation.kind() == RelationKind::SequenceLink)
|
||||
.then(|| {
|
||||
project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|e| {
|
||||
let steps = e.steps().expect("Sequence definitions have ordered steps");
|
||||
// legacy_order alone loses the explicit-versus-empty distinction.
|
||||
let local_order = (!uses_explicit_topology(project, e.id)
|
||||
&& steps.iter().all(|id| exchange(project, *id)))
|
||||
.then(|| steps.clone());
|
||||
let children = index.contributions(e.id);
|
||||
// Mirrors topology_context/analysis.rs Builder::expand's legacy-order
|
||||
// discriminator: all indexed contributions (including unlisted ones),
|
||||
// and neither SequenceLink nor ContextualLink definitions. Tests retain
|
||||
// literal discriminators where this intentionally differs from desktop.
|
||||
let expanded_order = (!linked.contains(&e.id)
|
||||
&& children.iter().all(|id| exchange(project, *id)))
|
||||
.then_some(children);
|
||||
(
|
||||
e.id,
|
||||
Interpretation {
|
||||
local_order,
|
||||
expanded_order,
|
||||
contextual_analysis: index.has_contextual_links(e.id),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn caller_edges(project: &Project, edges: &mut BTreeMap<Id, BTreeSet<Id>>) {
|
||||
for element in project.elements.values() {
|
||||
if let (
|
||||
Some(parent),
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence),
|
||||
},
|
||||
..
|
||||
}),
|
||||
) = (element.parent, element.shared())
|
||||
{
|
||||
edges.entry(*sequence).or_default().insert(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn callers(
|
||||
owner: Id,
|
||||
edges: &BTreeMap<Id, BTreeSet<Id>>,
|
||||
surviving: &BTreeMap<Id, Interpretation>,
|
||||
) -> BTreeSet<Id> {
|
||||
let mut seen = BTreeSet::from([owner]);
|
||||
let mut pending = vec![owner];
|
||||
while let Some(sequence) = pending.pop() {
|
||||
for &caller in edges.get(&sequence).into_iter().flatten() {
|
||||
if seen.insert(caller) {
|
||||
pending.push(caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.remove(&owner);
|
||||
seen.retain(|id| surviving.contains_key(id));
|
||||
seen
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
fn definition_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
let owner = if let Some(element) = project.elements.get(&id) {
|
||||
if matches!(element.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
Some(id)
|
||||
} else {
|
||||
element
|
||||
.parent
|
||||
.filter(|parent| is_contribution(project, *parent, id))
|
||||
}
|
||||
} else {
|
||||
project.relations.get(&id).and_then(|relation| {
|
||||
relation.contextual_owner().or_else(|| {
|
||||
(relation.kind() == RelationKind::SequenceLink)
|
||||
.then(|| {
|
||||
project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
}?;
|
||||
project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|_| owner)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.ok_or("Choose an existing Process or Functional Chain.")?;
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one local contribution or maintained link to remove.".into());
|
||||
}
|
||||
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let mut listed = BTreeSet::new();
|
||||
for &id in element
|
||||
.steps()
|
||||
.ok_or("This owner has no contribution order.")?
|
||||
{
|
||||
if !is_contribution(project, owner, id)
|
||||
|| project.elements[&id].layer() != element.layer()
|
||||
|| !listed.insert(id)
|
||||
{
|
||||
return Err(
|
||||
"Every process step must name a unique local contribution before removal.".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut occurrences = BTreeSet::new();
|
||||
let mut links = BTreeSet::new();
|
||||
// Admit ALL original typed targets before any dependency normalization.
|
||||
for target in targets {
|
||||
let id = match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
if !endpoint.references.is_empty() {
|
||||
return Err("Open the referenced definition to remove its contents.".into());
|
||||
}
|
||||
if !listed.contains(&endpoint.occurrence) {
|
||||
return Err("Every selected contribution must be listed directly in this process.".into());
|
||||
}
|
||||
occurrences.insert(endpoint.occurrence);
|
||||
endpoint.occurrence
|
||||
}
|
||||
Target::ProcessLink(id) => {
|
||||
let relation = project.relations.get(id).ok_or("A selected maintained link no longer exists.")?;
|
||||
let local = match &relation.body {
|
||||
RelationBody::SequenceLink { source, target, .. } => listed.contains(source) && listed.contains(target),
|
||||
RelationBody::ContextualLink { owner: actual, .. } => *actual == owner,
|
||||
_ => false,
|
||||
};
|
||||
if !local {
|
||||
return Err("Every selected link must be maintained locally by this process, with listed local endpoints for sequence links.".into());
|
||||
}
|
||||
links.insert(*id);
|
||||
*id
|
||||
}
|
||||
_ => return Err("Remove only local process contributions and maintained links together; referenced preview links are read-only here.".into()),
|
||||
};
|
||||
if !selected.insert(id) {
|
||||
return Err("Every selected process subject must be unique.".into());
|
||||
}
|
||||
}
|
||||
// Contribution kinds cannot contain one another; only admitted links can be
|
||||
// covered by an occurrence cascade. Ancestry alone misses incident links.
|
||||
let covered: BTreeSet<_> = occurrences
|
||||
.iter()
|
||||
.flat_map(|id| oa_core::references::deletion_impact(project, *id))
|
||||
.collect();
|
||||
let command = Command::Batch(
|
||||
occurrences
|
||||
.into_iter()
|
||||
.chain(links.into_iter().filter(|id| !covered.contains(id)))
|
||||
.map(Command::Delete)
|
||||
.collect(),
|
||||
);
|
||||
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
let retained: BTreeSet<_> = candidate
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
if !retained.is_subset(&original) {
|
||||
return Err("Removal unexpectedly created model identities; no change was applied.".into());
|
||||
}
|
||||
let removed: BTreeSet<_> = original.difference(&retained).copied().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);
|
||||
}
|
||||
}
|
||||
let before = interpretations(project);
|
||||
let after = interpretations(&candidate);
|
||||
let mut edges = BTreeMap::new();
|
||||
caller_edges(project, &mut edges);
|
||||
caller_edges(&candidate, &mut edges);
|
||||
let affected_owners: BTreeSet<_> = removed
|
||||
.iter()
|
||||
.chain(&changed)
|
||||
.flat_map(|&id| {
|
||||
[
|
||||
definition_owner(project, id),
|
||||
definition_owner(&candidate, id),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.filter(|owner| after.contains_key(owner))
|
||||
.collect();
|
||||
let affected_uses = affected_owners
|
||||
.into_iter()
|
||||
.map(|owner| (owner, callers(owner, &edges, &after)))
|
||||
.collect();
|
||||
let interpretation = after
|
||||
.iter()
|
||||
.filter_map(|(&owner, next)| {
|
||||
let previous = before.get(&owner)?;
|
||||
(previous != next).then(|| InterpretationChange {
|
||||
owner,
|
||||
before: previous.clone(),
|
||||
after: next.clone(),
|
||||
callers: callers(owner, &edges, &after),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Plan {
|
||||
command,
|
||||
removed,
|
||||
changed,
|
||||
interpretation,
|
||||
affected_uses,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,909 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{MessageKind, source_project::ProjectLimits};
|
||||
|
||||
fn endpoint(id: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
}
|
||||
}
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(endpoint(id))
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let element = occurrence(project, owner, name, body);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn action(project: &mut Project, owner: Id, name: &str, work: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_step(project: &mut Project, owner: Id, name: &str, exchange: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn sequence(project: &mut Project, layer: Layer, name: &str) -> Id {
|
||||
project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn reuse(project: &mut Project, owner: Id, name: &str, definition: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn maintained(project: &mut Project, source: Id, target: Id) -> Id {
|
||||
let Command::CreateRelation(relation) = link(project, source, target, "authored guard") else {
|
||||
panic!()
|
||||
};
|
||||
let id = relation.id;
|
||||
project.relations.insert(id, relation);
|
||||
id
|
||||
}
|
||||
|
||||
fn contextual(
|
||||
project: &mut Project,
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
purpose: ContextualLinkPurpose,
|
||||
) -> Id {
|
||||
let Command::CreateRelation(mut relation) =
|
||||
link(project, source.occurrence, target.occurrence, "")
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let id = relation.id;
|
||||
relation.body = RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
};
|
||||
project.relations.insert(id, relation);
|
||||
id
|
||||
}
|
||||
|
||||
fn precedence() -> ContextualLinkPurpose {
|
||||
ContextualLinkPurpose::Precedence {
|
||||
guard: "caller guard".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified(references: &[Id], occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: references.to_vec(),
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn interpretation(
|
||||
local: Option<Vec<Id>>,
|
||||
expanded: Option<Vec<Id>>,
|
||||
contextual: bool,
|
||||
) -> Interpretation {
|
||||
Interpretation {
|
||||
local_order: local,
|
||||
expanded_order: expanded,
|
||||
contextual_analysis: contextual,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_without(project: &Project, owner: Id, elements: &[Id], relations: &[Id]) -> Project {
|
||||
let mut expected = project.clone();
|
||||
for id in elements {
|
||||
expected.elements.remove(id);
|
||||
}
|
||||
for id in relations {
|
||||
expected.relations.remove(id);
|
||||
}
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| !elements.contains(id));
|
||||
expected
|
||||
}
|
||||
|
||||
fn assert_history(project: &Project, expected: &Project, command: Command) {
|
||||
let mut doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
doc.execute(command).unwrap();
|
||||
assert_eq!(doc.project(), expected);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), expected);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), project);
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), expected);
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_cards_preserve_sources_definitions_and_other_uses() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let definition = sequence(&mut p, layer, "Reusable");
|
||||
let leaf = action(&mut p, definition, "Leaf", f.work[0]);
|
||||
let a = action(&mut p, f.owner, "Action", f.work[0]);
|
||||
let e = exchange_step(&mut p, f.owner, "Exchange", f.exchange);
|
||||
let c = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let r = reuse(&mut p, f.owner, "First use", definition);
|
||||
let r2 = reuse(&mut p, f.owner, "Preserved use", definition);
|
||||
let plan = prepare(&p, f.owner, &[local(r), local(c), local(e), local(a)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([a, e, c, r]));
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
assert!(plan.interpretation.is_empty());
|
||||
let expected = expected_without(&p, f.owner, &[a, e, c, r], &[]);
|
||||
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[r2]);
|
||||
assert!(expected.contains(leaf));
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_dependent_selected_links_are_normalized_after_whole_set_admission() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, c] = f.nodes;
|
||||
let parallel = maintained(&mut p, a, b);
|
||||
let adjacent = maintained(&mut p, b, c);
|
||||
let context = contextual(
|
||||
&mut p,
|
||||
f.owner,
|
||||
endpoint(a),
|
||||
endpoint(b),
|
||||
ContextualLinkPurpose::Exchange {
|
||||
exchange: f.exchange,
|
||||
},
|
||||
);
|
||||
let targets = vec![
|
||||
Target::ProcessLink(f.link),
|
||||
local(a),
|
||||
Target::ProcessLink(parallel),
|
||||
local(b),
|
||||
Target::ProcessLink(context),
|
||||
];
|
||||
let first = prepare(&p, f.owner, &targets).unwrap();
|
||||
let mut reverse = targets.clone();
|
||||
reverse.reverse();
|
||||
let second = prepare(&p, f.owner, &reverse).unwrap();
|
||||
assert_eq!(first.command, second.command);
|
||||
assert_eq!(
|
||||
first.removed,
|
||||
BTreeSet::from([a, b, f.link, parallel, adjacent, context])
|
||||
);
|
||||
assert_eq!(first.changed, BTreeSet::from([f.owner]));
|
||||
assert_eq!(
|
||||
first.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, true),
|
||||
after: interpretation(None, None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
let Command::Batch(commands) = &first.command else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
2,
|
||||
"All selected links are covered by admitted card roots"
|
||||
);
|
||||
let expected =
|
||||
expected_without(&p, f.owner, &[a, b], &[f.link, parallel, adjacent, context]);
|
||||
assert_history(&p, &expected, first.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_refuses_every_invalid_original_typed_member() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let other = sequence(&mut p, layer, "Other");
|
||||
let foreign = action(&mut p, other, "Foreign", f.work[0]);
|
||||
let foreign_other = action(&mut p, other, "Other foreign", f.work[0]);
|
||||
let unlisted = action(&mut p, f.owner, "Owned but unlisted", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unlisted);
|
||||
let unlisted_link = maintained(&mut p, unlisted, f.nodes[0]);
|
||||
let foreign_context = contextual(
|
||||
&mut p,
|
||||
other,
|
||||
endpoint(foreign),
|
||||
endpoint(foreign_other),
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
);
|
||||
// Use two separate qualified paths with the same leaf: neither can be
|
||||
// silently reduced to that leaf or to an outer reference occurrence.
|
||||
let r1 = reuse(&mut p, f.owner, "Use one", other);
|
||||
let r2 = reuse(&mut p, f.owner, "Use two", other);
|
||||
let missing = Id::new_v4();
|
||||
let bad = vec![
|
||||
Target::Whole,
|
||||
Target::Placement(f.nodes[0]),
|
||||
Target::Port {
|
||||
port: f.work[0],
|
||||
placement: f.nodes[0],
|
||||
},
|
||||
Target::Edge(f.link),
|
||||
Target::StateRow {
|
||||
placement: f.nodes[0],
|
||||
relation: f.link,
|
||||
},
|
||||
Target::AllocationGrouping {
|
||||
relation: f.link,
|
||||
work_placement: f.nodes[0],
|
||||
performer_placement: f.nodes[1],
|
||||
},
|
||||
Target::ScenarioObject(f.nodes[0]),
|
||||
Target::ProcessPreviewLink {
|
||||
relation: f.link,
|
||||
source: endpoint(f.nodes[0]),
|
||||
target: endpoint(f.nodes[1]),
|
||||
},
|
||||
Target::ProcessOccurrence(qualified(&[r1], foreign)),
|
||||
Target::ProcessOccurrence(qualified(&[r2], foreign)),
|
||||
local(foreign),
|
||||
local(unlisted),
|
||||
local(missing),
|
||||
local(f.work[0]),
|
||||
Target::ProcessLink(unlisted_link),
|
||||
Target::ProcessLink(foreign_context),
|
||||
Target::ProcessLink(f.exchange),
|
||||
Target::ProcessLink(missing),
|
||||
Target::ProcessLink(f.nodes[0]),
|
||||
];
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let source = oa_core::language::format(&p);
|
||||
for target in bad {
|
||||
assert!(
|
||||
prepare(&p, f.owner, &[local(f.nodes[0]), target.clone()]).is_err(),
|
||||
"{target:?}"
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[target, local(f.nodes[0])]).is_err());
|
||||
}
|
||||
assert!(prepare(&p, f.owner, &[]).is_err());
|
||||
assert!(prepare(&p, f.owner, &[local(f.nodes[0]), local(f.nodes[0])]).is_err());
|
||||
assert!(
|
||||
prepare(
|
||||
&p,
|
||||
f.owner,
|
||||
&[Target::ProcessLink(f.link), Target::ProcessLink(f.link)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(prepare(&p, f.work[0], &[local(f.nodes[0])]).is_err());
|
||||
assert!(prepare(&p, missing, &[local(f.nodes[0])]).is_err());
|
||||
assert_eq!(oa_core::language::format(&p), source);
|
||||
let mut duplicate_steps = p.clone();
|
||||
duplicate_steps
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.nodes[0]);
|
||||
assert!(prepare(&duplicate_steps, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
let mut wrong_parent = p.clone();
|
||||
wrong_parent.elements.get_mut(&f.nodes[0]).unwrap().parent = Some(other);
|
||||
assert!(prepare(&wrong_parent, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
let mut missing_step = p.clone();
|
||||
missing_step
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(missing);
|
||||
assert!(prepare(&missing_step, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_last_markers_and_legacy_interior_report_literal_orders() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let a = action(&mut p, f.owner, "Marker", f.work[0]);
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let c = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Middle", f.exchange);
|
||||
let e3 = exchange_step(&mut p, f.owner, "Last", f.exchange);
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
reuse(&mut p, caller, "Reference", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
reuse(&mut p, outer, "Transitive reference", caller);
|
||||
let plan = prepare(&p, f.owner, &[local(c), local(a)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
|
||||
callers: BTreeSet::from([caller, outer]),
|
||||
}]
|
||||
);
|
||||
let expected = expected_without(&p, f.owner, &[a, c], &[]);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
let bridge = prepare(&expected, f.owner, &[local(e2)]).unwrap();
|
||||
assert_eq!(
|
||||
bridge.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
|
||||
after: interpretation(Some(vec![e1, e3]), Some(vec![e1, e3]), false),
|
||||
callers: BTreeSet::from([caller, outer]),
|
||||
}]
|
||||
);
|
||||
assert_eq!(bridge.removed, BTreeSet::from([e2]));
|
||||
assert_history(
|
||||
&expected,
|
||||
&expected_without(&expected, f.owner, &[e2], &[]),
|
||||
bridge.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_last_link_and_zero_or_one_exchange_have_no_invented_objects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Second", next);
|
||||
let link = maintained(&mut p, e1, e2);
|
||||
let plan = prepare(&p, f.owner, &[Target::ProcessLink(link)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([link]));
|
||||
assert!(plan.changed.is_empty());
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2]), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&p,
|
||||
&expected_without(&p, f.owner, &[], &[link]),
|
||||
plan.command,
|
||||
);
|
||||
for remaining in [vec![], vec![e1]] {
|
||||
let mut model = expected_without(&p, f.owner, &[e2], &[link]);
|
||||
if remaining.is_empty() {
|
||||
model = expected_without(&model, f.owner, &[e1], &[]);
|
||||
}
|
||||
let marker = action(&mut model, f.owner, "Marker", f.work[0]);
|
||||
let plan = prepare(&model, f.owner, &[local(marker)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(remaining.clone()), Some(remaining), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&model,
|
||||
&expected_without(&model, f.owner, &[marker], &[]),
|
||||
plan.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_global_contextual_dependencies_and_caller_modes_are_separate_from_record_deltas()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
let r1 = reuse(&mut p, caller, "Use one", f.owner);
|
||||
let r2 = reuse(&mut p, caller, "Use two", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
let outer_use = reuse(&mut p, outer, "Outer use", caller);
|
||||
let caller_link = contextual(
|
||||
&mut p,
|
||||
caller,
|
||||
qualified(&[r1], f.nodes[0]),
|
||||
qualified(&[r2], f.nodes[1]),
|
||||
precedence(),
|
||||
);
|
||||
let outer_link = contextual(
|
||||
&mut p,
|
||||
outer,
|
||||
qualified(&[outer_use, r1], f.nodes[0]),
|
||||
qualified(&[outer_use, r2], f.nodes[1]),
|
||||
precedence(),
|
||||
);
|
||||
let top = sequence(&mut p, layer, "Top");
|
||||
reuse(&mut p, top, "Top use", outer);
|
||||
let plan = prepare(&p, f.owner, &[local(f.nodes[0])]).unwrap();
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
BTreeSet::from([f.nodes[0], f.link, caller_link, outer_link])
|
||||
);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
let by_owner: BTreeMap<_, _> = plan
|
||||
.interpretation
|
||||
.iter()
|
||||
.map(|change| (change.owner, change))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
by_owner.keys().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([caller, outer, top])
|
||||
);
|
||||
for id in [caller, outer, top] {
|
||||
assert_eq!(by_owner[&id].before, interpretation(None, None, true));
|
||||
assert_eq!(by_owner[&id].after, interpretation(None, None, false));
|
||||
}
|
||||
assert_eq!(by_owner[&caller].callers, BTreeSet::from([outer, top]));
|
||||
assert_eq!(by_owner[&outer].callers, BTreeSet::from([top]));
|
||||
assert!(by_owner[&top].callers.is_empty());
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([
|
||||
(f.owner, BTreeSet::from([caller, outer, top])),
|
||||
(caller, BTreeSet::from([outer, top])),
|
||||
(outer, BTreeSet::from([top])),
|
||||
])
|
||||
);
|
||||
let expected = expected_without(
|
||||
&p,
|
||||
f.owner,
|
||||
&[f.nodes[0]],
|
||||
&[f.link, caller_link, outer_link],
|
||||
);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
// Removing an outer reuse removes only that use and paths through it.
|
||||
let outer_plan =
|
||||
prepare(&p, caller, &[local(r1), Target::ProcessLink(caller_link)]).unwrap();
|
||||
assert_eq!(
|
||||
outer_plan.removed,
|
||||
BTreeSet::from([r1, caller_link, outer_link])
|
||||
);
|
||||
let expected = expected_without(&p, caller, &[r1], &[caller_link, outer_link]);
|
||||
assert_history(&p, &expected, outer_plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_qualified_local_relations_preserve_every_purpose_source() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for purpose_kind in 0..3 {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let owner = sequence(&mut p, layer, "Assembly");
|
||||
let r1 = reuse(&mut p, owner, "First", f.owner);
|
||||
let r2 = reuse(&mut p, owner, "Second", f.owner);
|
||||
let (target, purpose) = match purpose_kind {
|
||||
0 => (f.nodes[1], precedence()),
|
||||
1 => (
|
||||
f.nodes[1],
|
||||
ContextualLinkPurpose::Exchange {
|
||||
exchange: f.exchange,
|
||||
},
|
||||
),
|
||||
_ => (
|
||||
f.nodes[0],
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
),
|
||||
};
|
||||
let relation = contextual(
|
||||
&mut p,
|
||||
owner,
|
||||
qualified(&[r1], f.nodes[0]),
|
||||
qualified(&[r2], target),
|
||||
purpose,
|
||||
);
|
||||
let plan = prepare(&p, owner, &[Target::ProcessLink(relation)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([relation]));
|
||||
assert!(plan.changed.is_empty());
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner,
|
||||
before: interpretation(None, None, true),
|
||||
after: interpretation(None, None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&p,
|
||||
&expected_without(&p, owner, &[], &[relation]),
|
||||
plan.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_predicate_discriminators_include_contextual_only_and_unlisted_contributions()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Second", f.exchange);
|
||||
// Core-valid incomplete source: desktop lists only exchanges, while the
|
||||
// index includes two unlisted actions and their CommonWork relation.
|
||||
let a1 = action(&mut p, f.owner, "Unlisted one", f.work[0]);
|
||||
let a2 = action(&mut p, f.owner, "Unlisted two", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != a1 && *id != a2);
|
||||
let relation = contextual(
|
||||
&mut p,
|
||||
f.owner,
|
||||
endpoint(a1),
|
||||
endpoint(a2),
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
);
|
||||
let plan = prepare(&p, f.owner, &[Target::ProcessLink(relation)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), None, true),
|
||||
after: interpretation(Some(vec![e1, e2]), None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
let mut p = expected_without(&p, f.owner, &[a1, a2], &[relation]);
|
||||
let unlisted = action(&mut p, f.owner, "Unlisted marker", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unlisted);
|
||||
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), None, false),
|
||||
after: interpretation(Some(vec![e2]), None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[local(unlisted), local(e1)]).is_err());
|
||||
let p = expected_without(&p, f.owner, &[unlisted], &[]);
|
||||
let mut p = p;
|
||||
let extra = exchange_step(&mut p, f.owner, "Unlisted exchange", f.exchange);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != extra);
|
||||
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2, extra]), false),
|
||||
after: interpretation(Some(vec![e2]), Some(vec![e2, extra]), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(&p, &expected_without(&p, f.owner, &[e1], &[]), plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_explicit_topology_still_reports_transitive_reused_effects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
maintained(&mut p, f.nodes[1], f.nodes[2]);
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
reuse(&mut p, caller, "First use", f.owner);
|
||||
reuse(&mut p, caller, "Repeated use", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
reuse(&mut p, outer, "Transitive use", caller);
|
||||
for target in [local(f.nodes[0]), Target::ProcessLink(f.link)] {
|
||||
let plan = prepare(&p, f.owner, &[target]).unwrap();
|
||||
assert!(
|
||||
plan.interpretation.is_empty(),
|
||||
"Explicit modes remain unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([(f.owner, BTreeSet::from([caller, outer]))])
|
||||
);
|
||||
assert!(!plan.changed.contains(&caller));
|
||||
assert!(!plan.changed.contains(&outer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_covered_and_independent_selected_links_delete_each_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, c] = f.nodes;
|
||||
let independent = maintained(&mut p, b, c);
|
||||
let targets = [
|
||||
Target::ProcessLink(independent),
|
||||
Target::ProcessLink(f.link),
|
||||
local(a),
|
||||
];
|
||||
let plan = prepare(&p, f.owner, &targets).unwrap();
|
||||
let reverse = prepare(&p, f.owner, &targets.into_iter().rev().collect::<Vec<_>>()).unwrap();
|
||||
assert_eq!(plan.command, reverse.command);
|
||||
assert_eq!(plan.removed, BTreeSet::from([a, f.link, independent]));
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
let Command::Batch(commands) = &plan.command else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
commands,
|
||||
&[Command::Delete(a), Command::Delete(independent)]
|
||||
);
|
||||
assert!(plan.interpretation.is_empty());
|
||||
let expected = expected_without(&p, f.owner, &[a], &[f.link, independent]);
|
||||
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[b, c]);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_exact_attachments_traces_placements_and_diagram_impact() {
|
||||
use oa_core::{ArtifactLink, ArtifactRelation, EdgeRoute, Position, Trace, TraceKind};
|
||||
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, _] = f.nodes;
|
||||
let overview = p.ensure_diagram(layer);
|
||||
// Overview explicitly includes Occurrence in ProjectionKind::includes;
|
||||
// saved placement validation admits these same-layer semantic elements.
|
||||
assert!(p.diagrams[&overview].projection.includes(Kind::Occurrence));
|
||||
let node = p.place(overview, a, Position { x: 100., y: 100. });
|
||||
let kept_node = p.place(overview, b, Position { x: 400., y: 100. });
|
||||
p.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: f.link,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 300., y: 80. }],
|
||||
label: None,
|
||||
});
|
||||
let other_view = Id::new_v4();
|
||||
let mut view = p.diagrams[&overview].clone();
|
||||
view.id = other_view;
|
||||
view.alias = p.fresh_alias("second_overview", "view", other_view);
|
||||
view.name = "Second overview".into();
|
||||
view.nodes.clear();
|
||||
view.routes.clear();
|
||||
p.diagrams.insert(other_view, view);
|
||||
let other_node = p.place(other_view, a, Position { x: 220., y: 160. });
|
||||
let opposite = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let counterpart = p
|
||||
.add_element(
|
||||
if opposite == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
opposite,
|
||||
"Preserved counterpart",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let unrelated_view = p.ensure_diagram(opposite);
|
||||
p.place(unrelated_view, counterpart, Position { x: 50., y: 75. });
|
||||
let traces: [Id; 2] = [a, b].map(|subject| {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: p.fresh_alias("manual_trace", "trace", id),
|
||||
name: "Manual correspondence".into(),
|
||||
description: "Preserve counterpart".into(),
|
||||
oa: if layer == Layer::Oa {
|
||||
subject
|
||||
} else {
|
||||
counterpart
|
||||
},
|
||||
sa: if layer == Layer::Sa {
|
||||
subject
|
||||
} else {
|
||||
counterpart
|
||||
},
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
// Include an attachment owned by the dependent placement: core impact
|
||||
// must follow the fixed point rather than only direct semantic subjects.
|
||||
let artifacts: [Id; 4] = [a, node, f.link, b].map(|owner| {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias("evidence", "artifact", id),
|
||||
name: "Evidence".into(),
|
||||
description: "Authored evidence".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "evidence.txt".into(),
|
||||
revision: Some("v1".into()),
|
||||
fragment: Some("review".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let constraint = p
|
||||
.add_element(Kind::Constraint, layer, "Surviving constraint", None)
|
||||
.unwrap();
|
||||
*p.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "review".into(),
|
||||
constrained: vec![a, b],
|
||||
};
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let plan = prepare(&p, f.owner, &[local(a)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
BTreeSet::from([
|
||||
a,
|
||||
f.link,
|
||||
node,
|
||||
other_node,
|
||||
traces[0],
|
||||
artifacts[0],
|
||||
artifacts[1],
|
||||
artifacts[2],
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
BTreeSet::from([f.owner, overview, other_view, constraint])
|
||||
);
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([(f.owner, BTreeSet::new())])
|
||||
);
|
||||
let mut expected = expected_without(&p, f.owner, &[a], &[f.link]);
|
||||
expected.traces.remove(&traces[0]);
|
||||
for id in &artifacts[..3] {
|
||||
expected.artifact_links.remove(id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&node);
|
||||
expected.diagrams.get_mut(&overview).unwrap().routes.clear();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&other_view)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&other_node);
|
||||
let Some(SharedBody::Constraint { constrained, .. }) =
|
||||
expected.elements.get_mut(&constraint).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*constrained = vec![b];
|
||||
assert_eq!(expected.elements[&counterpart], p.elements[&counterpart]);
|
||||
assert_eq!(
|
||||
expected.diagrams[&overview].nodes[&kept_node],
|
||||
p.diagrams[&overview].nodes[&kept_node]
|
||||
);
|
||||
assert_eq!(
|
||||
expected.diagrams[&unrelated_view],
|
||||
p.diagrams[&unrelated_view]
|
||||
);
|
||||
assert_eq!(expected.traces[&traces[1]], p.traces[&traces[1]]);
|
||||
assert_eq!(
|
||||
expected.artifact_links[&artifacts[3]],
|
||||
p.artifact_links[&artifacts[3]]
|
||||
);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Transient pointer ownership shared by the canvas and its App adapter.
|
||||
//!
|
||||
//! Iced queues messages until the entire native event batch has reached widgets.
|
||||
//! A later press must therefore revoke an earlier removal packet synchronously,
|
||||
//! without relying on the ordering of two queued application messages.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Lifetime(Arc<AtomicU64>);
|
||||
|
||||
impl Lifetime {
|
||||
/// Even generations are idle; odd generations belong to one live gesture.
|
||||
pub(super) fn idle(&self) -> Option<u64> {
|
||||
let generation = self.0.load(Ordering::SeqCst);
|
||||
(generation % 2 == 0).then_some(generation)
|
||||
}
|
||||
|
||||
/// Retire an idle packet at a native input boundary (including the
|
||||
/// viewport's synthetic Unfocused event when it takes camera ownership).
|
||||
pub(super) fn revoke_idle(&self) {
|
||||
if let Some(generation) = self.idle() {
|
||||
// Keep overflow terminal and unavailable rather than reusing zero.
|
||||
let next = generation.checked_add(2).unwrap_or(u64::MAX);
|
||||
let _ = self
|
||||
.0
|
||||
.compare_exchange(generation, next, Ordering::SeqCst, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire(&self) -> Option<Lease> {
|
||||
let generation = self.idle()?;
|
||||
let active = generation.checked_add(1)?;
|
||||
self.0
|
||||
.compare_exchange(generation, active, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.ok()?;
|
||||
Some(Lease {
|
||||
lifetime: self.clone(),
|
||||
active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Lease {
|
||||
lifetime: Lifetime,
|
||||
active: u64,
|
||||
}
|
||||
|
||||
impl Drop for Lease {
|
||||
fn drop(&mut self) {
|
||||
// A terminal odd generation stays unavailable on overflow. No stale
|
||||
// packet can become valid again through counter wraparound.
|
||||
if let Some(next) = self.active.checked_add(1) {
|
||||
let _ = self.lifetime.0.compare_exchange(
|
||||
self.active,
|
||||
next,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn removal_input_lease_revokes_idle_capture_and_drop_never_reuses_it() {
|
||||
let lifetime = Lifetime::default();
|
||||
let captured = lifetime.idle().unwrap();
|
||||
let clone = lifetime.clone();
|
||||
let lease = clone.acquire().unwrap();
|
||||
assert!(lifetime.idle().is_none());
|
||||
assert!(lifetime.acquire().is_none());
|
||||
drop(lease);
|
||||
assert!(lifetime.idle().is_some());
|
||||
assert_ne!(lifetime.idle(), Some(captured));
|
||||
let after_drop = lifetime.idle();
|
||||
lifetime.revoke_idle();
|
||||
assert_ne!(lifetime.idle(), after_drop);
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.selection = app.multiselect_snapshot();
|
||||
graph.removal_input = state.removal_input.clone();
|
||||
graph.selected_preview = state
|
||||
.qualified_selection(p, owner)
|
||||
.filter(|address| {
|
||||
@@ -574,6 +575,15 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
|
||||
if snapshot.targets().len() > 1 {
|
||||
controls = controls.push(
|
||||
button("Remove selected…").on_press_maybe(
|
||||
app.process_removal_enabled()
|
||||
.then(|| crate::app::process_removal::begin(app))
|
||||
.flatten(),
|
||||
),
|
||||
);
|
||||
}
|
||||
controls = controls.push(button("Clear selection").on_press_maybe(
|
||||
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
|
||||
));
|
||||
|
||||
@@ -77,7 +77,7 @@ impl Opened {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scenario_selection = if request.scenario.is_some() {
|
||||
let scenario_selection = if request.scenario.is_some() || request.process.is_some() {
|
||||
Some(app.multiselect_snapshot()?)
|
||||
} else {
|
||||
None
|
||||
@@ -437,21 +437,23 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
true,
|
||||
));
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& selected
|
||||
&& (selected || request.process.is_some())
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
@@ -465,9 +467,13 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
entries.push(Entry::title(
|
||||
"Drag contributions within one column to reorder",
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Hide unavailable · cards are model contributions",
|
||||
));
|
||||
if let Some(removal) = crate::app::process_removal::begin(app) {
|
||||
entries.push(item(
|
||||
"Remove selected contributions / links…",
|
||||
removal,
|
||||
true,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
@@ -475,9 +481,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
if request.process.is_none() {
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
}
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
|
||||
@@ -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.process.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::process_removal::view::view(app, target),
|
||||
700.,
|
||||
);
|
||||
}
|
||||
if target.scenario.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
|
||||
@@ -2292,6 +2292,26 @@ A qualified selection inside an expanded reusable process MUST NOT silently
|
||||
delete the outer reference or shared definition item. The editor directs the
|
||||
user to open the definition or explicitly select the outer reference.
|
||||
|
||||
For a group of local Process or FunctionalChain contributions and maintained
|
||||
links, removal MUST admit every original typed selection member before reducing
|
||||
overlapping cascade commands. A selected link already removed by a selected
|
||||
occurrence's cascade remains a selected effect in the review. A mixed local and
|
||||
qualified selection MUST refuse as a whole, without a primary-only fallback.
|
||||
A locally owned ContextualLink is distinct from a read-only relation displayed
|
||||
inside a reused definition; its explicit owner governs removal even when both
|
||||
endpoint paths are qualified.
|
||||
|
||||
The aggregate review MUST identify actual deleted records, changed surviving
|
||||
records, and affected caller definitions. Removing the last explicit topology
|
||||
marker can restore an exchange-only sequence's implicit ordered meaning;
|
||||
removing an interior exchange from a legacy order changes adjacent pairs.
|
||||
These consequences MUST be explicit before confirmation. Reused topology and
|
||||
entry/exit connections can also change without an interpretation-mode switch
|
||||
or new stored relation. Caller-definition counts MUST NOT be presented as
|
||||
counts of reference instances. Empty or incomplete surviving sequences retain
|
||||
their ordinary diagnostics; deletion MUST NOT invent replacement controls or
|
||||
links to conceal such consequences.
|
||||
|
||||
Confirmation identifies the captured object and removal effect. Cancel and
|
||||
Escape preserve model, source and history. A confirmation token belongs to one
|
||||
dialog; changes to its document, revision, owner or selected identity invalidate
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
arcadia 7;
|
||||
project process_removal_trial "Process removal trial" id "93600000-0000-4000-8000-000000000001" {
|
||||
oa_activity intake "Intake" id "93600000-0000-4000-8000-000000000002" { }
|
||||
oa_activity assess "Assess" id "93600000-0000-4000-8000-000000000003" { }
|
||||
oa_activity report "Report" id "93600000-0000-4000-8000-000000000004" { }
|
||||
interaction received "Received" id "93600000-0000-4000-8000-000000000005" { source @intake; target @assess; }
|
||||
interaction reported "Reported" id "93600000-0000-4000-8000-000000000006" { source @assess; target @report; }
|
||||
oa_process assessment "01 Assessment" id "93600000-0000-4000-8000-000000000010" { steps [@a,@e1,@c,@e2]; }
|
||||
oa_occurrence a "Review gate" id "93600000-0000-4000-8000-000000000011" { parent @assessment; body { Action { action @assess; lifeline none; }; }; }
|
||||
oa_occurrence e1 "Receive report" id "93600000-0000-4000-8000-000000000012" { parent @assessment; body { Exchange { exchange @received; sender none; receiver none; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence c "Review gate" id "93600000-0000-4000-8000-000000000013" { parent @assessment; body { Control { kind Choice; }; }; }
|
||||
oa_occurrence e2 "Forward report" id "93600000-0000-4000-8000-000000000014" { parent @assessment; body { Exchange { exchange @reported; sender none; receiver none; message_kind Asynchronous; }; }; }
|
||||
sequence_link approved "Gate approval" id "93600000-0000-4000-8000-000000000015" { source @a; target @c; guard "approved"; }
|
||||
oa_process caller "02 Response using Assessment" id "93600000-0000-4000-8000-000000000020" { steps [@r1,@r2]; }
|
||||
oa_occurrence r1 "First assessment" id "93600000-0000-4000-8000-000000000021" { parent @caller; body { SequenceReference { sequence @assessment; }; }; }
|
||||
oa_occurrence r2 "Second assessment" id "93600000-0000-4000-8000-000000000022" { parent @caller; body { SequenceReference { sequence @assessment; }; }; }
|
||||
oa_process outer "03 Mission using Response" id "93600000-0000-4000-8000-000000000030" { steps [@r3]; }
|
||||
oa_occurrence r3 "Response use" id "93600000-0000-4000-8000-000000000031" { parent @outer; body { SequenceReference { sequence @caller; }; }; }
|
||||
sa_function sentinel "SA preserved function" id "93600000-0000-4000-8000-000000000040" { }
|
||||
sa_functional_chain sentinel_chain "SA preserved chain" id "93600000-0000-4000-8000-000000000041" { steps [@sentinel_step]; }
|
||||
sa_occurrence sentinel_step "SA preserved contribution" id "93600000-0000-4000-8000-000000000042" { parent @sentinel_chain; body { Action { action @sentinel; lifeline none; }; }; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
arcadia 7;
|
||||
project process_removal_trial "Process removal trial" id "93600000-0000-4000-8000-000000000001" {
|
||||
oa_activity intake "Intake" id "93600000-0000-4000-8000-000000000002" { }
|
||||
oa_activity assess "Assess" id "93600000-0000-4000-8000-000000000003" { }
|
||||
oa_activity report "Report" id "93600000-0000-4000-8000-000000000004" { }
|
||||
interaction received "Received" id "93600000-0000-4000-8000-000000000005" { source @intake; target @assess; }
|
||||
interaction reported "Reported" id "93600000-0000-4000-8000-000000000006" { source @assess; target @report; }
|
||||
oa_process assessment "01 Assessment" id "93600000-0000-4000-8000-000000000010" { steps [@e1, @e2]; }
|
||||
oa_occurrence e1 "Receive report" id "93600000-0000-4000-8000-000000000012" { parent @assessment; body { Exchange { exchange @received; sender none; receiver none; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence e2 "Forward report" id "93600000-0000-4000-8000-000000000014" { parent @assessment; body { Exchange { exchange @reported; sender none; receiver none; message_kind Asynchronous; }; }; }
|
||||
oa_process caller "02 Response using Assessment" id "93600000-0000-4000-8000-000000000020" { steps [@r1,@r2]; }
|
||||
oa_occurrence r1 "First assessment" id "93600000-0000-4000-8000-000000000021" { parent @caller; body { SequenceReference { sequence @assessment; }; }; }
|
||||
oa_occurrence r2 "Second assessment" id "93600000-0000-4000-8000-000000000022" { parent @caller; body { SequenceReference { sequence @assessment; }; }; }
|
||||
oa_process outer "03 Mission using Response" id "93600000-0000-4000-8000-000000000030" { steps [@r3]; }
|
||||
oa_occurrence r3 "Response use" id "93600000-0000-4000-8000-000000000031" { parent @outer; body { SequenceReference { sequence @caller; }; }; }
|
||||
sa_function sentinel "SA preserved function" id "93600000-0000-4000-8000-000000000040" { }
|
||||
sa_functional_chain sentinel_chain "SA preserved chain" id "93600000-0000-4000-8000-000000000041" { steps [@sentinel_step]; }
|
||||
sa_occurrence sentinel_step "SA preserved contribution" id "93600000-0000-4000-8000-000000000042" { parent @sentinel_chain; body { Action { action @sentinel; lifeline none; }; }; }
|
||||
}
|
||||
@@ -110,6 +110,11 @@ whole-set local admission, aggregate impact and one-step history. Its
|
||||
tested source, mixed participant/message workflow, exact saved-source recovery
|
||||
and explicit coverage limits; default-branch integration remains separate.
|
||||
|
||||
The [Process removal policy](process-group-removal-policy-20260925.md) defines
|
||||
local occurrence/link admission, cascade normalization, implicit-order and
|
||||
caller consequences, and captured one-step removal. It is a development policy;
|
||||
its existence does not establish App, native or integration acceptance.
|
||||
|
||||
## Acceptance and dependencies
|
||||
|
||||
1. Prove simultaneous geometry planning with unrelated roots/internal routes,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Process and FunctionalChain group removal
|
||||
|
||||
This policy refines the local Process part of the [multiple-selection
|
||||
contract](multiselect-contract-20260923.md) and the focused-removal semantics in
|
||||
[language section 8.1](../docs/language.rst). It is an implementation decision,
|
||||
not runtime, native, integrated or release acceptance.
|
||||
|
||||
## Exact admission and one transaction
|
||||
|
||||
Remove the complete captured group of local contribution occurrences and
|
||||
maintained links. An occurrence must be a unique listed direct child of the
|
||||
active Process or FunctionalChain, with the matching layer and an Action,
|
||||
Exchange, Control or SequenceReference body. A SequenceLink must connect listed
|
||||
local contributions of that same owner. A ContextualLink is local when its
|
||||
explicit owner matches, even if both endpoint addresses are qualified. Its
|
||||
Precedence, Exchange and CommonWork purposes retain their distinct meanings.
|
||||
|
||||
A qualified ProcessOccurrence or ProcessPreviewLink is not an editable local
|
||||
object. Refuse the whole mixed selection; never strip its reference path, edit
|
||||
the reused definition, select an outer reference implicitly, or fall back to
|
||||
the primary. Open the defining sequence to edit its contents. Legacy ordered
|
||||
arrows have no maintained relation identity and cannot be deleted as links.
|
||||
|
||||
Admit every original typed target before normalizing execution. Selected local
|
||||
occurrences are independent deletion roots. Compute their existing core
|
||||
deletion-impact union, and omit a selected relation command only when that
|
||||
relation is already covered by this union. It remains a selected removal in the
|
||||
review. Blindly deleting both an occurrence and its incident selected link would
|
||||
fail because the first cascade already removed the second identity.
|
||||
|
||||
Evaluate one Batch with the core command engine and final structural validation.
|
||||
Derive deleted identities and changed surviving records from the actual final
|
||||
candidate, including embedded diagram placements. All selected identities must
|
||||
be removed and no identity may be created. Preserve referenced work, exchanges,
|
||||
sequence definitions, other uses and opposite-layer counterparts. Global
|
||||
dependent ContextualLinks, attachments and traces follow existing core cascade
|
||||
rules; never shorten their paths or silently retarget them.
|
||||
|
||||
## Order and caller consequences
|
||||
|
||||
Deletion can change meaning without creating any new stored record. Given
|
||||
`[Action A, Exchange E1, Control C, Exchange E2]` and no maintained links,
|
||||
removing A and C leaves implicit E1-to-E2 order. Removing the last authored
|
||||
SequenceLink on an exchange-only list likewise restores implicit order, which
|
||||
may retain a visible connection that the user expected to remove. Removing E2
|
||||
from legacy `[E1, E2, E3]` creates the implicit E1-to-E3 adjacency. A remainder
|
||||
with zero or one exchange changes interpretation without adding an edge.
|
||||
|
||||
The review therefore compares before/after interpretation for every surviving
|
||||
Process and FunctionalChain, independently of whether its stored record changed:
|
||||
|
||||
- Local implicit order follows the existing desktop listed-step/SequenceLink
|
||||
predicate. Explicit mode is distinct from an empty implicit sequence.
|
||||
- Reused-definition implicit order follows the core contextual expansion
|
||||
predicate: indexed contributions (including owned but unlisted contributions)
|
||||
must all be exchanges and the definition must have no maintained SequenceLink
|
||||
or ContextualLink. This is a definition-level order fact, not a claim that the
|
||||
entire expanded graph was analyzed or is connected.
|
||||
- Contextual analysis follows the core index's transitive contextual-link
|
||||
presence. Losing the last such link is reported separately from either order
|
||||
predicate; it does not necessarily mean a local explicit-to-implicit switch.
|
||||
|
||||
For each changed interpretation, identify surviving direct and transitive
|
||||
callers through the union of before/after SequenceReference dependencies. Such
|
||||
callers can be affected while their stored records remain unchanged. Keep these
|
||||
effects separate from removed/updated record counts and make the full affected
|
||||
set searchable and pageable, with full names and aliases.
|
||||
|
||||
Also seed caller impact from definitions whose contributions or maintained links
|
||||
are removed or changed, even when all three interpretation fields stay the same.
|
||||
For example, removing A from explicit A-to-B-to-C changes every use of that
|
||||
definition while the remaining B-to-C link keeps its mode explicit. Label the
|
||||
caller effect as using a changed definition; do not imply that complete expanded
|
||||
connectivity or boundary analysis was performed. Interpretation-only detection
|
||||
would miss this ordinary case and is rejected.
|
||||
|
||||
Explain that reused topology, entry/exit connections and diagnostics may change.
|
||||
Deleting A-to-B while B-to-C remains can make B a new intrinsic entry and thus
|
||||
add an effective reference-boundary connection in callers. No new stored
|
||||
identity does not mean no new effective connection.
|
||||
|
||||
Caller rows identify definitions, not individual SequenceReference instances;
|
||||
two references to P in Q still yield one Q row. Never present that count as the
|
||||
number of uses. An empty expanded-order classification supplies no edges and
|
||||
does not establish a valid flow or boundary: core expansion of an empty sequence
|
||||
is incomplete. Say that no contributions/ordered pairs remain, preserving the
|
||||
ordinary completeness diagnostics.
|
||||
|
||||
Incomplete models may have owned but unlisted contributions. Selected unlisted
|
||||
cards are ineligible, but other such contributions must not be ignored in impact
|
||||
computation. The local and expanded predicates can differ; report their actual
|
||||
results separately rather than falsely treating them as one execution mode.
|
||||
This operation does not resolve that broader semantic inconsistency or suppress
|
||||
existing diagnostics.
|
||||
|
||||
Alternatives considered: rejecting all interpretation changes prevents normal
|
||||
editing of valid incomplete models; silently accepting them hides an engineering
|
||||
consequence; inventing sentinel controls or replacement links manufactures model
|
||||
objects; a new persisted topology-mode field requires an independent language,
|
||||
migration and API decision. Explicitly reviewing the existing source-derived
|
||||
consequences preserves graphical/textual parity without introducing new objects.
|
||||
|
||||
## Capture, review and verification
|
||||
|
||||
The App captures document, revision, owner, canvas epoch, exact selection
|
||||
generation, membership and primary, and verifies actual rendered visibility.
|
||||
Pending source/property/behavior edits, gestures, other modals and non-selection
|
||||
tools refuse admission. Header, context menu, focused Delete and Shift+Delete
|
||||
must all capture the complete group before ordinary singleton handlers can act.
|
||||
Invoking a menu over an unselected object does not replace the existing group.
|
||||
|
||||
Only explicit confirmation of the current token commits one Document
|
||||
transaction. Recheck complete admission before applying it. Search, page,
|
||||
Cancel and Confirm packets belong to their review token. Enter in search does
|
||||
not implicitly confirm deletion. Cancel, stale/refused events and source-write
|
||||
failure preserve the accepted model/source/history, selection and camera.
|
||||
Success returns inspection to the owner and retires removed transient selection;
|
||||
one model Undo/Redo restores the exact complete source and model.
|
||||
|
||||
Required discriminators include OA and SA mixed occurrence/link groups;
|
||||
cascade-covered selected links in different click/UUID orders; repeated reuse
|
||||
and globally dependent links; all ContextualLink purposes; local plus qualified
|
||||
whole-set refusal; explicit-to-implicit and legacy adjacency changes; zero/one
|
||||
remainders; contextual-analysis-only changes; surviving transitive callers;
|
||||
owned-unlisted contributions; stale secondary selection and tokens; pending
|
||||
drafts; exact cancel/failure/Undo/Redo/save/reopen behavior. Tests must compare
|
||||
literal expected identities and models, not only counts or the planner's own
|
||||
output. Native acceptance must inspect meaningful review wording, full impact
|
||||
reachability, focus, compact layout, cancellation and persistence.
|
||||
Reference in New Issue
Block a user