Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8db66080d | ||
|
|
fa9d89efad | ||
|
|
b137d45727 | ||
|
|
84cd5ef56c | ||
|
|
1b683ea336 | ||
|
|
3ab63ef028 | ||
|
|
8688faf8c5 | ||
|
|
7750ecd751 | ||
|
|
afcea12cd9 | ||
|
|
7323a80e94 | ||
|
|
572e96ba03 | ||
|
|
d93c0a8012 | ||
|
|
f20497b29b | ||
|
|
fe637dcd27 | ||
|
|
c62622cce7 | ||
|
|
2aaea8a3a0 | ||
|
|
f0e8746950 | ||
|
|
43340b94a1 | ||
|
|
35be9ae133 | ||
|
|
81db58c8b3 | ||
|
|
ed6eaaa73e | ||
|
|
cdd5a0462b | ||
|
|
1c4099a9ef | ||
|
|
201097793c | ||
|
|
14df924b98 | ||
|
|
04485e0017 | ||
|
|
9bfa113aa5 | ||
|
|
652ffb4d1b | ||
|
|
c9f6a3da40 | ||
|
|
f3ed6e97dd | ||
|
|
61099ed474 | ||
|
|
a16e014049 | ||
|
|
cae7a22297 | ||
|
|
5381729869 | ||
|
|
8d6fac4701 | ||
|
|
4a27c8ef0b | ||
|
|
67534ce4a5 | ||
|
|
3b113e5316 | ||
|
|
3b2bb8ba78 | ||
|
|
ed55b1781d | ||
|
|
ca1f18e6b0 | ||
|
|
a0a70340a6 | ||
|
|
879eb1d5c8 | ||
|
|
47917dd0ef | ||
|
|
7d3d7b23e7 |
@@ -12,6 +12,12 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
harness/scenario-selection-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-group-spacing-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-reorder-interaction-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
harness/scenario-removal-followup-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/process-removal-removed-v2-20260925.arcadia -text whitespace=-blank-at-eol
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
@@ -89,3 +95,12 @@ harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-bl
|
||||
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact Process removal review and portable evidence bytes.
|
||||
harness/process-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact saved-view planner review and portable evidence bytes.
|
||||
harness/saved-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact saved-view application check records.
|
||||
harness/saved-group-removal-app-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Root<'_> {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &self.app.saved_removal_input,
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
|
||||
@@ -19,12 +19,18 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
pub(crate) mod process_removal;
|
||||
mod reference_authoring;
|
||||
mod removal_review;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod saved_removal;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_multiselect;
|
||||
pub(crate) mod scenario_removal;
|
||||
pub(crate) mod scenario_reorder;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
@@ -215,11 +221,14 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, f.sender,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(context),
|
||||
"same-owner selection retains gesture context"
|
||||
let fresh = app.scenario_context().unwrap();
|
||||
assert_eq!(fresh.visit, context.visit);
|
||||
assert_eq!(fresh.representation, context.representation);
|
||||
assert_ne!(
|
||||
fresh.epoch, context.epoch,
|
||||
"selection retires older gestures"
|
||||
);
|
||||
let context = fresh;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: created,
|
||||
@@ -437,6 +446,10 @@ pub enum Message {
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||
ScenarioRemoval(Box<scenario_removal::Event>),
|
||||
ProcessRemoval(Box<process_removal::Event>),
|
||||
SavedRemoval(Box<saved_removal::Event>),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
@@ -523,12 +536,14 @@ pub struct App {
|
||||
pub guide: crate::guide::State,
|
||||
pub traceability: crate::views::traceability::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub(crate) scenario_reorder: scenario_reorder::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
pub selected_node: Option<Id>,
|
||||
pub diagram_selection: Option<crate::diagram_selection::Selection>,
|
||||
pub(crate) saved_removal_input: crate::removal_input::Lifetime,
|
||||
pub draft: Option<Draft>,
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
@@ -645,12 +660,14 @@ impl App {
|
||||
guide: crate::guide::State::default(),
|
||||
traceability: Default::default(),
|
||||
behavior: Default::default(),
|
||||
scenario_reorder: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
diagram_selection: None,
|
||||
saved_removal_input: Default::default(),
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
@@ -1348,8 +1365,128 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Group dialogs only accept their stamped Cancel button. A delayed
|
||||
// singleton dialog packet cannot close a later aggregate review.
|
||||
if matches!(message, Message::CancelDelete)
|
||||
&& self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.scenario.is_some() || target.process.is_some() || target.saved.is_some()
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::SavedRemoval(event) = message {
|
||||
return self.saved_removal_event(*event);
|
||||
}
|
||||
if let Message::StateDiagram(behavior::state_edit::Event::ModelRemoval(event)) = message {
|
||||
return self.saved_removal_event(*event);
|
||||
}
|
||||
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() || target.saved.is_some())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::ScenarioRemoval(event) = message {
|
||||
return self.scenario_removal_event(*event);
|
||||
}
|
||||
if let Message::ConfirmDelete(token) = &message {
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_none_or(|target| target.snapshot.token != *token)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.saved.is_some())
|
||||
{
|
||||
self.confirm_saved_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.scenario.is_some())
|
||||
{
|
||||
self.confirm_scenario_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
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.
|
||||
if let Message::ScenarioReorder(event) = message {
|
||||
return self.scenario_reorder_event(*event);
|
||||
}
|
||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
|
||||
{
|
||||
return self.scenario_selection_event(*event);
|
||||
}
|
||||
// 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.process.is_some() || request.canvas.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.process.is_some()
|
||||
|| menu.request.canvas.is_some())
|
||||
&& (menu.token != *token || !menu.valid(self))
|
||||
{
|
||||
// Consume an expired menu's own packet without touching a newer
|
||||
// menu or crossing source/selection boundaries.
|
||||
if menu.token == *token {
|
||||
self.model_menu = None;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Command(Command::MoveStep(..)) | Message::Delete
|
||||
)
|
||||
{
|
||||
self.status =
|
||||
"Use Reorder or Remove above the canvas to act on the complete Scenario selection."
|
||||
.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| {
|
||||
self.multiselect_snapshot()
|
||||
.map(|selection| (context, selection))
|
||||
});
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(captured, _)
|
||||
@@ -1518,6 +1655,16 @@ impl App {
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
event,
|
||||
behavior::scenario::CanvasEvent::Select(..)
|
||||
| behavior::scenario::CanvasEvent::Remove(..)
|
||||
| behavior::scenario::CanvasEvent::Reorder { .. }
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Passive layout/form reports retain their existing epoch rules;
|
||||
// camera input uses its separate lifetime below. All other actions
|
||||
// must match the exact context before shared reveal reconciliation.
|
||||
@@ -1605,9 +1752,20 @@ impl App {
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
if let Some((context, selection)) = scenario_selection_before
|
||||
&& self.scenario_context() == Some(context)
|
||||
&& self.multiselect_snapshot().as_ref() != Some(&selection)
|
||||
{
|
||||
// Legacy singleton selection retires old spacing packets. If the
|
||||
// action already advanced its context, its new navigation/reveal
|
||||
// owns that context and must not be retired by reconciliation.
|
||||
self.behavior.scenario.cancel_spacing();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
||||
self.refresh_draft();
|
||||
}
|
||||
self.scenario_reorder_reconcile();
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -2158,6 +2316,10 @@ impl App {
|
||||
}
|
||||
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
|
||||
Message::Hosted(event) => return self.hosted_event(event),
|
||||
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
|
||||
Message::ScenarioRemoval(event) => return self.scenario_removal_event(*event),
|
||||
Message::ProcessRemoval(event) => return self.process_removal_event(*event),
|
||||
Message::SavedRemoval(event) => return self.saved_removal_event(*event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
@@ -2666,6 +2828,9 @@ impl App {
|
||||
if request.process.is_some() {
|
||||
request.process = self.topology_context().map(Box::new);
|
||||
}
|
||||
if request.scenario.is_some() {
|
||||
request.scenario = self.scenario_context().map(Box::new);
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
@@ -2695,9 +2860,21 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
if matches!(
|
||||
*action,
|
||||
Message::DiagramSelection(_) | Message::SavedRemoval(_)
|
||||
) || (menu.request.process.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::TopologyCanvas(_) | Message::ProcessRemoval(_)
|
||||
))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::ScenarioCanvas(_)
|
||||
| Message::ScenarioReorder(_)
|
||||
| Message::ScenarioRemoval(_)
|
||||
))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2740,6 +2917,10 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
self.scenario_reorder_cancel();
|
||||
return Task::none();
|
||||
}
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
||||
@@ -3608,9 +3789,9 @@ 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."
|
||||
"Use Delete from model above the canvas to review the complete selection, or Hide selected to remove placements."
|
||||
}.into();
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
|
||||
@@ -67,6 +67,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
@@ -299,6 +300,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -179,7 +179,7 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
// Keep selection and epoch unchanged while moving to another Scenario owner.
|
||||
let captured = app.scenario_context().unwrap();
|
||||
assert_eq!(captured.scenario, owners[0]);
|
||||
assert_eq!(
|
||||
@@ -197,9 +197,28 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
|
||||
assert_eq!(other.epoch, captured.epoch);
|
||||
assert_eq!(app.selected, uses[0]);
|
||||
assert!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the independent owner guard must reject an otherwise current token"
|
||||
!app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the real captured snapshot rejects the changed Scenario destination"
|
||||
);
|
||||
// Isolate the independent behavior-owner predicate: a current, unqualified
|
||||
// object request has no old Scenario envelope or destination to reject it.
|
||||
// This test-only replacement does not refresh the captured behavior owner.
|
||||
let otherwise_current = crate::model_menu::Opened::new(
|
||||
&app,
|
||||
crate::model_menu::Request::object(uses[0], Point::ORIGIN),
|
||||
token,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(otherwise_current.valid(&app));
|
||||
let target = app.delete_target.as_ref().unwrap();
|
||||
assert_eq!(target.selection, app.selected);
|
||||
assert_eq!(target.placement, None);
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert_eq!(target.behavior_owner, Some(owners[0]));
|
||||
assert_eq!(diagram_removal::behavior_owner(&app), Some(owners[1]));
|
||||
assert!(app.delete_pending);
|
||||
assert!(!app.behavior.has_pending() && !app.source_dirty() && !app.draft_dirty());
|
||||
app.delete_target.as_mut().unwrap().snapshot = otherwise_current;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct Confirmation {
|
||||
pub selection: Id,
|
||||
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(crate) saved: Option<super::saved_removal::Review>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
@@ -209,6 +212,9 @@ impl App {
|
||||
selection: self.selected,
|
||||
placement: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
scenario: None,
|
||||
process: None,
|
||||
saved: None,
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
|
||||
//! Keep overlay ownership ahead of cancellation and captured reorder Apply.
|
||||
//!
|
||||
//! Iced skips root.update when an overlay captures an event. Keeping this
|
||||
//! wrapper present even when inactive preserves the child's widget state. A
|
||||
@@ -24,9 +24,34 @@ 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() || target.saved.is_some())
|
||||
})
|
||||
.map(|target| {
|
||||
if target.saved.is_some() {
|
||||
return Message::SavedRemoval(Box::new(super::saved_removal::Event::Cancel {
|
||||
token: target.snapshot.token,
|
||||
}));
|
||||
}
|
||||
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: [
|
||||
architecture_authoring::captured_escape_message(app),
|
||||
modal.then_some(Message::Escape),
|
||||
modal.then_some(Message::Escape).or_else(|| {
|
||||
app.scenario_reorder.session.as_ref().map(|session| {
|
||||
crate::behavior::scenario::reorder_view::message(
|
||||
session,
|
||||
super::scenario_reorder::Intent::Cancel,
|
||||
)
|
||||
})
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -34,6 +59,9 @@ 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>,
|
||||
}
|
||||
|
||||
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
@@ -87,6 +115,43 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.reorder_active
|
||||
&& matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Enter), modifiers, .. })
|
||||
if modifiers.control() && !modifiers.alt() && !modifiers.shift() && !modifiers.logo())
|
||||
{
|
||||
// A picker may have opened earlier in this same input batch, before
|
||||
// App state can invalidate the captured reorder action.
|
||||
if let Some(element) = self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let mut current = overlay::Nested::new(element);
|
||||
let node = current.layout(renderer, viewport.size());
|
||||
current.update(
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { repeat: false, .. })
|
||||
) && let Some(message) = &self.reorder_apply
|
||||
{
|
||||
shell.publish(message.clone());
|
||||
}
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
let escape = matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
@@ -99,6 +164,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,
|
||||
@@ -120,6 +190,13 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
if self.reorder_active
|
||||
&& let Some(cancel) = &self.messages[1]
|
||||
{
|
||||
shell.publish(cancel.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
|
||||
@@ -100,6 +100,7 @@ impl App {
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
self.scenario_context(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
@@ -109,6 +110,9 @@ impl App {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
if let Some(current) = self.scenario_context() {
|
||||
return Some(crate::behavior::scenario::selection::context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
@@ -119,6 +123,14 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.scenario_context() {
|
||||
return crate::behavior::scenario::selection::visible(
|
||||
self.project(),
|
||||
context,
|
||||
self.selected,
|
||||
)
|
||||
.contains(target);
|
||||
}
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
@@ -152,6 +164,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
// Scenario input additionally needs its representation and visit.
|
||||
if self.scenario_context().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
@@ -219,6 +235,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.reconcile_scenario_multiselect();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
@@ -284,6 +304,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn inspect_multiselect_primary(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 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,
|
||||
saved: 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,646 @@
|
||||
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"),
|
||||
// V1 assumed canonical whitespace. V2 independently retains removed-token
|
||||
// trivia and existing reference spelling under language section 7's rules.
|
||||
"/../../harness/fixtures/process-removal-removed-v2-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;
|
||||
// Replace the generated split workspace just as open_source_project does.
|
||||
// sync preserves discovered files and is not a new-document initializer.
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.sync();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.source_workspace.bundle(), native_bundle(BASELINE));
|
||||
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,156 @@
|
||||
//! 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::Project;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(super) fn matching_rows(
|
||||
project: &Project,
|
||||
review: &Review,
|
||||
) -> Vec<super::super::removal_review::ImpactRow> {
|
||||
let selected = review
|
||||
.snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.filter_map(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) if endpoint.references.is_empty() => {
|
||||
Some(endpoint.occurrence)
|
||||
}
|
||||
Target::ProcessLink(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
super::super::removal_review::matching_rows(
|
||||
project,
|
||||
&review.plan.removed,
|
||||
&review.plan.changed,
|
||||
&selected,
|
||||
&review.plan.interpretation,
|
||||
&review.plan.affected_uses,
|
||||
&review.query,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Shared stored-record and derived Process effect descriptions.
|
||||
use oa_core::{Id, Project};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) struct ImpactRow {
|
||||
pub(crate) label: String,
|
||||
pub(crate) disposition: String,
|
||||
}
|
||||
|
||||
pub(crate) fn matching_rows(
|
||||
project: &Project,
|
||||
removed: &BTreeSet<Id>,
|
||||
changed: &BTreeSet<Id>,
|
||||
selected: &BTreeSet<Id>,
|
||||
interpretation: &[crate::removal_effects::InterpretationChange],
|
||||
affected_uses: &BTreeMap<Id, BTreeSet<Id>>,
|
||||
query: &str,
|
||||
) -> 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 mut rows = vec![];
|
||||
for id in removed {
|
||||
rows.push(ImpactRow {
|
||||
label: label(*id),
|
||||
disposition: if selected.contains(id) {
|
||||
"Removed · selected"
|
||||
} else {
|
||||
"Additional removed"
|
||||
}
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
for id in 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 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 = affected_uses.clone();
|
||||
for impact in 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 = 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()
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Explicit saved-view model deletion: one captured group and one transaction.
|
||||
use super::*;
|
||||
use crate::diagram::group_removal;
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::{model_menu, route_edit::Context};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
input_generation: u64,
|
||||
context: 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: Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) plan: group_removal::Plan,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
}
|
||||
|
||||
fn context(app: &App) -> Option<Context> {
|
||||
let current = match app.page {
|
||||
Page::Diagram => app.route_context()?,
|
||||
Page::Behavior if app.topology_context().is_none() && app.scenario_context().is_none() => {
|
||||
app.state_context()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let saved = app.project().diagrams.get(¤t.diagram)?;
|
||||
(saved.projection.is_architecture() || saved.projection.state_machine().is_some())
|
||||
.then_some(current)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
let input_generation = app.saved_removal_input.idle()?;
|
||||
let context = context(app)?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::SavedRemoval(Box::new(Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn saved_removal_selecting(&self) -> bool {
|
||||
self.connection.is_none()
|
||||
&& self.selected_bend.is_none()
|
||||
&& match self.page {
|
||||
Page::Diagram => self
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline() && !state.saved.placing),
|
||||
Page::Behavior => {
|
||||
self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn saved_removal_enabled(&self) -> bool {
|
||||
diagram_removal::enabled(self)
|
||||
&& !self.source_rename.open()
|
||||
&& self.saved_removal_selecting()
|
||||
&& self.saved_removal_input.idle().is_some()
|
||||
&& context(self).is_some()
|
||||
}
|
||||
|
||||
fn saved_removal_matches(&self, captured: Context, snapshot: &Snapshot) -> bool {
|
||||
context(self) == Some(captured)
|
||||
&& self.diagram_selection.as_ref().is_some_and(|selection| {
|
||||
selection.matches(snapshot, multiselect::context(captured))
|
||||
})
|
||||
&& snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| self.multiselect_target_visible(target))
|
||||
}
|
||||
|
||||
pub(super) fn saved_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin {
|
||||
input_generation,
|
||||
context: captured,
|
||||
snapshot,
|
||||
} => {
|
||||
// Refuse before source/editor/selection boundaries can disturb a
|
||||
// newer interaction, including a later press in the same batch.
|
||||
if self.saved_removal_input.idle() != Some(input_generation)
|
||||
|| !self.saved_removal_matches(captured, &snapshot)
|
||||
|| !self.saved_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan = match group_removal::prepare(
|
||||
self.project(),
|
||||
captured.diagram,
|
||||
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, placement) = match &primary_target {
|
||||
Target::Placement(id) => (
|
||||
self.project().diagrams[&captured.diagram].nodes[id].element,
|
||||
Some(*id),
|
||||
),
|
||||
Target::Port { port, .. } => (*port, None),
|
||||
Target::Edge(id) => (*id, None),
|
||||
Target::StateRow { relation, .. } => (*relation, None),
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let Some(token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = context(self).expect("admitted saved view");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.selection_target = Some(Box::new(primary_target));
|
||||
self.menu_sequence = token;
|
||||
self.delete_target = model_menu::Opened::new(self, request, token).map(|opened| {
|
||||
diagram_removal::Confirmation {
|
||||
snapshot: opened,
|
||||
selection: self.selected,
|
||||
placement,
|
||||
behavior_owner: None,
|
||||
scenario: None,
|
||||
process: None,
|
||||
saved: 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(review) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
.and_then(|target| target.saved.as_mut())
|
||||
{
|
||||
review.query = query;
|
||||
review.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page { token, page } => {
|
||||
if let Some(review) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
.and_then(|target| target.saved.as_mut())
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.snapshot.token == token && target.saved.is_some())
|
||||
{
|
||||
self.delete_target = None;
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Model deletion cancelled. Model and selection unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn saved_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.saved else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.saved_removal_input.idle() == Some(review.input_generation)
|
||||
&& self.saved_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.saved_removal_selecting()
|
||||
}
|
||||
|
||||
pub(super) fn confirm_saved_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.saved.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.saved_removal_review_valid(target);
|
||||
let target = self.delete_target.take().expect("captured saved review");
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if !valid {
|
||||
self.status = "Model deletion review expired. Nothing was deleted; review the current selection again.".into();
|
||||
return;
|
||||
}
|
||||
let review = target.saved.expect("saved review");
|
||||
let machine = self.project().diagrams[&review.context.diagram]
|
||||
.projection
|
||||
.state_machine();
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected_node = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
// A selected machine can own the current saved view. Retire
|
||||
// those UI references only after its transaction succeeds.
|
||||
if self
|
||||
.behavior
|
||||
.active_owner
|
||||
.is_some_and(|id| !self.project().contains(id))
|
||||
{
|
||||
self.behavior.active_owner = None;
|
||||
}
|
||||
if self
|
||||
.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.is_some_and(|id| !self.project().diagrams.contains_key(&id))
|
||||
{
|
||||
self.behavior.diagram = Default::default();
|
||||
}
|
||||
if self.page == Page::Behavior
|
||||
&& let Some(machine) = machine.filter(|id| self.project().contains(*id))
|
||||
{
|
||||
self.selected = machine;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
} else {
|
||||
self.selected = if self
|
||||
.project()
|
||||
.diagrams
|
||||
.contains_key(&review.context.diagram)
|
||||
{
|
||||
review.context.diagram
|
||||
} else {
|
||||
self.project().id
|
||||
};
|
||||
}
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Deleted {} selected model subjects in one change across all views. Undo restores the model.",
|
||||
review.plan.selected.len()
|
||||
);
|
||||
}
|
||||
Err(error) => self.status = format!("Nothing deleted: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! Saved semantic removal through App, with independently edited complete models.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::*;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn saved(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("saved_{}", id.simple()),
|
||||
name: "Saved view".into(),
|
||||
description: "Authored view metadata must survive".into(),
|
||||
layer,
|
||||
projection,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn architecture(project: Project, diagram: Id) -> App {
|
||||
validate_candidate(&project).unwrap();
|
||||
let layer = project.diagrams[&diagram].layer;
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
app
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, targets: &[Target]) {
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
if index == 0 {
|
||||
multiselect::Choice::Replace
|
||||
} else {
|
||||
multiselect::Choice::Add
|
||||
},
|
||||
)));
|
||||
}
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), targets);
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::SavedRemoval(Box::new(event)));
|
||||
}
|
||||
|
||||
fn open(app: &mut App) -> u64 {
|
||||
assert!(app.saved_removal_enabled());
|
||||
let message = begin(app).expect("captured saved group");
|
||||
let _ = app.update(message);
|
||||
let confirmation = app.delete_target.as_ref().expect("saved group review");
|
||||
assert!(app.saved_removal_review_valid(confirmation));
|
||||
assert!(confirmation.saved.is_some());
|
||||
confirmation.snapshot.token
|
||||
}
|
||||
|
||||
fn source_matches(app: &App) {
|
||||
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
|
||||
fn mounted_fixture(state_view: bool) -> App {
|
||||
if state_view {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(f.na), Target::Placement(f.nb)],
|
||||
);
|
||||
f.app
|
||||
} else {
|
||||
architecture_fixture(Layer::Sa).app
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_canvas(mounted: &mut crate::behavior::canvas_workspace::tests::Mounted, app: &App) {
|
||||
use iced::advanced::widget::{self, operation::Focusable};
|
||||
struct Focus;
|
||||
impl widget::Operation for Focus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn Focusable,
|
||||
) {
|
||||
if id == Some(&widget::Id::from("diagram-keyboard-owner")) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
mounted.operate(app, &mut Focus);
|
||||
}
|
||||
|
||||
fn shift_delete() -> iced::Event {
|
||||
let mut event =
|
||||
crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
|
||||
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
|
||||
*modifiers = iced::keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_removal_mounted_shift_delete_review_query_and_stamped_escape_preserve_source() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{keyboard::key::Named, mouse};
|
||||
for state_view in [false, true] {
|
||||
let mut app = mounted_fixture(state_view);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
focus_canvas(&mut mounted, &app);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[shift_delete()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{state_view}: {messages:?}");
|
||||
let confirmation = app.delete_target.as_ref().unwrap();
|
||||
assert!(app.saved_removal_review_valid(confirmation));
|
||||
let token = confirmation.snapshot.token;
|
||||
send(
|
||||
&mut app,
|
||||
Event::Query {
|
||||
token,
|
||||
query: "no matching record".into(),
|
||||
},
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.saved_removal_review_valid(app.delete_target.as_ref().unwrap()));
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Cancel { token: t } if *t == token))), "{messages:?}");
|
||||
assert!(app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_removal_mounted_later_press_or_tab_revokes_queued_review_before_app() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
for state_view in [false, true] {
|
||||
for tab in [false, true] {
|
||||
let mut app = mounted_fixture(state_view);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
focus_canvas(&mut mounted, &app);
|
||||
let geometry = mounted.geometry(&app);
|
||||
let bounds = geometry
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.unwrap()
|
||||
.1;
|
||||
let at = iced::Point::new(
|
||||
bounds.x + bounds.width - 12.,
|
||||
bounds.y + bounds.height - 12.,
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let boundary = if tab {
|
||||
key(Named::Tab)
|
||||
} else {
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
|
||||
};
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
shift_delete(),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
boundary,
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { .. }))), "{state_view}/{tab}: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"later input must retire the queued request"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
if !tab {
|
||||
assert!(begin(&app).is_none(), "active pan owns an input lease");
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[Input::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
app.saved_removal_input.idle().is_some(),
|
||||
"abandoned gesture releases its lease"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Architecture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
parent: Id,
|
||||
child: Id,
|
||||
survivor: Id,
|
||||
parent_node: Id,
|
||||
child_node: Id,
|
||||
survivor_node: Id,
|
||||
edge: Id,
|
||||
port: Option<Id>,
|
||||
expected: Project,
|
||||
}
|
||||
|
||||
fn architecture_fixture(layer: Layer) -> Architecture {
|
||||
let mut project = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let parent = project.add_element(kind, layer, "Parent", None).unwrap();
|
||||
let child = project
|
||||
.add_element(kind, layer, "Child", Some(parent))
|
||||
.unwrap();
|
||||
let survivor = project.add_element(kind, layer, "Survivor", None).unwrap();
|
||||
project.elements.get_mut(&survivor).unwrap().description =
|
||||
"Unrelated authored description".into();
|
||||
let edge = project.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Incident",
|
||||
child,
|
||||
survivor,
|
||||
);
|
||||
let port = match project.relations[&edge].body {
|
||||
RelationBody::FunctionalExchange { source_port, .. } => Some(source_port),
|
||||
_ => None,
|
||||
};
|
||||
let diagram = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
let parent_node = project.place(diagram, parent, point(0., 0.));
|
||||
let child_node = project.place(diagram, child, point(40., 100.));
|
||||
let survivor_node = project.place(diagram, survivor, point(800., 50.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point(450.25, 90.5)],
|
||||
label: None,
|
||||
});
|
||||
let other = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
let other_child = project.place(other, child, point(-40., 100.));
|
||||
project.place(other, survivor, point(700., 100.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&other)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
let mut expected = project.clone();
|
||||
expected.elements.remove(&parent);
|
||||
expected.elements.remove(&child);
|
||||
if let Some(port) = port {
|
||||
expected.elements.remove(&port);
|
||||
}
|
||||
expected.relations.remove(&edge);
|
||||
let view = expected.diagrams.get_mut(&diagram).unwrap();
|
||||
view.nodes.remove(&parent_node);
|
||||
view.nodes.remove(&child_node);
|
||||
view.routes.clear();
|
||||
let view = expected.diagrams.get_mut(&other).unwrap();
|
||||
view.nodes.remove(&other_child);
|
||||
view.view_options.hidden_edges.clear();
|
||||
validate_candidate(&expected).unwrap();
|
||||
let mut app = architecture(project, diagram);
|
||||
let mut targets = vec![
|
||||
Target::Placement(parent_node),
|
||||
Target::Placement(child_node),
|
||||
Target::Edge(edge),
|
||||
];
|
||||
if let Some(port) = port {
|
||||
targets.push(Target::Port {
|
||||
port,
|
||||
placement: child_node,
|
||||
});
|
||||
}
|
||||
choose(&mut app, &targets);
|
||||
Architecture {
|
||||
app,
|
||||
diagram,
|
||||
parent,
|
||||
child,
|
||||
survivor,
|
||||
parent_node,
|
||||
child_node,
|
||||
survivor_node,
|
||||
edge,
|
||||
port,
|
||||
expected,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_architecture_mixed_semantics_are_one_exact_model_source_transaction_in_oa_and_sa() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = architecture_fixture(layer);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let camera = (f.app.zoom, f.app.pan);
|
||||
let token = open(&mut f.app);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let mut subjects: BTreeSet<_> = [f.parent, f.child, f.edge].into_iter().collect();
|
||||
subjects.extend(f.port);
|
||||
assert_eq!(review.plan.selected, subjects);
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!((f.app.zoom, f.app.pan), camera);
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
struct States {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
other: Id,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
na: Id,
|
||||
nb: Id,
|
||||
work: Id,
|
||||
action: Id,
|
||||
row: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn state_fixture(layer: Layer) -> States {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, layer, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, layer, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let a = project
|
||||
.add_element(Kind::State, layer, "Operating", Some(region))
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::State, layer, "Standby", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
"Reusable work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let action = project.relate(RelationKind::StateAction, "Entry work", a, work);
|
||||
let row = project.relate(RelationKind::StateTransition, "Internal check", a, a);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut project.relations.get_mut(&row).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
let edge = project.relate(RelationKind::StateTransition, "Change mode", a, b);
|
||||
let diagram = saved(
|
||||
&mut project,
|
||||
layer,
|
||||
ProjectionKind::StateMachine { machine },
|
||||
);
|
||||
let na = project.place(diagram, a, point(10., 20.));
|
||||
let nb = project.place(diagram, b, point(600., 20.));
|
||||
let other = saved(
|
||||
&mut project,
|
||||
layer,
|
||||
ProjectionKind::StateMachine { machine },
|
||||
);
|
||||
project.place(other, a, point(70., 80.));
|
||||
project.place(other, b, point(670., 80.));
|
||||
let architecture_view = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
project.place(architecture_view, owner, point(0., 0.));
|
||||
validate_candidate(&project).unwrap();
|
||||
let mut app = architecture(project, architecture_view);
|
||||
app.page = Page::Behavior;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
States {
|
||||
app,
|
||||
diagram,
|
||||
other,
|
||||
machine,
|
||||
region,
|
||||
a,
|
||||
b,
|
||||
na,
|
||||
nb,
|
||||
work,
|
||||
action,
|
||||
row,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_rows_delete_relations_not_referenced_work_and_state_overlap_is_exact() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for include_owner in [false, true] {
|
||||
let mut f = state_fixture(layer);
|
||||
let mut targets = vec![
|
||||
Target::StateRow {
|
||||
placement: f.na,
|
||||
relation: f.action,
|
||||
},
|
||||
Target::StateRow {
|
||||
placement: f.na,
|
||||
relation: f.row,
|
||||
},
|
||||
];
|
||||
if include_owner {
|
||||
targets.push(Target::Placement(f.na));
|
||||
} else {
|
||||
targets.push(Target::Placement(f.nb));
|
||||
}
|
||||
choose(&mut f.app, &targets);
|
||||
let before = f.app.document.snapshot();
|
||||
let camera = (f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan);
|
||||
let mut expected = f.app.project().clone();
|
||||
expected.relations.remove(&f.action);
|
||||
expected.relations.remove(&f.row);
|
||||
expected.relations.remove(&f.edge);
|
||||
let removed_state = if include_owner { f.a } else { f.b };
|
||||
expected.elements.remove(&removed_state);
|
||||
for view in expected.diagrams.values_mut() {
|
||||
view.nodes.retain(|_, node| node.element != removed_state);
|
||||
}
|
||||
validate_candidate(&expected).unwrap();
|
||||
let token = open(&mut f.app);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
review.context.diagram, f.diagram,
|
||||
"must not resolve stale Architecture field"
|
||||
);
|
||||
assert_eq!(
|
||||
review.plan.selected,
|
||||
[f.action, f.row, removed_state].into_iter().collect()
|
||||
);
|
||||
assert!(!review.plan.removed.contains(&f.work));
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.work));
|
||||
assert_eq!(
|
||||
(f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan),
|
||||
camera
|
||||
);
|
||||
assert!(f.app.project().contains(f.app.selected));
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_cancel_and_stale_modal_packets_preserve_exact_redo_source_selection_and_camera() {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.survivor, "Future name".into()))
|
||||
.unwrap();
|
||||
let future = f.app.document.snapshot();
|
||||
assert!(f.app.document.undo());
|
||||
f.app.sync();
|
||||
f.app.reconcile_multiselect();
|
||||
let before = f.app.document.snapshot();
|
||||
let editor = f.app.source_editor.text();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let camera = (f.app.zoom, f.app.pan);
|
||||
let old_begin = begin(&f.app).unwrap();
|
||||
let first = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token: first });
|
||||
let second = open(&mut f.app);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
for message in [
|
||||
old_begin,
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
Message::Escape,
|
||||
Message::SavedRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::SavedRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::SavedRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 999,
|
||||
})),
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, second);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(review.query.is_empty());
|
||||
assert_eq!(review.page, 0);
|
||||
}
|
||||
send(&mut f.app, Event::Cancel { token: second });
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_editor.text(), editor);
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert_eq!((f.app.zoom, f.app.pan), camera);
|
||||
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &future.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &future.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_begin_and_confirm_reject_selection_membership_and_primary_aba() {
|
||||
for after_open in [false, true] {
|
||||
for primary_only in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
let token = after_open.then(|| open(&mut f.app));
|
||||
let before = f.app.document.snapshot();
|
||||
let captured = f.app.multiselect_snapshot().unwrap();
|
||||
let selected = f.app.diagram_selection.as_mut().unwrap();
|
||||
let primary = captured.primary().unwrap();
|
||||
if primary_only {
|
||||
assert!(selected.set_primary(&Target::Placement(f.parent_node)));
|
||||
assert!(selected.set_primary(primary));
|
||||
} else {
|
||||
assert!(selected.remove(primary));
|
||||
assert!(selected.add(primary.clone(), |_| true));
|
||||
}
|
||||
let restored = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(restored.targets(), captured.targets());
|
||||
assert_eq!(restored.primary(), captured.primary());
|
||||
assert_ne!(restored.generation(), captured.generation());
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f
|
||||
.app
|
||||
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
restored.targets()
|
||||
);
|
||||
if !after_open {
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stale_context_packets_do_not_retire_current_selection_or_history() {
|
||||
for change in 0..4 {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
match change {
|
||||
0 => {
|
||||
f.app.canvas_document = Id::new_v4();
|
||||
}
|
||||
1 => {
|
||||
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
2 => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.survivor, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
f.app.active_diagram = f
|
||||
.app
|
||||
.project()
|
||||
.diagrams
|
||||
.keys()
|
||||
.copied()
|
||||
.find(|id| *id != f.diagram);
|
||||
}
|
||||
}
|
||||
let current = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(delayed);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(¤t, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_admission_preserves_property_source_and_state_editor_drafts_and_nonselect_tool() {
|
||||
for pending in ["properties", "source", "form", "tool", "busy"] {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(f.na), Target::Placement(f.nb)],
|
||||
);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Unapplied".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// pending", f.app.source_workspace.text());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"form" => {
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Edit(f.edge),
|
||||
)));
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Guard("Retained guard".into()),
|
||||
)));
|
||||
}
|
||||
"tool" => {
|
||||
f.app.behavior.diagram.tool = behavior::state_edit::Tool::Transition;
|
||||
}
|
||||
_ => f.app.busy = true,
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(delayed);
|
||||
assert!(f.app.delete_target.is_none(), "{pending}");
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{pending}"
|
||||
);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_input_lease_and_finished_gesture_reject_old_packets_at_begin_and_confirm() {
|
||||
for after_open in [false, true] {
|
||||
for finished in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
let token = after_open.then(|| open(&mut f.app));
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let lease = f.app.saved_removal_input.acquire().unwrap();
|
||||
let mut lease = Some(lease);
|
||||
if finished {
|
||||
drop(lease.take());
|
||||
}
|
||||
let _ = f
|
||||
.app
|
||||
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
drop(lease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_context_menu_keeps_the_whole_group_even_when_unselected_object_was_clicked() {
|
||||
for unselected in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let (object, placement) = if unselected {
|
||||
(f.survivor, f.survivor_node)
|
||||
} else {
|
||||
(f.child, f.child_node)
|
||||
};
|
||||
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = Some(placement);
|
||||
request.selection_target = Some(Box::new(Target::Placement(placement)));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let entries = model_menu::popup(&f.app).unwrap().entries;
|
||||
let invoke = entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. }
|
||||
if label == "Delete selected from model…" =>
|
||||
{
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("explicit semantic group menu action");
|
||||
let _ = f.app.update(invoke);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert!(!review.plan.selected.contains(&f.survivor));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hide_and_plain_delete_never_promote_mixed_targets_to_model_removal() {
|
||||
for message_kind in ["delete", "hide"] {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(if message_kind == "delete" {
|
||||
Message::Delete
|
||||
} else {
|
||||
Message::HideSelected
|
||||
});
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
let token = open(&mut f.app);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
}
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[
|
||||
Target::Placement(f.parent_node),
|
||||
Target::Placement(f.survivor_node),
|
||||
],
|
||||
);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.parent_node);
|
||||
view.nodes.remove(&f.survivor_node);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.parent) && f.app.project().contains(f.survivor));
|
||||
source_matches(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_owner_deletion_removes_owned_views_and_chooses_surviving_inspection_context() {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
let mut project = f.app.project().clone();
|
||||
let owner_node = project.place(f.diagram, f.machine, point(-400., -200.));
|
||||
f.app.document = Document::from_project(project.clone(), LIMITS).unwrap();
|
||||
f.app.source_workspace = Workspace::new(f.app.document.bundle(), true);
|
||||
f.app.sync();
|
||||
f.app.reconcile_multiselect();
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(owner_node), Target::Placement(f.na)],
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = project;
|
||||
for id in [f.machine, f.region, f.a, f.b] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
for id in [f.action, f.row, f.edge] {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected.diagrams.remove(&f.diagram);
|
||||
expected.diagrams.remove(&f.other);
|
||||
validate_candidate(&expected).unwrap();
|
||||
let token = open(&mut f.app);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.app.selected));
|
||||
assert!(
|
||||
f.app
|
||||
.behavior
|
||||
.active_owner
|
||||
.is_none_or(|id| f.app.project().contains(id))
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.is_none_or(|id| f.app.project().diagrams.contains_key(&id))
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| s.targets().is_empty())
|
||||
);
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Review the complete captured removal impact before one explicit confirmation.
|
||||
use super::{Event, Review};
|
||||
use crate::app::{App, Message, diagram_removal};
|
||||
use crate::style;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length::Fill};
|
||||
use oa_core::Project;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(super) fn matching_rows(
|
||||
project: &Project,
|
||||
review: &Review,
|
||||
) -> Vec<super::super::removal_review::ImpactRow> {
|
||||
super::super::removal_review::matching_rows(
|
||||
project,
|
||||
&review.plan.removed,
|
||||
&review.plan.changed,
|
||||
&review.plan.selected,
|
||||
&review.plan.interpretation,
|
||||
&review.plan.affected_uses,
|
||||
&review.query,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.saved.as_ref() else {
|
||||
return text("Model deletion review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.saved_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 detected Process order/analysis changes or affected Process 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::SavedRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::SavedRemoval(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::SavedRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Delete selected from model")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Delete {} model subjects?", review.plan.selected.len())).size(21),
|
||||
text(format!("{} selected diagram objects · {} unique model subjects", review.snapshot.targets().len(), review.plan.selected.len())).size(13),
|
||||
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("saved-removal-query")
|
||||
.on_input(move |query| Message::SavedRemoval(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("Deletes the selected model subjects and their listed dependent records across all views. This is different from hiding placements. Removing a State row preserves its referenced work; deleting a trace preserves its endpoints unless separately selected or owned by another deleted subject. Reused Process definitions may change; review their listed effects and callers. One Undo restores the entire model and source change.")
|
||||
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
impacts,
|
||||
].spacing(14).width(Fill))
|
||||
.width(Fill).height(Fill).spacing(8).id("saved-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()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Exact rendered Scenario selection, independent of semantic movement permission.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{self, selection};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_selection_envelope(&self, event: &selection::Event) -> bool {
|
||||
self.scenario_context() == Some(event.context)
|
||||
&& self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(&event.snapshot, selection::context(event.context))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_scenario_multiselect(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
let scope = selection::context(context).scope;
|
||||
let visible = selection::visible(self.project(), context, self.selected);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let legacy = Target::ScenarioObject(self.selected);
|
||||
let held = old.as_ref() == Some(&legacy);
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selected = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(scope));
|
||||
selected.reconcile(scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selected.primary() != old.as_ref() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
} else if selected.primary() != Some(&legacy) {
|
||||
if visible.contains(&legacy) {
|
||||
selected.replace(legacy, |target| visible.contains(target));
|
||||
} else {
|
||||
selected.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_scenario_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
self.selected = match self.diagram_selection.as_ref().and_then(Selection::primary) {
|
||||
Some(Target::ScenarioObject(id)) => *id,
|
||||
_ => context.scenario,
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
/// This is dispatched before shared navigation/gesture retirement. Invalid
|
||||
/// captures and unchanged selection must not disturb a newer input owner.
|
||||
pub(super) fn scenario_selection_event(&mut self, event: selection::Event) -> Task<Message> {
|
||||
use multiselect::Choice;
|
||||
use selection::Intent;
|
||||
if !self.scenario_selection_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
let visible = selection::visible(self.project(), event.context, self.selected);
|
||||
if !event.snapshot.targets().iter().all(|t| visible.contains(t)) {
|
||||
return Task::none();
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.source_rename.open()
|
||||
|| self.behavior.scenario.canvas_tool(event.context.scenario)
|
||||
!= scenario::CanvasTool::Select
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the Scenario selection.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let selected = self.diagram_selection.as_mut().expect("admitted selection");
|
||||
let valid = |target: &Target| visible.contains(target);
|
||||
let changed = match &event.intent {
|
||||
Intent::Choose(target, choice) => {
|
||||
if !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
match choice {
|
||||
Choice::Replace => selected.replace(target.clone(), valid),
|
||||
Choice::Add => selected.add(target.clone(), valid),
|
||||
Choice::Remove => selected.remove(target),
|
||||
Choice::Toggle => selected.toggle(target.clone(), valid),
|
||||
Choice::Inspect => selected.set_primary(target),
|
||||
}
|
||||
}
|
||||
Intent::Collapse(target) => {
|
||||
if !selected.contains(target) || !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
selected.replace(target.clone(), valid)
|
||||
}
|
||||
Intent::Clear => selected.clear(),
|
||||
Intent::Refuse(reason) => {
|
||||
self.status = reason.clone();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.scenario_reorder_cancel();
|
||||
self.source_interaction();
|
||||
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
||||
scenario::CanvasEvent::Selection(Box::new(event)),
|
||||
));
|
||||
self.behavior.scenario.cancel_spacing();
|
||||
// Every generation change, including a secondary membership ABA,
|
||||
// retires old singleton spacing packets as well as group captures.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn scenario_group_selected(&self) -> bool {
|
||||
self.scenario_context().is_some()
|
||||
&& self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection::Intent, spacing};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Surface;
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn setup(saved: bool) -> (App, Id, Id, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = spacing::controller::tests::fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
if saved {
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
} else {
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
(app, owner, diagram, lane, message)
|
||||
}
|
||||
|
||||
fn capture(app: &App, intent: Intent) -> selection::Event {
|
||||
selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent,
|
||||
}
|
||||
}
|
||||
fn send(app: &mut App, event: selection::Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
|
||||
send(
|
||||
app,
|
||||
capture(app, Intent::Choose(Target::ScenarioObject(id), choice)),
|
||||
);
|
||||
}
|
||||
fn inert(app: &mut App, messages: Vec<Message>, preserve_status: bool) {
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let source = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
let selected = app.multiselect_snapshot();
|
||||
let primary = app.selected;
|
||||
let context = app.scenario_context();
|
||||
let camera = app.camera_boundary();
|
||||
let status = app.status.clone();
|
||||
let spacing = (
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token(),
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
assert_eq!(app.multiselect_snapshot(), selected);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token()
|
||||
),
|
||||
spacing
|
||||
);
|
||||
if preserve_status {
|
||||
assert_eq!(app.status, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn packet(event: selection::Event) -> Message {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_scenario_membership_primary_and_noops_preserve_model_source_and_history() {
|
||||
for saved in [false, true] {
|
||||
let (mut app, owner, diagram, lane, message) = setup(saved);
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.targets(),
|
||||
&[
|
||||
Target::ScenarioObject(message),
|
||||
Target::ScenarioObject(lane)
|
||||
]
|
||||
);
|
||||
assert_eq!(snapshot.primary(), Some(&Target::ScenarioObject(message)));
|
||||
assert_eq!(
|
||||
snapshot.context().scope.surface,
|
||||
if saved {
|
||||
Surface::Saved(diagram)
|
||||
} else {
|
||||
Surface::Scenario(owner)
|
||||
}
|
||||
);
|
||||
assert_eq!(app.selected, message);
|
||||
let repeated = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(message), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(repeated)], true);
|
||||
let stale_collapse = capture(&app, Intent::Collapse(Target::ScenarioObject(lane)));
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
snapshot.primary()
|
||||
);
|
||||
inert(&mut app, vec![packet(stale_collapse)], true);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_representation_round_trip_clears_group_and_rejects_old_packets() {
|
||||
let (mut app, owner, diagram, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let old = capture(&app, Intent::Clear);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let mut second = app.project().diagrams[&diagram].clone();
|
||||
let other = Id::new_v4();
|
||||
second.id = other;
|
||||
second.alias = "other_saved".into();
|
||||
second.name = "Other saved".into();
|
||||
// Navigation itself is tested against a model with both saved identities.
|
||||
let mut project = app.project().clone();
|
||||
project.diagrams.insert(other, second);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "A".into(),
|
||||
}));
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let first = capture(&app, Intent::Clear);
|
||||
let first_visit = first.context.visit;
|
||||
for representation in [
|
||||
Representation::Saved { diagram: other },
|
||||
Representation::Derived { owner },
|
||||
Representation::Saved { diagram },
|
||||
] {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
representation,
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().len() <= 1);
|
||||
}
|
||||
assert_ne!(app.scenario_context().unwrap().visit, first_visit);
|
||||
choose(&mut app, message, multiselect::Choice::Replace);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
inert(&mut app, vec![packet(first), packet(old)], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_bare_generic_packets_are_inert_before_side_effects() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let current = app.multiselect_context().unwrap();
|
||||
let events = [
|
||||
Target::Whole,
|
||||
Target::Placement(message),
|
||||
Target::ScenarioObject(Id::new_v4()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| {
|
||||
packet(capture(
|
||||
&app,
|
||||
Intent::Choose(target, multiselect::Choice::Replace),
|
||||
))
|
||||
})
|
||||
.chain([
|
||||
Message::DiagramSelection(multiselect::Event::Clear(current)),
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
current,
|
||||
Target::ScenarioObject(message),
|
||||
multiselect::Choice::Replace,
|
||||
)),
|
||||
])
|
||||
.collect();
|
||||
inert(&mut app, events, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_rejects_legacy_singleton_remove_reorder_and_index_arrows() {
|
||||
let (mut app, owner, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let context = app.scenario_context().unwrap();
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Select(context, message)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Remove(context, lane)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: message,
|
||||
slot: scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
},
|
||||
}),
|
||||
Message::Command(Command::MoveStep(owner, 0, 1)),
|
||||
Message::Delete,
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let old = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let group_begin = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(group_begin)));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"pending click retains its admitted lifetime"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.spacing_token(),
|
||||
Some(group_begin.token())
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
|
||||
true,
|
||||
);
|
||||
let current = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let spacing::Event::Begin { press_screen, .. } = current else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(old)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
})),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
})),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
spacing::Event::Finish {
|
||||
stamp: current.stamp(),
|
||||
token: current.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
},
|
||||
)));
|
||||
assert!(app.document.can_undo());
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(message)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_singleton_selection_aba_retires_old_spacing_begin() {
|
||||
for canvas in [false, true] {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let old = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
for id in [lane, message] {
|
||||
let event = if canvas {
|
||||
Message::ScenarioCanvas(CanvasEvent::Select(app.scenario_context().unwrap(), id))
|
||||
} else {
|
||||
Message::Select(id)
|
||||
};
|
||||
let _ = app.update(event);
|
||||
assert_eq!(app.selected, id);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(id)]
|
||||
);
|
||||
}
|
||||
assert_ne!(app.scenario_context().unwrap(), old.stamp().context);
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
|
||||
true,
|
||||
);
|
||||
let current = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_primary_edit_is_not_applied_by_selection_input() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending primary name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let event = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(lane), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(event)], false);
|
||||
assert!(app.draft_dirty());
|
||||
assert_ne!(app.project().named(message), "Pending primary name");
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! One captured Scenario selection, one impact review, one document transaction.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{self, group_removal, selection};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
context: scenario::Context,
|
||||
snapshot: Box<Snapshot>,
|
||||
},
|
||||
Query {
|
||||
token: u64,
|
||||
query: String,
|
||||
},
|
||||
Page {
|
||||
token: u64,
|
||||
page: usize,
|
||||
},
|
||||
Cancel {
|
||||
token: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Review {
|
||||
pub(crate) context: scenario::Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) plan: group_removal::Plan,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
let context = app.scenario_context()?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::ScenarioRemoval(Box::new(Event::Begin {
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_removal_enabled(&self) -> bool {
|
||||
self.scenario_reorder_enabled() && self.scenario_reorder.session.is_none()
|
||||
}
|
||||
|
||||
fn scenario_removal_matches(&self, context: scenario::Context, snapshot: &Snapshot) -> bool {
|
||||
if self.scenario_context() != Some(context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selected| selected.matches(snapshot, selection::context(context)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let visible = selection::visible(self.project(), context, self.selected);
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin { context, snapshot } => {
|
||||
// Admission precedes every shared input/selection boundary. A
|
||||
// stale packet must not prune the set or retire a newer editor.
|
||||
if !self.scenario_removal_matches(context, &snapshot)
|
||||
|| !self.scenario_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan = match group_removal::prepare(
|
||||
self.project(),
|
||||
context.scenario,
|
||||
snapshot.targets(),
|
||||
) {
|
||||
Ok(plan) => plan,
|
||||
Err(reason) => {
|
||||
self.status = reason;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
|
||||
return Task::none();
|
||||
};
|
||||
let primary = *primary;
|
||||
self.scenario_reorder_cancel();
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.scenario_context().expect("admitted Scenario");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.scenario = Some(Box::new(context));
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(primary)));
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence)
|
||||
.map(|opened| diagram_removal::Confirmation {
|
||||
snapshot: opened,
|
||||
selection: self.selected,
|
||||
placement: None,
|
||||
behavior_owner: Some(context.scenario),
|
||||
process: None,
|
||||
saved: None,
|
||||
scenario: Some(Review {
|
||||
context,
|
||||
snapshot,
|
||||
plan,
|
||||
query: String::new(),
|
||||
page: 0,
|
||||
}),
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
Event::Query { token, query } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.scenario.as_mut()
|
||||
{
|
||||
review.query = query;
|
||||
review.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page { token, page } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.scenario.as_mut()
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.snapshot.token == token && target.scenario.is_some()
|
||||
}) {
|
||||
self.delete_target = None;
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Removal cancelled. Model and selection unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.scenario else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.scenario_removal_matches(review.context, &review.snapshot)
|
||||
&& self.selected == target.selection
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_rename.open()
|
||||
&& self.scenario_reorder.session.is_none()
|
||||
&& self.behavior.scenario.spacing_token().is_none()
|
||||
&& self.behavior.scenario.canvas_tool(review.context.scenario)
|
||||
== scenario::CanvasTool::Select
|
||||
}
|
||||
|
||||
pub(super) fn confirm_scenario_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.scenario.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.scenario_removal_review_valid(target);
|
||||
let target = self.delete_target.take().expect("captured review");
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if !valid {
|
||||
self.status =
|
||||
"Removal review expired. Nothing was removed; review the current selection again."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
let review = target.scenario.expect("Scenario review");
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected = review.context.scenario;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(review.context.scenario);
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Removed {} selected Scenario objects in one change. Referenced definitions preserved. Undo restores the model.",
|
||||
review.snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
Err(error) => self.status = format!("Nothing removed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, direct_tests};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
call: Id,
|
||||
reply: Id,
|
||||
tail: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn setup(saved: bool) -> Fixture {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let mut tail = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unselected tail",
|
||||
Some(f.owner),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = tail.shared_mut() {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
let tail_id = tail.id;
|
||||
project.elements.insert(tail_id, tail);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(tail_id);
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "removal_view".into(),
|
||||
name: "Removal view".into(),
|
||||
description: "Preserved authored view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
let other = project
|
||||
.make_element(Kind::Scenario, Layer::Oa, "Unrelated Scenario", None)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project.elements.insert(other_owner, other);
|
||||
let mut other_view = project.diagrams[&diagram].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "unrelated_removal_view".into();
|
||||
other_view.name = "Unrelated saved view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
project.diagrams.insert(other_view.id, other_view);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(if saved {
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
})
|
||||
} else {
|
||||
Message::Page(Page::Behavior)
|
||||
});
|
||||
// Deliberately reverse semantic order; the primary is call, not the last step.
|
||||
for (id, choice) in [
|
||||
(f.reply, multiselect::Choice::Replace),
|
||||
(f.call, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.scenario_removal_enabled());
|
||||
Fixture {
|
||||
app,
|
||||
owner: f.owner,
|
||||
call: f.call,
|
||||
reply: f.reply,
|
||||
tail: tail_id,
|
||||
exchange: f.exchange,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioRemoval(Box::new(event)));
|
||||
}
|
||||
fn captured(app: &App) -> Event {
|
||||
Event::Begin {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: Box::new(app.multiselect_snapshot().unwrap()),
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> u64 {
|
||||
let event = captured(app);
|
||||
send(app, event);
|
||||
let target = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.expect("whole-selection review opened");
|
||||
assert!(app.delete_pending && target.scenario.is_some());
|
||||
assert!(
|
||||
target.snapshot.valid(app),
|
||||
"review captures refreshed modal epoch"
|
||||
);
|
||||
target.snapshot.token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_is_one_exact_model_source_transaction_in_both_representations() {
|
||||
for saved in [false, true] {
|
||||
let Fixture {
|
||||
mut app,
|
||||
owner,
|
||||
call,
|
||||
reply,
|
||||
tail,
|
||||
exchange,
|
||||
} = setup(saved);
|
||||
let baseline = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.scenario
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert_eq!(review.snapshot.primary(), selected.primary());
|
||||
assert_eq!(review.plan.removed, [call, reply].into_iter().collect());
|
||||
assert!(review.plan.changed.contains(&owner));
|
||||
assert!(!review.plan.removed.contains(&exchange));
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
let mut expected = baseline.decoded.project.clone();
|
||||
expected.elements.remove(&call);
|
||||
expected.elements.remove(&reply);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![tail];
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().contains(exchange));
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
let removed = app.document.bundle().clone();
|
||||
assert_ne!(removed, baseline.bundle);
|
||||
let reopened = Document::from_bundle(removed.clone(), Default::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"one Undo restores the entire group"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_source_write_failure_preserves_snapshot_selection_camera_and_redo() {
|
||||
use oa_core::source_project::{self, ProjectLimits};
|
||||
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
let lanes = ["a", "b"].map(|alias| {
|
||||
let id = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, alias, Some(owner))
|
||||
.unwrap();
|
||||
project.elements.get_mut(&id).unwrap().alias = alias.into();
|
||||
id
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
for index in 0..256 {
|
||||
let mut message = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
&format!("Message {index}"),
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let SharedBody::Occurrence { body, .. } = message.shared_mut().unwrap() else {
|
||||
panic!("message occurrence");
|
||||
};
|
||||
*body = OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: oa_core::MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
};
|
||||
messages.push(message.id);
|
||||
project.elements.insert(message.id, message);
|
||||
}
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.clone();
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "removal_view".into(),
|
||||
name: "Removal view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
let bundle = source_project::format_bundle(&project);
|
||||
let bytes = |bundle: &source_project::SourceBundle| {
|
||||
bundle.files.values().map(String::len).sum::<usize>()
|
||||
};
|
||||
let targets = lanes.map(Target::ScenarioObject);
|
||||
let plan = group_removal::prepare(&project, owner, &targets).unwrap();
|
||||
assert_eq!(plan.removed, lanes.into_iter().collect());
|
||||
let mut expected = project.clone();
|
||||
for lane in lanes {
|
||||
expected.elements.remove(&lane);
|
||||
}
|
||||
for id in &messages {
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
} = expected.elements.get_mut(id).unwrap().shared_mut().unwrap()
|
||||
else {
|
||||
panic!("surviving message");
|
||||
};
|
||||
*sender = None;
|
||||
*receiver = None;
|
||||
}
|
||||
oa_core::validate_candidate(&expected).unwrap();
|
||||
let mut unrestricted = Document::from_bundle(bundle.clone(), Default::default()).unwrap();
|
||||
unrestricted.execute(plan.command.clone()).unwrap();
|
||||
assert_eq!(unrestricted.project(), &expected);
|
||||
// Removal can grow losslessly edited source: each authored @a/@b becomes
|
||||
// explicit none in a surviving message. Prove net growth after deletion,
|
||||
// rather than assuming a budget failure or changing private Document limits.
|
||||
assert!(bytes(unrestricted.bundle()) > bytes(&bundle));
|
||||
let mut limits = ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bytes(&bundle);
|
||||
let mut bounded = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let error = bounded.execute(plan.command).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.diagnostic.code == "source_limit")
|
||||
);
|
||||
let refusal = format!("Nothing removed: {error}");
|
||||
|
||||
for saved in [false, true] {
|
||||
for with_redo in [false, true] {
|
||||
let mut app = App::with_project(project.clone());
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let redo = if with_redo {
|
||||
// A genuine earlier rename fits the same budget. Undo leaves
|
||||
// its exact model/source snapshot available before this review.
|
||||
app.document
|
||||
.execute(Command::Rename(owner, "Short".into()))
|
||||
.unwrap();
|
||||
let future = app.document.snapshot();
|
||||
assert!(app.document.undo());
|
||||
Some(future)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
app.sync();
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(if saved {
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
})
|
||||
} else {
|
||||
Message::Page(Page::Behavior)
|
||||
});
|
||||
for (id, choice) in [
|
||||
(lanes[1], multiselect::Choice::Replace),
|
||||
(lanes[0], multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.scenario_removal_enabled());
|
||||
let baseline = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[targets[1].clone(), targets[0].clone()]
|
||||
);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let navigation = (
|
||||
app.page,
|
||||
app.active_diagram,
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.active_owner,
|
||||
);
|
||||
let editor = app.source_editor.text();
|
||||
assert!(!app.document.dirty() && !app.document.can_undo());
|
||||
assert_eq!(app.document.can_redo(), with_redo);
|
||||
let token = open(&mut app);
|
||||
let review = app.delete_target.as_ref().unwrap();
|
||||
assert!(app.scenario_removal_review_valid(review));
|
||||
assert_eq!(review.scenario.as_ref().unwrap().plan.removed, plan.removed);
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.status, refusal);
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
assert_ne!(app.canvas_epoch, epoch, "failed confirmation retires input");
|
||||
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.document.revision(), revision);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo());
|
||||
assert_eq!(app.document.can_redo(), with_redo);
|
||||
let retained = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(retained.targets(), selected.targets());
|
||||
assert_eq!(retained.primary(), selected.primary());
|
||||
assert_eq!(retained.generation(), selected.generation());
|
||||
let after = app.scenario_context().unwrap();
|
||||
assert_eq!(after.document, context.document);
|
||||
assert_eq!(after.revision, context.revision);
|
||||
assert_eq!(after.representation, context.representation);
|
||||
assert_eq!(after.visit, context.visit);
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.active_diagram,
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.active_owner,
|
||||
),
|
||||
navigation
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), editor);
|
||||
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
|
||||
assert!(!app.source_dirty() && !app.draft_dirty());
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), retained);
|
||||
assert_eq!(app.status, refusal);
|
||||
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
if let Some(future) = redo {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &future.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &future.bundle);
|
||||
assert!(!app.document.can_redo());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_old_modal_packets_do_not_touch_the_new_review() {
|
||||
let mut f = setup(true);
|
||||
let old_begin = captured(&f.app);
|
||||
let first = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token: first });
|
||||
let second = open(&mut f.app);
|
||||
assert_ne!(first, second);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let source = f.app.document.bundle().clone();
|
||||
for message in [
|
||||
Message::ScenarioRemoval(Box::new(old_begin)),
|
||||
Message::ScenarioRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::ScenarioRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 99,
|
||||
})),
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
let target = f.app.delete_target.as_ref().unwrap();
|
||||
assert_eq!(target.snapshot.token, second);
|
||||
assert!(target.scenario.as_ref().unwrap().query.is_empty());
|
||||
assert_eq!(target.scenario.as_ref().unwrap().page, 0);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
}
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::Query {
|
||||
token: second,
|
||||
query: "reply".into(),
|
||||
},
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::Page {
|
||||
token: second,
|
||||
page: 1,
|
||||
},
|
||||
);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
send(&mut f.app, Event::Cancel { token: second });
|
||||
assert!(!f.app.delete_pending);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_confirm_expires_for_each_context_or_selection_change() {
|
||||
for change in 0..5 {
|
||||
let mut f = setup(true);
|
||||
let token = open(&mut f.app);
|
||||
match change {
|
||||
0 => f.app.canvas_document = Id::new_v4(),
|
||||
1 => {
|
||||
let context = f.app.scenario_context().unwrap();
|
||||
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 560.),
|
||||
)));
|
||||
assert_ne!(f.app.canvas_epoch, context.epoch);
|
||||
}
|
||||
2 => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.owner, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
3 => {
|
||||
let context = f.app.scenario_context().unwrap();
|
||||
f.app.behavior.scenario.enter_representation(
|
||||
context.document,
|
||||
f.owner,
|
||||
Representation::Derived { owner: f.owner },
|
||||
);
|
||||
f.app.behavior.scenario.enter_representation(
|
||||
context.document,
|
||||
f.owner,
|
||||
context.representation,
|
||||
);
|
||||
assert_ne!(f.app.scenario_context().unwrap().visit, context.visit);
|
||||
}
|
||||
_ => {
|
||||
let selected = f.app.diagram_selection.as_mut().unwrap();
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(f.reply)));
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(f.call)));
|
||||
}
|
||||
}
|
||||
let current = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), ¤t.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), ¤t.bundle);
|
||||
assert!(!f.app.delete_pending && f.app.delete_target.is_none());
|
||||
assert!(f.app.status.contains("expired"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_cancel_preserves_existing_redo_and_full_selection() {
|
||||
let mut f = setup(true);
|
||||
let _ = f.app.update(Message::Command(Command::Rename(
|
||||
f.tail,
|
||||
"Redo remains available".into(),
|
||||
)));
|
||||
let renamed = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(f.app.document.can_redo());
|
||||
// Rename inspects the renamed tail; Undo preserves that inspection. Restore
|
||||
// the real group before testing cancellation of its removal review.
|
||||
for (id, choice) in [
|
||||
(f.reply, multiselect::Choice::Replace),
|
||||
(f.call, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: f.app.scenario_context().unwrap(),
|
||||
snapshot: f.app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets().len(), 2);
|
||||
assert!(f.app.document.can_redo());
|
||||
let source = f.app.document.bundle().clone();
|
||||
let token = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token });
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_does_not_retire_active_reorder_or_spacing() {
|
||||
for spacing in [false, true] {
|
||||
let mut f = setup(true);
|
||||
if spacing {
|
||||
let event = crate::app::scenario_spacing::tests::packet_for_app(
|
||||
&f.app,
|
||||
scenario::spacing::Target::Message(f.call),
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
assert!(f.app.behavior.scenario.spacing_token().is_some());
|
||||
} else {
|
||||
let event = crate::app::scenario_reorder::Event {
|
||||
context: f.app.scenario_context().unwrap(),
|
||||
snapshot: f.app.multiselect_snapshot().unwrap(),
|
||||
token: Id::new_v4(),
|
||||
intent: crate::app::scenario_reorder::Intent::Begin,
|
||||
};
|
||||
let _ = f.app.update(Message::ScenarioReorder(Box::new(event)));
|
||||
assert!(f.app.scenario_reorder.session.is_some());
|
||||
}
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let spacing_token = f.app.behavior.scenario.spacing_token();
|
||||
let reorder_token = f
|
||||
.app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|session| session.token());
|
||||
let source = f.app.document.bundle().clone();
|
||||
let event = captured(&f.app);
|
||||
send(&mut f.app, event);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.behavior.scenario.spacing_token(), spacing_token);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|session| session.token()),
|
||||
reorder_token
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_menu_does_not_select_its_clicked_subject() {
|
||||
for unselected in [false, true] {
|
||||
let mut f = setup(true);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let clicked = if unselected { f.tail } else { f.reply };
|
||||
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
|
||||
request.scenario = f.app.scenario_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(clicked)));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let menu = f.app.model_menu.as_ref().expect("Scenario menu");
|
||||
let token = menu.token;
|
||||
let action = begin(&f.app).expect("captured group menu action");
|
||||
let _ = f.app.update(Message::Menu(model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(action),
|
||||
)));
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.scenario
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert_eq!(f.app.selected, f.call);
|
||||
assert!(!review.plan.removed.contains(&f.tail));
|
||||
assert!(f.app.project().contains(clicked));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_rejects_dirty_properties_and_legacy_singleton_messages() {
|
||||
let mut f = setup(false);
|
||||
let baseline = f.app.document.bundle().clone();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(Message::Delete);
|
||||
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
f.app.scenario_context().unwrap(),
|
||||
f.call,
|
||||
)));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied group primary".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let event = captured(&f.app);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
send(&mut f.app, event);
|
||||
assert!(f.app.delete_target.is_none() && f.app.draft_dirty());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.document.bundle(), &baseline);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_group_delete_captures_whole_set_and_repeat_does_not_confirm() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
keyboard::{self, key::Named},
|
||||
mouse,
|
||||
};
|
||||
for shift in [false, true] {
|
||||
let mut f = setup(true);
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
mounted
|
||||
.batch(&mut f.app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let mut remove = key(Named::Delete);
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove {
|
||||
*modifiers = if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
};
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut f.app, &[remove.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ScenarioRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets()==selected.targets() && snapshot.primary()==selected.primary()))), "actual canvas Delete emits full captured group: {output:?}");
|
||||
assert!(!output.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Delete | Message::ScenarioCanvas(CanvasEvent::Remove(..))
|
||||
)));
|
||||
let token = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.expect("mounted group review")
|
||||
.snapshot
|
||||
.token;
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
|
||||
*repeat = true;
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut f.app, &[remove], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!output
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ConfirmDelete(_)))
|
||||
);
|
||||
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, token);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
send(&mut f.app, Event::Cancel { token });
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Review the complete captured removal impact before one explicit confirmation.
|
||||
use super::{Event, Review};
|
||||
use crate::app::{App, Message, diagram_removal};
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::style;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length::Fill};
|
||||
use oa_core::{Id, Project};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
struct ImpactRow {
|
||||
label: String,
|
||||
disposition: &'static str,
|
||||
}
|
||||
|
||||
fn matching_rows(project: &Project, review: &Review) -> Vec<ImpactRow> {
|
||||
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
|
||||
let query = review.query.trim().to_lowercase();
|
||||
review
|
||||
.plan
|
||||
.removed
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let selected = review
|
||||
.snapshot
|
||||
.targets()
|
||||
.contains(&Target::ScenarioObject(*id));
|
||||
(
|
||||
*id,
|
||||
if selected {
|
||||
"Removed · selected"
|
||||
} else {
|
||||
"Additional removed"
|
||||
},
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
review
|
||||
.plan
|
||||
.changed
|
||||
.iter()
|
||||
.map(|id| (*id, "Updated · preserved")),
|
||||
)
|
||||
.filter_map(|(id, disposition)| {
|
||||
let name = project.named(id);
|
||||
let identity = aliases
|
||||
.get(&id)
|
||||
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
|
||||
let label = if name == "Missing object" || name.is_empty() {
|
||||
identity
|
||||
} else {
|
||||
format!("{name} ({identity})")
|
||||
};
|
||||
(query.is_empty()
|
||||
|| label.to_lowercase().contains(&query)
|
||||
|| id.to_string().contains(&query))
|
||||
.then_some(ImpactRow { label, disposition })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.scenario.as_ref() else {
|
||||
return text("Scenario removal review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.scenario_removal_review_valid(target);
|
||||
let rows = matching_rows(app.project(), review);
|
||||
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = review.page.min(pages - 1);
|
||||
let mut impacts = column![].spacing(12).width(Fill);
|
||||
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
impacts = impacts.push(
|
||||
column![
|
||||
text(impact.disposition).size(12).color(style::MUTED),
|
||||
text(impact.label.clone())
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
impacts = impacts.push(text("No matching affected records. Try a name or alias.").size(13));
|
||||
}
|
||||
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page + 1,
|
||||
}))
|
||||
}));
|
||||
let mut footer = column![].spacing(8).width(Fill);
|
||||
if !valid {
|
||||
footer = footer.push(
|
||||
text("This review expired. Cancel and review the current selection again.")
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
container(
|
||||
button("Cancel")
|
||||
.padding(10)
|
||||
.on_press(Message::ScenarioRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Remove from Scenario")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Remove {} Scenario objects?", review.snapshot.targets().len())).size(21),
|
||||
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
|
||||
text_input("Search affected names or aliases", &review.query)
|
||||
.id("scenario-removal-query")
|
||||
.on_input(move |query| Message::ScenarioRemoval(Box::new(Event::Query { token, query })))
|
||||
.size(13),
|
||||
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
|
||||
.spacing(10).align_y(iced::Alignment::Center),
|
||||
scrollable(column![
|
||||
text("Removes local occurrences or participants and their owned descendants. Affected references are cleared; reusable definitions are preserved. Updated records include owning sequences and affected views. One Undo restores the entire change.")
|
||||
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
impacts,
|
||||
].spacing(14).width(Fill))
|
||||
.width(Fill).height(Fill).spacing(8).id("scenario-removal-impact"),
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill)
|
||||
// At the reviewed 732px window height, 570px content plus the modal's
|
||||
// 52px padding leaves 110px outside the card. Only the impact body flexes.
|
||||
.height((app.window_size.height - 160.).clamp(260., 570.))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Exact-input adapter for an explicit semantic reorder operation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||
use crate::{
|
||||
behavior::scenario::{
|
||||
self,
|
||||
reorder_session::{Outcome, Session},
|
||||
selection,
|
||||
},
|
||||
diagram_selection::Snapshot,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
pub(crate) session: Option<Session>,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
pub(crate) summary: Option<(scenario::Context, String)>,
|
||||
summary_selection: Option<Snapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Event {
|
||||
pub(crate) context: scenario::Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) token: Id,
|
||||
pub(crate) intent: Intent,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Intent {
|
||||
Begin,
|
||||
Choose(scenario::Slot),
|
||||
Query(String),
|
||||
Page(usize),
|
||||
Finish(scenario::Slot),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn short_label(value: &str) -> String {
|
||||
crate::views::short(value, 24)
|
||||
}
|
||||
|
||||
/// Actual committed block order, independent of capture order or saved Y.
|
||||
fn result_neighborhood(project: &oa_core::Project, parent: Id, snapshot: &Snapshot) -> String {
|
||||
let Some(order) = project
|
||||
.elements
|
||||
.get(&parent)
|
||||
.and_then(|element| element.steps())
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
let selected: Vec<_> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, id)| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(**id))
|
||||
})
|
||||
.collect();
|
||||
let Some((first, _)) = selected.first() else {
|
||||
return String::new();
|
||||
};
|
||||
let last = selected.last().expect("nonempty block").0;
|
||||
let label = |index: usize| format!("#{} @{}", index + 1, project.elements[&order[index]].alias);
|
||||
let left = first
|
||||
.checked_sub(1)
|
||||
.map_or_else(|| "Start".to_owned(), label);
|
||||
let right = if last + 1 < order.len() {
|
||||
label(last + 1)
|
||||
} else {
|
||||
"End".to_owned()
|
||||
};
|
||||
let mut members = selected
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|(index, _)| label(*index))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if selected.len() > 3 {
|
||||
members.push_str(&format!(", +{} more", selected.len() - 3));
|
||||
}
|
||||
format!("{left} → [{members}] → {right}")
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_reorder_enabled(&self) -> bool {
|
||||
self.spacing_enabled()
|
||||
&& diagram_removal::enabled(self)
|
||||
&& self.behavior.scenario.spacing_token().is_none()
|
||||
&& self.scenario_context().is_some_and(|context| {
|
||||
self.behavior.scenario.canvas_tool(context.scenario) == scenario::CanvasTool::Select
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_cancel(&mut self) {
|
||||
if self.scenario_reorder.session.take().is_some() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_reconcile(&mut self) {
|
||||
if self
|
||||
.scenario_reorder
|
||||
.summary
|
||||
.as_ref()
|
||||
.is_some_and(|(context, _)| {
|
||||
// A committed result is presentation, not a live authoring packet.
|
||||
// Resizing diagnostics or moving the camera retires input epochs
|
||||
// without changing the result's document, view or selection.
|
||||
let same_result = self.scenario_context().is_some_and(|mut current| {
|
||||
current.epoch = context.epoch;
|
||||
current == *context
|
||||
});
|
||||
let same_selection = self
|
||||
.scenario_reorder
|
||||
.summary_selection
|
||||
.as_ref()
|
||||
.zip(self.diagram_selection.as_ref())
|
||||
.is_some_and(|(snapshot, selected)| {
|
||||
selected.matches(snapshot, snapshot.context())
|
||||
});
|
||||
!same_result || !same_selection
|
||||
})
|
||||
{
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
}
|
||||
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let valid = self.scenario_reorder_enabled()
|
||||
&& self
|
||||
.scenario_context()
|
||||
.zip(self.multiselect_snapshot())
|
||||
.is_some_and(|(context, snapshot)| {
|
||||
session.matches(self.project(), context, &snapshot, session.token())
|
||||
});
|
||||
if !valid {
|
||||
self.scenario_reorder_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_event(&mut self, event: Event) -> Task<Message> {
|
||||
// Reject stale envelopes before touching any newer operation or UI state.
|
||||
if event.token.is_nil()
|
||||
|| self.scenario_context() != Some(event.context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection_state| {
|
||||
selection_state.matches(&event.snapshot, selection::context(event.context))
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(&event.intent, Intent::Begin) {
|
||||
if self.scenario_reorder.session.is_some() || !self.scenario_reorder_enabled() {
|
||||
return Task::none();
|
||||
}
|
||||
// Retire the captured Begin before construction, including failed starts.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.scenario_context().expect("admitted scenario");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
match Session::new(
|
||||
self.document.snapshot().decoded.project.clone().into(),
|
||||
context,
|
||||
snapshot,
|
||||
event.token,
|
||||
) {
|
||||
Ok(session) => {
|
||||
self.scenario_reorder.session = Some(session);
|
||||
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
|
||||
return scenario::reorder_view::focus_query(event.token);
|
||||
}
|
||||
Err(reason) => self.status = reason,
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !session.matches(self.project(), event.context, &event.snapshot, event.token) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Intent::Finish(slot) = &event.intent
|
||||
&& session
|
||||
.current_preview()
|
||||
.is_none_or(|preview| preview.slot != *slot)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(&event.intent, Intent::Cancel) {
|
||||
self.scenario_reorder_cancel();
|
||||
self.status = "Reorder cancelled. Sequence unchanged.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if !self.scenario_reorder_enabled() {
|
||||
self.scenario_reorder_cancel();
|
||||
return Task::none();
|
||||
}
|
||||
match event.intent {
|
||||
Intent::Choose(slot) => {
|
||||
let preview = self
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_mut()
|
||||
.expect("admitted session")
|
||||
.preview(slot);
|
||||
self.status = match &preview.outcome {
|
||||
Outcome::Refused(reason) => reason.clone(),
|
||||
Outcome::Noop => "This destination leaves the semantic order unchanged.".into(),
|
||||
Outcome::Change => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||
Some(PresentationStatus::Ready) => "Review the semantic order, then apply reorder. Saved positions are preserved.".into(),
|
||||
Some(PresentationStatus::Fallback { .. }) => "The new semantic order requires automatic display fallback. Apply keeps the requested saved view and its authored positions.".into(),
|
||||
_ => "The candidate cannot be displayed; reorder cannot be applied.".into(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Intent::Query(query) => {
|
||||
self.scenario_reorder.query = query;
|
||||
self.scenario_reorder.page = 0;
|
||||
}
|
||||
Intent::Page(page) => self.scenario_reorder.page = page,
|
||||
Intent::Finish(slot) => {
|
||||
let label = |id| {
|
||||
self.project().elements.get(&id).map_or_else(
|
||||
|| id.to_string(),
|
||||
|element| {
|
||||
format!(
|
||||
"{} (@{})",
|
||||
short_label(&element.name),
|
||||
short_label(&element.alias)
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
let parent_name = label(slot.parent);
|
||||
let destination = slot.before.map_or_else(
|
||||
|| "at the end".to_owned(),
|
||||
|anchor| format!("before {}", label(anchor)),
|
||||
);
|
||||
let session = self
|
||||
.scenario_reorder
|
||||
.session
|
||||
.take()
|
||||
.expect("admitted session");
|
||||
let selection_visible = session
|
||||
.current_preview()
|
||||
.is_some_and(|preview| preview.selection_visible);
|
||||
let result =
|
||||
session.finish(self.project(), event.context, &event.snapshot, event.token);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
match result {
|
||||
Err(reason) => self.status = reason,
|
||||
Ok(completion) => {
|
||||
debug_assert_eq!(completion.representation, event.context.representation);
|
||||
if !selection_visible {
|
||||
self.status = "The candidate would hide a selected occurrence; reorder was not applied.".into();
|
||||
} else if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Unavailable { .. }
|
||||
) {
|
||||
self.status =
|
||||
"The candidate cannot be displayed; reorder was not applied."
|
||||
.into();
|
||||
} else if let Some(command) = completion.command {
|
||||
match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
if let Some(crate::diagram_usage::Target::ScenarioObject(
|
||||
primary,
|
||||
)) = completion.snapshot.primary()
|
||||
{
|
||||
self.selected = *primary;
|
||||
}
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
let mut summary = format!(
|
||||
"Moved {} selected occurrences {} in {}. {}. Saved positions are preserved.",
|
||||
completion.snapshot.targets().len(),
|
||||
destination,
|
||||
parent_name,
|
||||
result_neighborhood(
|
||||
self.project(),
|
||||
slot.parent,
|
||||
&completion.snapshot
|
||||
)
|
||||
);
|
||||
if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Fallback { .. }
|
||||
) {
|
||||
summary.push_str(" Automatic display fallback is active.");
|
||||
}
|
||||
self.scenario_reorder.summary =
|
||||
self.scenario_context().map(|context| (context, summary));
|
||||
self.scenario_reorder.summary_selection =
|
||||
self.multiselect_snapshot();
|
||||
self.status = if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Fallback { .. }
|
||||
) {
|
||||
"Semantic order updated. Automatic display fallback is active; the requested saved view and authored positions are preserved.".into()
|
||||
} else {
|
||||
"Semantic order updated. Saved positions are preserved."
|
||||
.into()
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Reorder was not applied: {error}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.status = "Semantic order unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent::Begin | Intent::Cancel => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summary_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, Slot, direct_tests};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
fn committed() -> (App, Id, Id, oa_core::source_project::SourceBundle) {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let mut extra = project
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Tail", Some(f.owner))
|
||||
.unwrap();
|
||||
if let Some(oa_core::SharedBody::Occurrence { body, .. }) = extra.shared_mut() {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
let extra_id = extra.id;
|
||||
project.elements.insert(extra_id, extra);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(extra_id);
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "summary_view".into(),
|
||||
name: "Summary view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Summary view".into(),
|
||||
}));
|
||||
for (id, choice) in [
|
||||
(f.call, multiselect::Choice::Replace),
|
||||
(f.reply, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
viewport(&mut app, 700.);
|
||||
let baseline = app.document.bundle().clone();
|
||||
dispatch(&mut app, Intent::Begin);
|
||||
let slot = Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
};
|
||||
dispatch(&mut app, Intent::Choose(slot));
|
||||
dispatch(&mut app, Intent::Finish(slot));
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[extra_id, f.call, f.reply]
|
||||
);
|
||||
assert!(app.scenario_reorder.summary.is_some(), "{}", app.status);
|
||||
(app, f.owner, f.call, baseline)
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, intent: Intent) {
|
||||
let event = Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
token: app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map_or_else(Id::new_v4, Session::token),
|
||||
intent,
|
||||
};
|
||||
let _ = app.update(Message::ScenarioReorder(Box::new(event)));
|
||||
}
|
||||
|
||||
fn viewport(app: &mut App, height: f32) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., height),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_survives_diagnostics_resize_and_camera_then_undo_clears() {
|
||||
let (mut app, _, _, baseline) = committed();
|
||||
let summary = app.scenario_reorder.summary.clone();
|
||||
let changed = app.document.bundle().clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
viewport(&mut app, 660.);
|
||||
assert_ne!(
|
||||
app.canvas_epoch, epoch,
|
||||
"real diagnostics-height viewport change must retire input"
|
||||
);
|
||||
assert_eq!(app.scenario_reorder.summary, summary);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
crate::behavior::canvas_workspace::Event::Pan(iced::Vector::new(24., 12.)),
|
||||
)));
|
||||
assert!(app.behavior.scenario.camera_boundary() != camera);
|
||||
assert_eq!(app.scenario_reorder.summary, summary);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
assert_eq!(app.document.bundle(), &baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_retires_on_view_selection_and_new_operation() {
|
||||
for action in 0..3 {
|
||||
let (mut app, owner, call, _) = committed();
|
||||
let source = app.document.bundle().clone();
|
||||
match action {
|
||||
0 => {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Derived { owner },
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Derived { owner }
|
||||
);
|
||||
}
|
||||
1 => {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Collapse(Target::ScenarioObject(call)),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
_ => {
|
||||
dispatch(&mut app, Intent::Begin);
|
||||
assert!(app.scenario_reorder.session.is_some());
|
||||
dispatch(&mut app, Intent::Cancel);
|
||||
assert!(app.scenario_reorder.session.is_none());
|
||||
assert_eq!(app.status, "Reorder cancelled. Sequence unchanged.");
|
||||
}
|
||||
}
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_selection_generation_aba_is_not_a_camera_change() {
|
||||
let (mut app, _, call, _) = committed();
|
||||
let captured = app.multiselect_snapshot().unwrap();
|
||||
let selected = app.diagram_selection.as_mut().unwrap();
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(call)));
|
||||
assert!(selected.set_primary(captured.primary().unwrap()));
|
||||
assert_eq!(selected.targets(), captured.targets());
|
||||
assert_eq!(selected.primary(), captured.primary());
|
||||
app.scenario_reorder_reconcile();
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_clears_after_real_spacing_commit_without_another_event() {
|
||||
use crate::behavior::scenario::spacing::{Event as Spacing, Target as SpacingTarget};
|
||||
let (mut app, owner, call, _) = committed();
|
||||
let reordered = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let begin =
|
||||
crate::app::scenario_spacing::tests::packet_for_app(&app, SpacingTarget::Message(call));
|
||||
let Spacing::Begin { press_screen, .. } = begin else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
|
||||
assert!(app.scenario_reorder.summary.is_some());
|
||||
let screen = press_screen + iced::Vector::new(0., 24.);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
Spacing::Move {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
)));
|
||||
assert_eq!(app.document.bundle(), &reordered.bundle);
|
||||
let finish = Spacing::Finish {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(finish)));
|
||||
assert_ne!(
|
||||
app.document.bundle(),
|
||||
&reordered.bundle,
|
||||
"spacing must actually commit: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements[&owner].steps(),
|
||||
reordered.decoded.project.elements[&owner].steps()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &reordered.bundle);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,33 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
use crate::diagram_usage::Target;
|
||||
use behavior::scenario::{
|
||||
selection,
|
||||
spacing::{Event, Refusal, controller::Finish},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests;
|
||||
|
||||
fn refusal_reason(refusal: &Refusal) -> String {
|
||||
match refusal {
|
||||
Refusal::InvalidSelection(reason) => (*reason).into(),
|
||||
Refusal::TargetNotRendered(_) => {
|
||||
"Every selected object must be a rendered local message or lifeline in this saved view."
|
||||
.into()
|
||||
}
|
||||
Refusal::Geometry(_) => {
|
||||
"These positions cannot be displayed together. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
Refusal::Command(reason) => reason.clone(),
|
||||
_ => {
|
||||
"The drag could not be applied in this view. The saved arrangement is unchanged.".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
@@ -16,22 +40,54 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let stamp = event.stamp();
|
||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(snapshot) = self.multiselect_snapshot() else {
|
||||
return Task::none();
|
||||
};
|
||||
if snapshot.generation() != stamp.selection_generation
|
||||
|| snapshot.context() != selection::context(stamp.context)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let state = &self.behavior.scenario;
|
||||
let matches =
|
||||
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
|
||||
if match event {
|
||||
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
|
||||
_ => !matches,
|
||||
_ => {
|
||||
!matches
|
||||
|| !state.spacing_snapshot().is_some_and(|captured| {
|
||||
self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(captured, selection::context(stamp.context))
|
||||
})
|
||||
})
|
||||
}
|
||||
} {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Begin { .. }) {
|
||||
let visible = selection::visible(self.project(), stamp.context, self.selected);
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
{
|
||||
// This otherwise current Begin cannot be replayed after repair.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status =
|
||||
"Every selected object must still be visible before moving the group.".into();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
if self.behavior.scenario.cancel_spacing() || matches!(event, Event::Begin { .. }) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
@@ -39,18 +95,38 @@ impl App {
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
let result = state.begin_spacing_with_snapshot(self.project(), event, snapshot);
|
||||
self.behavior.scenario = state;
|
||||
if result.is_err() {
|
||||
self.status = "The drag could not start in this view. Try again.".into();
|
||||
if let Err(error) = result {
|
||||
// Consume admitted failed starts too; a repeated Begin is stale.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = refusal_reason(&error);
|
||||
}
|
||||
}
|
||||
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
|
||||
Event::Finish { screen, .. } => {
|
||||
// Full captured selection matched above, before taking the session.
|
||||
let captured = self
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_snapshot()
|
||||
.cloned()
|
||||
.expect("admitted session snapshot");
|
||||
let group = captured.targets().len() > 1;
|
||||
let finished = self.behavior.scenario.finish_spacing(screen);
|
||||
// Retire queued packets even for zero motion or a refused finish.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
match finished {
|
||||
Ok(Finish::Click(target)) if group => {
|
||||
let target = Target::ScenarioObject(target);
|
||||
// The controller only captures a pressed member of this set.
|
||||
if captured.targets().contains(&target) {
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.expect("admitted selection")
|
||||
.replace(target, |_| true);
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
}
|
||||
Ok(Finish::Click(target)) => self.select(target),
|
||||
Ok(Finish::Noop) => {}
|
||||
Ok(Finish::Commit {
|
||||
@@ -58,28 +134,38 @@ impl App {
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => {
|
||||
// Document prepares and writes the source candidate before
|
||||
// committing its snapshot. Failure leaves camera/selection
|
||||
// and source/history at their accepted values.
|
||||
match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
self.selected = selected;
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
self.status = "Scenario spacing updated.".into();
|
||||
}) => match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
self.selected = if group {
|
||||
match captured.primary() {
|
||||
Some(Target::ScenarioObject(id)) => *id,
|
||||
_ => selected,
|
||||
}
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
if group {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
self.status = if group {
|
||||
format!(
|
||||
"Spacing updated for {} selected objects. Scenario order is unchanged.",
|
||||
captured.targets().len()
|
||||
)
|
||||
} else {
|
||||
"Scenario spacing updated.".into()
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
},
|
||||
Err(error) => self.status = refusal_reason(&error),
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.scenario_reorder_reconcile();
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
use super::tests::{begin, finish, move_to, packet_for_app, send, setup};
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection, spacing::Target};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Target as SelectedTarget;
|
||||
use iced::Vector;
|
||||
use oa_core::{
|
||||
DiagramLayout, OccurrenceEndpoint, ScenarioLanePosition, ScenarioLayout,
|
||||
ScenarioMessagePosition,
|
||||
};
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(SelectedTarget::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
fn group(messages: bool, reverse: bool) -> (App, [Id; 2]) {
|
||||
let (mut app, lane, message) = setup();
|
||||
let ids = if messages {
|
||||
[message, id(&app, "second")]
|
||||
} else {
|
||||
[lane, id(&app, "operator")]
|
||||
};
|
||||
let order = if reverse { [ids[1], ids[0]] } else { ids };
|
||||
for member in order {
|
||||
choose(&mut app, member, multiselect::Choice::Add);
|
||||
}
|
||||
(app, ids)
|
||||
}
|
||||
fn target(messages: bool, id: Id) -> Target {
|
||||
if messages {
|
||||
Target::Message(id)
|
||||
} else {
|
||||
Target::Lane(id)
|
||||
}
|
||||
}
|
||||
fn delta(messages: bool, pixels: f32) -> Vector {
|
||||
if messages {
|
||||
Vector::new(0., pixels)
|
||||
} else {
|
||||
Vector::new(pixels, 0.)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_pressed_member_moves_complete_group_and_preserves_primary_source_and_one_history_step() {
|
||||
for messages in [false, true] {
|
||||
for pressed_index in 0..2 {
|
||||
let (mut app, ids) = group(messages, true);
|
||||
// Automatic lane order depends on generated identities. Give this
|
||||
// literal geometry oracle explicit authored starting coordinates.
|
||||
let c = app.scenario_context().unwrap();
|
||||
let Representation::Saved { diagram } = c.representation else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut initial = app.project().clone();
|
||||
let mut members = ids.to_vec();
|
||||
members.sort();
|
||||
let mut layout = ScenarioLayout::default();
|
||||
for member in members {
|
||||
if messages {
|
||||
layout.messages.push(ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: member,
|
||||
},
|
||||
send_y: if member == ids[0] { 148. } else { 240. },
|
||||
});
|
||||
} else {
|
||||
layout.lanes.push(ScenarioLanePosition {
|
||||
lifeline: member,
|
||||
x: if member == ids[0] { 160. } else { 400. },
|
||||
});
|
||||
}
|
||||
}
|
||||
initial.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(layout));
|
||||
app.document =
|
||||
Document::from_project(initial, oa_core::source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
app.sync();
|
||||
let original = app.document.snapshot();
|
||||
let original_selection = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let c = app.scenario_context().unwrap();
|
||||
let Representation::Saved { diagram } = c.representation else {
|
||||
unreachable!()
|
||||
};
|
||||
let (event, start, scale) = begin(&mut app, target(messages, ids[pressed_index]));
|
||||
let world_delta = 24. / scale;
|
||||
move_to(&mut app, event, start + delta(messages, 24.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.spacing_snapshot(),
|
||||
Some(&original_selection)
|
||||
);
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let mut members = ids.to_vec();
|
||||
members.sort();
|
||||
let mut layout = ScenarioLayout::default();
|
||||
for member in members {
|
||||
if messages {
|
||||
let y: f32 = if member == ids[0] { 148. } else { 240. };
|
||||
layout.messages.push(ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: member,
|
||||
},
|
||||
send_y: (f64::from(y) + f64::from(world_delta)) as f32,
|
||||
});
|
||||
} else {
|
||||
let x: f32 = if member == ids[0] { 160. } else { 400. };
|
||||
layout.lanes.push(ScenarioLanePosition {
|
||||
lifeline: member,
|
||||
x: (f64::from(x) + f64::from(world_delta)) as f32,
|
||||
});
|
||||
}
|
||||
}
|
||||
expected.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(layout));
|
||||
finish(&mut app, event, start + delta(messages, 24.));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
original_selection.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
original_selection.primary()
|
||||
);
|
||||
let saved = app.document.bundle().clone();
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let _ = app.update(Message::ShortcutUndo(true));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &saved);
|
||||
let reopened =
|
||||
Document::from_bundle(saved, oa_core::source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_stationary_click_collapses_but_offaxis_outback_cancel_preserve_group_and_redo() {
|
||||
for messages in [false, true] {
|
||||
for mode in 0..4 {
|
||||
let (mut app, ids) = group(messages, true);
|
||||
let (seed, start, _) = begin(&mut app, target(messages, ids[0]));
|
||||
finish(&mut app, seed, start + delta(messages, 20.));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert!(app.document.can_redo());
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, target(messages, ids[1]));
|
||||
match mode {
|
||||
0 => finish(&mut app, event, start),
|
||||
1 => finish(&mut app, event, start + delta(!messages, 50.)),
|
||||
2 => {
|
||||
move_to(&mut app, event, start + delta(messages, 30.));
|
||||
finish(&mut app, event, start);
|
||||
}
|
||||
_ => {
|
||||
move_to(&mut app, event, start + delta(messages, 30.));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: event.stamp(),
|
||||
token: event.token(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(app.document.can_redo() && !app.document.can_undo());
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
if mode == 0 {
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[SelectedTarget::ScenarioObject(ids[1])]
|
||||
);
|
||||
assert_eq!(app.selected, ids[1]);
|
||||
} else {
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(app.selected, primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_group_packets_and_generation_forgery_do_not_disturb_a_newer_live_gesture() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let (old, start, _) = begin(&mut app, Target::Message(ids[0]));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
},
|
||||
);
|
||||
let (current, current_start, _) = begin(&mut app, Target::Message(ids[1]));
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let status = app.status.clone();
|
||||
let mut forged = current;
|
||||
if let Event::Begin { stamp, .. } = &mut forged {
|
||||
stamp.selection_generation = stamp.selection_generation.wrapping_add(1);
|
||||
}
|
||||
for event in [
|
||||
old,
|
||||
forged,
|
||||
Event::Move {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: start + Vector::new(0., 90.),
|
||||
},
|
||||
Event::Finish {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: start + Vector::new(0., 90.),
|
||||
},
|
||||
Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
},
|
||||
] {
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.multiselect_snapshot(), selection);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
}
|
||||
finish(&mut app, current, current_start + Vector::new(0., 24.));
|
||||
assert!(app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_drag_refuses_whole_set_without_primary_only_change_or_replay() {
|
||||
let (mut app, lane, message) = setup();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let event = packet_for_app(&app, Target::Message(message));
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
let Event::Begin { press_screen, .. } = event else {
|
||||
unreachable!()
|
||||
};
|
||||
move_to(&mut app, event, press_screen + Vector::new(0., 30.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
finish(&mut app, event, press_screen + Vector::new(0., 30.));
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let status = app.status.clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_stationary_click_on_either_role_still_selects_only_that_member() {
|
||||
for lane_hit in [false, true] {
|
||||
let (mut app, lane, message) = setup();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let original = app.document.snapshot();
|
||||
let member = if lane_hit { lane } else { message };
|
||||
let (event, start, _) = begin(&mut app, target(!lane_hit, member));
|
||||
finish(&mut app, event, start);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[SelectedTarget::ScenarioObject(member)]
|
||||
);
|
||||
assert_eq!(app.selected, member);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_current_begin_consumes_its_lifetime_without_editing_or_replaying() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let mut event = packet_for_app(&app, Target::Message(ids[0]));
|
||||
if let Event::Begin { canvas_bounds, .. } = &mut event {
|
||||
canvas_bounds.width = 0.;
|
||||
}
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
let epoch = app.canvas_epoch;
|
||||
let status = app.status.clone();
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.status, status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_source_write_failure_is_atomic_and_preserves_selection_and_camera() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
app.sync();
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Message(ids[1]));
|
||||
finish(&mut app, event, start + Vector::new(0., 24.));
|
||||
assert!(
|
||||
app.status.starts_with("Spacing was not applied:"),
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use behavior::scenario::{
|
||||
};
|
||||
use iced::{Point, Size, Vector};
|
||||
|
||||
fn setup() -> (App, Id, Id) {
|
||||
pub(super) fn setup() -> (App, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
@@ -88,14 +88,21 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture()
|
||||
"the current gesture remains usable"
|
||||
);
|
||||
}
|
||||
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let event = packet(
|
||||
pub(in crate::app) fn packet_for_app(app: &App, target: Target) -> Event {
|
||||
let mut event = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
target,
|
||||
);
|
||||
if let Event::Begin { stamp, .. } = &mut event {
|
||||
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
|
||||
}
|
||||
event
|
||||
}
|
||||
pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let event = packet_for_app(app, target);
|
||||
let Event::Begin {
|
||||
press_screen,
|
||||
scale,
|
||||
@@ -108,10 +115,10 @@ fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
(event, press_screen, scale)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
pub(super) fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
}
|
||||
fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Move {
|
||||
@@ -121,7 +128,7 @@ fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
},
|
||||
);
|
||||
}
|
||||
fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
pub(super) fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Finish {
|
||||
|
||||
@@ -116,6 +116,7 @@ impl App {
|
||||
}
|
||||
match event {
|
||||
Event::Selection(event) => self.multiselect_event(event),
|
||||
Event::ModelRemoval(event) => return self.saved_removal_event(*event),
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
|
||||
@@ -1243,6 +1243,7 @@ struct Scene {
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
spacing_stamp: Option<scenario::spacing::Stamp>,
|
||||
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1262,6 +1263,8 @@ struct SequenceSlot {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct SequenceInput {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group_press: Option<ScenarioGroupPress>,
|
||||
context: Option<scenario::Context>,
|
||||
tool: scenario::CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
@@ -1269,6 +1272,13 @@ struct SequenceInput {
|
||||
source: Option<(Id, scenario::Slot)>,
|
||||
press: Option<SequencePress>,
|
||||
}
|
||||
struct ScenarioGroupPress {
|
||||
context: scenario::Context,
|
||||
snapshot: crate::diagram_selection::Snapshot,
|
||||
target: crate::diagram_usage::Target,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Spacing {
|
||||
@@ -1309,11 +1319,48 @@ impl SequenceInput {
|
||||
cancel
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.group_press = None;
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Scene {
|
||||
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
ids.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter(|b| !b.title.is_empty())
|
||||
.map(|b| b.id),
|
||||
);
|
||||
ids.extend(self.lines.iter().map(|line| line.id));
|
||||
ids.extend(self.activations.iter().map(|segment| segment.call));
|
||||
ids.extend(self.render_lanes.keys().copied());
|
||||
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
|
||||
ids.into_iter()
|
||||
.map(crate::diagram_usage::Target::ScenarioObject)
|
||||
.collect()
|
||||
}
|
||||
fn is_selected(&self, id: Id) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.map_or(id == self.selected, |(_, snapshot)| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
|
||||
})
|
||||
}
|
||||
fn is_group(&self) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
|
||||
}
|
||||
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
|
||||
let (context, snapshot) = self.selection.as_ref()?;
|
||||
Some(
|
||||
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
|
||||
)
|
||||
}
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
@@ -1340,6 +1387,7 @@ impl Scene {
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
selection: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
@@ -1982,7 +2030,24 @@ impl canvas::Program<Message> for Scene {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use crate::{app::multiselect::Choice, diagram_usage::Target};
|
||||
use scenario::selection::Intent;
|
||||
use scenario::{CanvasEvent, CanvasTool};
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
if state.group_press.as_ref().is_some_and(|press| {
|
||||
self.selection.as_ref().is_none_or(|(context, snapshot)| {
|
||||
*context != press.context || *snapshot != press.snapshot
|
||||
})
|
||||
}) {
|
||||
state.cancel();
|
||||
}
|
||||
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
@@ -2007,10 +2072,14 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
}
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
@@ -2037,6 +2106,9 @@ impl canvas::Program<Message> for Scene {
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(press) = &mut state.group_press {
|
||||
press.moved |= position.distance(press.start) >= 3.;
|
||||
}
|
||||
if let Some(press) = &mut state.press {
|
||||
match press {
|
||||
SequencePress::Message { start, moved, .. }
|
||||
@@ -2072,9 +2144,22 @@ impl canvas::Program<Message> for Scene {
|
||||
&& tool == CanvasTool::Select
|
||||
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
&& state.press.is_none()
|
||||
&& state.group_press.is_none()
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.is_group() {
|
||||
let (_, snapshot) = self.selection.as_ref()?;
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioRemoval(Box::new(
|
||||
crate::app::scenario_removal::Event::Begin {
|
||||
context,
|
||||
snapshot: Box::new(snapshot.clone()),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
@@ -2089,7 +2174,10 @@ impl canvas::Program<Message> for Scene {
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
}) if state.source.is_some()
|
||||
|| state.press.is_some()
|
||||
|| state.group_press.is_some() =>
|
||||
{
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
@@ -2103,6 +2191,18 @@ impl canvas::Program<Message> for Scene {
|
||||
)
|
||||
});
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
|
||||
let (context, _) = self.selection.as_ref()?;
|
||||
let hit = self.hit_item(world?)?;
|
||||
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
|
||||
request.scenario = Some(Box::new(*context));
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
|
||||
state.cancel();
|
||||
Some(
|
||||
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
|
||||
.and_capture(),
|
||||
)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
return None;
|
||||
@@ -2112,6 +2212,83 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if let Some((selection_context, snapshot)) = &self.selection {
|
||||
let hit = self.hit_item(point).or_else(|| {
|
||||
(!self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
))
|
||||
.then(|| {
|
||||
self.reorder_handle()
|
||||
.filter(|handle| handle.contains(point))
|
||||
})
|
||||
.flatten()
|
||||
.map(|_| SceneHit {
|
||||
id: self.selected,
|
||||
role: SceneHitRole::Box,
|
||||
})
|
||||
});
|
||||
if tool != CanvasTool::Select && self.is_group() {
|
||||
return self.selection_action(Intent::Refuse(
|
||||
"Select one occurrence before using an authoring tool".into(),
|
||||
));
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(hit) = hit {
|
||||
let target = Target::ScenarioObject(hit.id);
|
||||
if state.modifiers.shift() {
|
||||
state.cancel();
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Toggle));
|
||||
}
|
||||
let group_spacing_hit = self.coordinate_input
|
||||
&& self.spacing_stamp.is_some()
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Saved { .. }
|
||||
)
|
||||
&& matches!(
|
||||
hit.role,
|
||||
SceneHitRole::LifelineHeader
|
||||
| SceneHitRole::MessageBody
|
||||
| SceneHitRole::MessageLabel
|
||||
);
|
||||
if self.is_group() && self.is_selected(hit.id) && !group_spacing_hit
|
||||
{
|
||||
state.group_press = Some(ScenarioGroupPress {
|
||||
context: *selection_context,
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: cursor.position()?,
|
||||
moved: false,
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
// Preserve the existing first-press singleton
|
||||
// spacing/reorder gesture. Replacing a displayed
|
||||
// group is an explicit selection change first.
|
||||
if (self.is_group() && !self.is_selected(hit.id))
|
||||
|| !self.coordinate_input
|
||||
{
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Replace));
|
||||
}
|
||||
} else {
|
||||
state.cancel();
|
||||
if state.modifiers.shift() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
// Background clearing shares this press with camera panning.
|
||||
return Some(Action::publish(scenario::selection::message(
|
||||
*selection_context,
|
||||
snapshot,
|
||||
Intent::Clear,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
@@ -2214,6 +2391,27 @@ impl canvas::Program<Message> for Scene {
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if let Some(press) = state.group_press.take() {
|
||||
if world.is_none() {
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let intent = if press.moved {
|
||||
Intent::Refuse(
|
||||
"This selection cannot be moved here; select one occurrence for its individual actions"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Intent::Collapse(press.target)
|
||||
};
|
||||
return Some(
|
||||
Action::publish(scenario::selection::message(
|
||||
press.context,
|
||||
&press.snapshot,
|
||||
intent,
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
@@ -2365,7 +2563,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if guide.id == self.selected {
|
||||
.with_color(if self.is_selected(guide.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2391,7 +2589,7 @@ impl canvas::Program<Message> for Scene {
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if activation.call == self.selected {
|
||||
.with_color(if self.is_selected(activation.call) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2408,7 +2606,7 @@ impl canvas::Program<Message> for Scene {
|
||||
b.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
);
|
||||
let color = if b.id == self.selected {
|
||||
let color = if self.is_selected(b.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2430,7 +2628,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let color = if l.id == self.selected {
|
||||
let color = if self.is_selected(l.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2486,7 +2684,7 @@ impl canvas::Program<Message> for Scene {
|
||||
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
||||
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
||||
size: 13.into(),
|
||||
color: if b.id == self.selected {
|
||||
color: if self.is_selected(b.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
@@ -2516,6 +2714,19 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if self.selection.is_some() {
|
||||
// Lifecycle glyphs use their exact painted hit identity too.
|
||||
for (id, point) in self.lifecycle.selection_markers() {
|
||||
if self.is_selected(id) {
|
||||
f.stroke(
|
||||
&Path::circle(point, 12.),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(if id == self.selected { 3. } else { 1.5 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
@@ -2542,6 +2753,8 @@ impl canvas::Program<Message> for Scene {
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
)
|
||||
&& !self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
|
||||
@@ -6,6 +6,13 @@ use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_removal;
|
||||
pub(crate) mod group_reorder;
|
||||
pub(crate) mod reorder_session;
|
||||
#[cfg(test)]
|
||||
mod reorder_slot_experiments;
|
||||
pub(crate) mod reorder_view;
|
||||
pub(crate) mod selection;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
@@ -143,6 +150,7 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Selection(Box<selection::Event>),
|
||||
Spacing(spacing::Event),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
@@ -186,6 +194,7 @@ impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Spacing(event) => event.stamp().context,
|
||||
Self::Selection(event) => event.context,
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
@@ -1253,26 +1262,21 @@ pub(crate) fn reorder(
|
||||
occurrence: Id,
|
||||
slot: Slot,
|
||||
) -> Result<Command, String> {
|
||||
let to = slot_index(p, owner, slot)?;
|
||||
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
|
||||
return Err(
|
||||
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
|
||||
.into(),
|
||||
);
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
std::sync::Arc::new(p.clone()),
|
||||
owner,
|
||||
&[crate::diagram_usage::Target::ScenarioObject(occurrence)],
|
||||
)?;
|
||||
match prepared.plan(slot)? {
|
||||
group_reorder::Plan::Change(command) => Ok(*command),
|
||||
group_reorder::Plan::Noop => Ok(Command::SetSteps(
|
||||
slot.parent,
|
||||
p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("admitted sequence")
|
||||
.clone(),
|
||||
)),
|
||||
}
|
||||
let mut steps = p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("slot validated")
|
||||
.clone();
|
||||
let from = steps
|
||||
.iter()
|
||||
.position(|id| *id == occurrence)
|
||||
.ok_or("This occurrence is not in the sequence.")?;
|
||||
steps.remove(from);
|
||||
steps.insert(to - usize::from(from < to), occurrence);
|
||||
let command = Command::SetSteps(slot.parent, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
|
||||
represented(p, referenced)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Whole-set local Scenario removal. UI visibility and input lifetime admission
|
||||
//! belong to the caller; this planner validates structural identity and semantics.
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Command, Id, Kind, Layer, OccurrenceBody, Project, SharedBody, execute_batch};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
/// Actual deleted identities, including owned descendants and dependents.
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
/// Surviving records changed by reference clearing, sequence edits or view
|
||||
/// pruning. Embedded surviving placements are included alongside diagrams.
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. }
|
||||
if matches!(
|
||||
body,
|
||||
OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::State { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Fragment { .. }
|
||||
) =>
|
||||
{
|
||||
Some(body)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the actual ordered/operand lists, not only parent pointers. Check the
|
||||
/// complete local tree so malformed siblings cannot become a permissive subset.
|
||||
fn local_occurrences(project: &Project, owner: Id, layer: Layer) -> Result<BTreeSet<Id>, String> {
|
||||
let mut reachable = BTreeSet::new();
|
||||
let mut sequences = vec![owner];
|
||||
while let Some(parent) = sequences.pop() {
|
||||
let steps = project.elements[&parent]
|
||||
.steps()
|
||||
.ok_or("The Scenario sequence is no longer available.")?;
|
||||
for &id in steps {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.ok_or("A Scenario step is missing.")?;
|
||||
let body =
|
||||
step_body(project, id).ok_or("The Scenario contains an unsupported step.")?;
|
||||
if element.parent != Some(parent) || element.layer() != layer || !reachable.insert(id) {
|
||||
return Err("The Scenario lists must contain unique local occurrences of their actual parents.".into());
|
||||
}
|
||||
if let OccurrenceBody::Fragment { branches, .. } = body {
|
||||
for &branch in branches {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&branch)
|
||||
.ok_or("A Scenario operand is missing.")?;
|
||||
if element.parent != Some(id)
|
||||
|| element.layer() != layer
|
||||
|| !matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
|| !reachable.insert(branch)
|
||||
{
|
||||
return Err("The Scenario fragment must list each actual local operand exactly once.".into());
|
||||
}
|
||||
sequences.push(branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reachable)
|
||||
}
|
||||
|
||||
fn changed_records<T: PartialEq>(
|
||||
before: &BTreeMap<Id, T>,
|
||||
after: &BTreeMap<Id, T>,
|
||||
changed: &mut BTreeSet<Id>,
|
||||
) {
|
||||
changed.extend(
|
||||
before
|
||||
.iter()
|
||||
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let owner_element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|element| element.kind() == Kind::Scenario)
|
||||
.ok_or("This Scenario no longer exists.")?;
|
||||
let mut selected = BTreeSet::new();
|
||||
for target in targets {
|
||||
let Target::ScenarioObject(id) = target else {
|
||||
return Err("Remove only local Scenario participants and occurrences together.".into());
|
||||
};
|
||||
if !selected.insert(*id) {
|
||||
return Err("Every selected Scenario subject must be unique.".into());
|
||||
}
|
||||
}
|
||||
if selected.is_empty() {
|
||||
return Err("Select at least one Scenario participant or occurrence to remove.".into());
|
||||
}
|
||||
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let reachable = local_occurrences(project, owner, owner_element.layer())?;
|
||||
for &id in &selected {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.ok_or("A selected Scenario subject no longer exists.")?;
|
||||
let local = if element.kind() == Kind::Lifeline {
|
||||
element.parent == Some(owner) && element.layer() == owner_element.layer()
|
||||
} else {
|
||||
reachable.contains(&id)
|
||||
};
|
||||
if !local {
|
||||
return Err("Every selected subject must be a local participant or a listed occurrence/operand in this Scenario.".into());
|
||||
}
|
||||
}
|
||||
// Admission above includes every original member. An admitted selected
|
||||
// descendant is covered by its selected ancestor's core cascade, not dropped
|
||||
// as an ineligible target. Sending both Delete commands would delete it twice.
|
||||
let roots = selected.iter().copied().filter(|id| {
|
||||
let mut parent = project.elements[id].parent;
|
||||
while let Some(id) = parent {
|
||||
if selected.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
parent = project.elements.get(&id).and_then(|element| element.parent);
|
||||
}
|
||||
true
|
||||
});
|
||||
let command = Command::Batch(roots.map(Command::Delete).collect());
|
||||
// One candidate and one final semantic validation, with no intermediate
|
||||
// Document transactions or singleton validation that can reject a valid union.
|
||||
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let retained: BTreeSet<_> = candidate
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
let removed: BTreeSet<_> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.filter(|id| !retained.contains(id))
|
||||
.collect();
|
||||
if !selected.is_subset(&removed) {
|
||||
return Err("The complete selected set could not be removed.".into());
|
||||
}
|
||||
let mut changed = BTreeSet::new();
|
||||
changed_records(&project.elements, &candidate.elements, &mut changed);
|
||||
changed_records(&project.relations, &candidate.relations, &mut changed);
|
||||
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
|
||||
changed_records(
|
||||
&project.artifact_links,
|
||||
&candidate.artifact_links,
|
||||
&mut changed,
|
||||
);
|
||||
changed_records(&project.traces, &candidate.traces, &mut changed);
|
||||
for (&id, diagram) in &project.diagrams {
|
||||
if let Some(after) = candidate.diagrams.get(&id) {
|
||||
changed_records(&diagram.nodes, &after.nodes, &mut changed);
|
||||
}
|
||||
}
|
||||
Ok(Plan {
|
||||
command,
|
||||
removed,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,824 @@
|
||||
use super::*;
|
||||
use crate::{behavior::topology::occurrence, document::Document};
|
||||
use oa_core::{
|
||||
ArtifactLink, ArtifactRelation, Diagram, DiagramLayout, FragmentOperator, LifelineBinding,
|
||||
MessageKind, OccurrenceEndpoint, Position, ProjectionKind, ScenarioLanePosition,
|
||||
ScenarioLayout, ScenarioMessagePosition, Trace, TraceKind, language,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
work: [Id; 2],
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let element = occurrence(project, parent, name, body);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn body(project: &mut Project, id: Id) -> &mut OccurrenceBody {
|
||||
let SharedBody::Occurrence { body, .. } =
|
||||
project.elements.get_mut(&id).unwrap().shared_mut().unwrap()
|
||||
else {
|
||||
panic!("occurrence")
|
||||
};
|
||||
body
|
||||
}
|
||||
|
||||
fn action(project: &mut Project, parent: Id, name: &str) -> Id {
|
||||
add(
|
||||
project,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn operand(project: &mut Project, fragment: Id, name: &str) -> Id {
|
||||
let element = occurrence(
|
||||
project,
|
||||
fragment,
|
||||
name,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
let OccurrenceBody::Fragment { branches, .. } = body(project, fragment) else {
|
||||
panic!()
|
||||
};
|
||||
branches.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, layer, "Conversation", None)
|
||||
.unwrap();
|
||||
let work = ["Source work", "Target work"].map(|name| {
|
||||
project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let lanes = ["Left", "Right"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, layer, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
for (lane, work) in lanes.into_iter().zip(work) {
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&lane)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(work),
|
||||
};
|
||||
}
|
||||
let messages = ["First", "Second"].map(|name| {
|
||||
add(
|
||||
&mut project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let views = ["Saved A", "Saved B"].map(|name| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: project.fresh_alias(name, "view", id),
|
||||
name: name.into(),
|
||||
description: "Keep authored metadata".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 140.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 540.,
|
||||
},
|
||||
],
|
||||
messages: messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, &occurrence)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y: 220. + index as f32 * 160.,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
oa_core::session::validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
work,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(ids: &[Id]) -> Vec<Target> {
|
||||
ids.iter().copied().map(Target::ScenarioObject).collect()
|
||||
}
|
||||
|
||||
fn layout(project: &mut Project, diagram: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&diagram).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn exact_transaction(project: &Project, plan: Plan, expected: &Project) {
|
||||
assert!(matches!(plan.command, Command::Batch(_)));
|
||||
assert_eq!(
|
||||
&execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(),
|
||||
expected
|
||||
);
|
||||
let mut document = Document::from_project(project.clone(), Default::default()).unwrap();
|
||||
let original = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project(), expected);
|
||||
let changed = document.bundle().clone();
|
||||
assert_eq!(
|
||||
language::parse(&language::format(expected)).unwrap(),
|
||||
*expected
|
||||
);
|
||||
assert_eq!(
|
||||
Document::from_bundle(changed.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
expected
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), project);
|
||||
assert_eq!(document.bundle(), &original);
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), expected);
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_messages_in_both_layers_preserve_definitions_and_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
let baseline = f.project.clone();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&f.messages)).unwrap();
|
||||
let reversed = prepare(
|
||||
&f.project,
|
||||
f.owner,
|
||||
&selected(&[f.messages[1], f.messages[0]]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.command, reversed.command);
|
||||
assert_eq!(plan.removed, f.messages.into_iter().collect());
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.views[0], f.views[1]].into_iter().collect()
|
||||
);
|
||||
let mut expected = f.project.clone();
|
||||
for id in f.messages {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.clear();
|
||||
}
|
||||
for id in f.work {
|
||||
assert_eq!(expected.elements[&id], f.project.elements[&id]);
|
||||
}
|
||||
assert_eq!(f.project, baseline, "preparation is pure");
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_participant_and_message_union_clears_surviving_references_and_prunes_both_views() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let local_action = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Local action",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: Some(f.lanes[0]),
|
||||
},
|
||||
);
|
||||
let observation = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Observation",
|
||||
OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: Some(f.lanes[0]),
|
||||
},
|
||||
);
|
||||
let definition = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Reused definition", None)
|
||||
.unwrap();
|
||||
let remote = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Remote participant",
|
||||
Some(definition),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&remote)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(f.work[0]),
|
||||
};
|
||||
let use_id = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Use definition",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![LifelineBinding {
|
||||
referenced: remote,
|
||||
local: f.lanes[0],
|
||||
}],
|
||||
},
|
||||
);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.lanes[0], f.messages[0]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.lanes[0]);
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1], local_action, observation, use_id];
|
||||
let OccurrenceBody::Exchange { sender, .. } = body(&mut expected, f.messages[1]) else {
|
||||
panic!()
|
||||
};
|
||||
*sender = None;
|
||||
let OccurrenceBody::Action { lifeline, .. } = body(&mut expected, local_action) else {
|
||||
panic!()
|
||||
};
|
||||
*lifeline = None;
|
||||
let OccurrenceBody::State { lifeline, .. } = body(&mut expected, observation) else {
|
||||
panic!()
|
||||
};
|
||||
*lifeline = None;
|
||||
let OccurrenceBody::ScenarioUse { bindings, .. } = body(&mut expected, use_id) else {
|
||||
panic!()
|
||||
};
|
||||
bindings.clear();
|
||||
for view in f.views {
|
||||
let rows = layout(&mut expected, view);
|
||||
rows.lanes.remove(0);
|
||||
rows.messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.lanes[0], f.messages[0]].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[
|
||||
f.owner,
|
||||
f.messages[1],
|
||||
local_action,
|
||||
observation,
|
||||
use_id,
|
||||
f.views[0],
|
||||
f.views[1]
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert_eq!(
|
||||
expected.elements[&definition],
|
||||
f.project.elements[&definition]
|
||||
);
|
||||
assert_eq!(expected.elements[&remote], f.project.elements[&remote]);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_removal_keeps_reply_as_warning_admitted_draft_and_reports_its_changed_identity() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let OccurrenceBody::Exchange { message_kind, .. } = body(&mut f.project, f.messages[0]) else {
|
||||
panic!()
|
||||
};
|
||||
*message_kind = MessageKind::Call;
|
||||
let OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
} = body(&mut f.project, f.messages[1])
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*sender = Some(f.lanes[1]);
|
||||
*receiver = Some(f.lanes[0]);
|
||||
*message_kind = MessageKind::Reply;
|
||||
*reply_to = Some(f.messages[0]);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0], f.lanes[1]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
expected.elements.remove(&f.lanes[1]);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1]];
|
||||
let OccurrenceBody::Exchange {
|
||||
sender, reply_to, ..
|
||||
} = body(&mut expected, f.messages[1])
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*sender = None;
|
||||
*reply_to = None;
|
||||
for view in f.views {
|
||||
let rows = layout(&mut expected, view);
|
||||
rows.lanes.remove(1);
|
||||
rows.messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.messages[0], f.lanes[1]].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.messages[1], f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert!(
|
||||
oa_core::validation::validate(&expected)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "missing_reply_call"
|
||||
&& issue.severity == oa_core::validation::Severity::Warning)
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
fn nested(f: &mut Fixture) -> (Id, [Id; 2], [Id; 2]) {
|
||||
let fragment = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Alternatives",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branches = [
|
||||
operand(&mut f.project, fragment, "Left operand"),
|
||||
operand(&mut f.project, fragment, "Right operand"),
|
||||
];
|
||||
let children = [
|
||||
action(&mut f.project, branches[0], "Left action"),
|
||||
action(&mut f.project, branches[1], "Right action"),
|
||||
];
|
||||
(fragment, branches, children)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_ancestor_covers_all_descendants_after_admission_and_executes_once() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
assert!(
|
||||
execute_batch(
|
||||
&f.project,
|
||||
&[Command::Delete(fragment), Command::Delete(children[0])]
|
||||
)
|
||||
.is_err(),
|
||||
"naive cascading singleton deletes are order-dependent"
|
||||
);
|
||||
let members = [children[0], f.messages[1], fragment, branches[0]];
|
||||
let plan = prepare(&f.project, f.owner, &selected(&members)).unwrap();
|
||||
let roots: BTreeSet<_> = [fragment, f.messages[1]].into_iter().collect();
|
||||
assert_eq!(
|
||||
plan.command,
|
||||
Command::Batch(roots.into_iter().map(Command::Delete).collect())
|
||||
);
|
||||
let removed = [
|
||||
fragment,
|
||||
branches[0],
|
||||
branches[1],
|
||||
children[0],
|
||||
children[1],
|
||||
f.messages[1],
|
||||
];
|
||||
assert_eq!(plan.removed, removed.into_iter().collect());
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.views[0], f.views[1]].into_iter().collect()
|
||||
);
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[0]];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(1);
|
||||
}
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_operands_can_be_removed_together_without_a_reorder_parent_restriction() {
|
||||
let mut f = fixture(Layer::Sa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let plan = prepare(
|
||||
&f.project,
|
||||
f.owner,
|
||||
&selected(&[children[1], f.messages[0], children[0]]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
for id in [children[0], children[1], f.messages[0]] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
for branch in branches {
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
}
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1], fragment];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, branches[0], branches[1], f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_use_are_local_removal_targets_and_reused_definition_survives() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let definition = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
|
||||
.unwrap();
|
||||
let use_id = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Use",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[branches[0], use_id])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
for id in [branches[0], children[0], use_id] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
let OccurrenceBody::Fragment {
|
||||
branches: remaining,
|
||||
..
|
||||
} = body(&mut expected, fragment)
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*remaining = vec![branches[1]];
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[0], f.messages[1], fragment];
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[branches[0], children[0], use_id].into_iter().collect()
|
||||
);
|
||||
assert_eq!(plan.changed, [f.owner, fragment].into_iter().collect());
|
||||
assert_eq!(
|
||||
expected.elements[&definition],
|
||||
f.project.elements[&definition]
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_includes_placements_artifacts_traces_and_changed_surviving_constraints() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let overview = f.project.ensure_diagram(Layer::Oa);
|
||||
let node = f
|
||||
.project
|
||||
.place(overview, f.messages[0], Position { x: 100., y: 100. });
|
||||
let other_node = f
|
||||
.project
|
||||
.place(overview, f.messages[1], Position { x: 400., y: 100. });
|
||||
let counterpart = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
"Opposite-layer counterpart",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let trace = Id::new_v4();
|
||||
f.project.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "manual_trace".into(),
|
||||
name: "Manual trace".into(),
|
||||
description: String::new(),
|
||||
oa: f.messages[0],
|
||||
sa: counterpart,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let artifact = Id::new_v4();
|
||||
f.project.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "evidence".into(),
|
||||
name: "Evidence".into(),
|
||||
description: String::new(),
|
||||
owner: f.messages[0],
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "evidence.txt".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let constraint = f
|
||||
.project
|
||||
.add_element(Kind::Constraint, Layer::Oa, "Constraint", None)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "review".into(),
|
||||
constrained: f.messages.to_vec(),
|
||||
};
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
expected.traces.remove(&trace);
|
||||
expected.artifact_links.remove(&artifact);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&node);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1]];
|
||||
let SharedBody::Constraint { constrained, .. } = expected
|
||||
.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*constrained = vec![f.messages[1]];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.messages[0], node, trace, artifact].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, constraint, overview, f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert_eq!(
|
||||
expected.elements[&counterpart],
|
||||
f.project.elements[&counterpart]
|
||||
);
|
||||
assert_eq!(
|
||||
expected.diagrams[&overview].nodes[&other_node],
|
||||
f.project.diagrams[&overview].nodes[&other_node]
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_typed_selection_refuses_missing_foreign_definitions_and_duplicates_before_collapse() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, _, children) = nested(&mut f);
|
||||
let other = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign", Some(other))
|
||||
.unwrap();
|
||||
for targets in [
|
||||
vec![],
|
||||
vec![Target::Whole],
|
||||
selected(&[f.messages[0], f.messages[0]]),
|
||||
selected(&[f.messages[0], Id::nil()]),
|
||||
selected(&[f.messages[0], f.owner]),
|
||||
selected(&[f.messages[0], f.work[0]]),
|
||||
selected(&[f.messages[0], foreign]),
|
||||
vec![
|
||||
Target::ScenarioObject(f.messages[0]),
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
occurrence: f.messages[1],
|
||||
references: vec![],
|
||||
}),
|
||||
],
|
||||
selected(&[fragment, children[0], children[0]]),
|
||||
selected(&[fragment, Id::nil()]),
|
||||
] {
|
||||
let original = f.project.clone();
|
||||
assert!(prepare(&f.project, f.owner, &targets).is_err());
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
assert!(prepare(&f.project, f.work[0], &selected(&f.messages)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_list_reachability_and_complete_siblings_are_required() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let mut cases = Vec::new();
|
||||
let mut missing_branch = f.project.clone();
|
||||
let OccurrenceBody::Fragment {
|
||||
branches: listed, ..
|
||||
} = body(&mut missing_branch, fragment)
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
listed.clear();
|
||||
cases.push(missing_branch);
|
||||
let mut unlisted_child = f.project.clone();
|
||||
unlisted_child
|
||||
.elements
|
||||
.get_mut(&branches[0])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
cases.push(unlisted_child);
|
||||
let mut dangling_sibling = f.project.clone();
|
||||
dangling_sibling
|
||||
.elements
|
||||
.get_mut(&branches[1])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(Id::new_v4());
|
||||
cases.push(dangling_sibling);
|
||||
let mut duplicate_sibling = f.project.clone();
|
||||
duplicate_sibling
|
||||
.elements
|
||||
.get_mut(&branches[1])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(children[1]);
|
||||
cases.push(duplicate_sibling);
|
||||
let mut wrong_parent = f.project.clone();
|
||||
wrong_parent.elements.get_mut(&children[0]).unwrap().parent = Some(branches[1]);
|
||||
cases.push(wrong_parent);
|
||||
for project in cases {
|
||||
assert!(
|
||||
prepare(&project, f.owner, &selected(&[fragment, children[0]])).is_err(),
|
||||
"ancestor coverage never conceals an invalid original member/tree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_baseline_cannot_be_silently_repaired_and_refusal_preserves_existing_redo() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut invalid = f.project.clone();
|
||||
let duplicate_alias = invalid.elements[&f.owner].alias.clone();
|
||||
invalid.elements.get_mut(&f.messages[0]).unwrap().alias = duplicate_alias;
|
||||
assert!(oa_core::session::validate_candidate(&invalid).is_err());
|
||||
// The final deletion could repair this error, but the planner must admit a
|
||||
// valid original baseline rather than quietly using deletion as repair.
|
||||
assert!(execute_batch(&invalid, &[Command::Delete(f.messages[0])]).is_ok());
|
||||
assert!(prepare(&invalid, f.owner, &selected(&[f.messages[0]])).is_err());
|
||||
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
document
|
||||
.execute(Command::Rename(f.messages[1], "Prior edit".into()))
|
||||
.unwrap();
|
||||
assert!(document.undo());
|
||||
let before = document.bundle().clone();
|
||||
let revision = document.revision();
|
||||
assert!(
|
||||
prepare(
|
||||
document.project(),
|
||||
f.owner,
|
||||
&selected(&[f.messages[0], f.work[0]])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.can_redo());
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Frozen, all-or-nothing reorder of one immediate Scenario sequence.
|
||||
use super::Slot;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Command, Id, Kind, OccurrenceBody, Project, SharedBody, execute_batch};
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Plan {
|
||||
Noop,
|
||||
Change(Box<Command>),
|
||||
}
|
||||
|
||||
pub(crate) struct Prepared {
|
||||
project: Arc<Project>,
|
||||
parent: Id,
|
||||
original: Vec<Id>,
|
||||
selected: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. }
|
||||
if matches!(
|
||||
body,
|
||||
OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::State { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Fragment { .. }
|
||||
) =>
|
||||
{
|
||||
Some(body)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the same structural edges that make rows visible; parent pointers alone
|
||||
// would admit children of an operand omitted from its fragment's branch list.
|
||||
fn reachable_parent(project: &Project, owner: Id, wanted: Id) -> bool {
|
||||
let mut pending = vec![owner];
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(parent) = pending.pop() {
|
||||
if !visited.insert(parent) {
|
||||
continue;
|
||||
}
|
||||
if parent == wanted {
|
||||
return true;
|
||||
}
|
||||
let Some(steps) = project.elements.get(&parent).and_then(|e| e.steps()) else {
|
||||
continue;
|
||||
};
|
||||
for id in steps {
|
||||
if project.elements.get(id).and_then(|e| e.parent) != Some(parent) {
|
||||
continue;
|
||||
}
|
||||
if let Some(OccurrenceBody::Fragment { branches, .. }) = step_body(project, *id) {
|
||||
pending.extend(branches.iter().copied().filter(|branch| {
|
||||
project.elements.get(branch).is_some_and(|element| {
|
||||
element.parent == Some(*id)
|
||||
&& matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
owner: Id,
|
||||
targets: &[Target],
|
||||
) -> Result<Self, String> {
|
||||
if project.elements.get(&owner).map(|e| e.kind()) != Some(Kind::Scenario) {
|
||||
return Err("This scenario no longer exists.".into());
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut parent = None;
|
||||
for target in targets {
|
||||
let Target::ScenarioObject(id) = target else {
|
||||
return Err("Select only ordered occurrences in this scenario.".into());
|
||||
};
|
||||
if !selected.insert(*id) || step_body(&project, *id).is_none() {
|
||||
return Err(
|
||||
"Every selected subject must be a unique ordered scenario occurrence.".into(),
|
||||
);
|
||||
}
|
||||
let actual = project.elements[id]
|
||||
.parent
|
||||
.ok_or("The occurrence has no sequence.")?;
|
||||
if parent.is_some_and(|parent| parent != actual) {
|
||||
return Err(
|
||||
"Reorder inside one immediate sequence; dragging never changes operands."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
parent = Some(actual);
|
||||
}
|
||||
let parent = parent.ok_or("Select at least one occurrence.")?;
|
||||
if !reachable_parent(&project, owner, parent) {
|
||||
return Err("The selected sequence is not reachable in this scenario.".into());
|
||||
}
|
||||
let original = project.elements[&parent]
|
||||
.steps()
|
||||
.ok_or("The parent is not a sequence.")?
|
||||
.clone();
|
||||
let mut listed = BTreeSet::new();
|
||||
for id in &original {
|
||||
if !listed.insert(*id)
|
||||
|| step_body(&project, *id).is_none()
|
||||
|| project.elements[id].parent != Some(parent)
|
||||
{
|
||||
return Err("The complete sequence must contain unique, existing occurrences of this parent.".into());
|
||||
}
|
||||
}
|
||||
if !selected.is_subset(&listed) {
|
||||
return Err("A selected occurrence is not in this sequence.".into());
|
||||
}
|
||||
// Cache a validated immutable baseline, including descendants and other
|
||||
// sequences. Advisory authoring diagnostics deliberately remain allowed.
|
||||
oa_core::session::validate_candidate(&project).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
project,
|
||||
parent,
|
||||
original,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(&self, slot: Slot) -> Result<Plan, String> {
|
||||
if slot.parent != self.parent {
|
||||
return Err("Reorder inside the occurrence's current sequence; dragging never changes its operand.".into());
|
||||
}
|
||||
if let Some(anchor) = slot.before {
|
||||
if !self.original.contains(&anchor) {
|
||||
return Err("The insertion slot changed. Choose its position again.".into());
|
||||
}
|
||||
if self.selected.contains(&anchor) {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
}
|
||||
let mut steps: Vec<_> = self
|
||||
.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !self.selected.contains(id))
|
||||
.collect();
|
||||
let at = slot.before.map_or(steps.len(), |anchor| {
|
||||
steps
|
||||
.iter()
|
||||
.position(|id| *id == anchor)
|
||||
.expect("admitted anchor")
|
||||
});
|
||||
steps.splice(
|
||||
at..at,
|
||||
self.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| self.selected.contains(id)),
|
||||
);
|
||||
if steps == self.original {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
let command = Command::SetSteps(self.parent, steps);
|
||||
execute_batch(&self.project, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(Plan::Change(Box::new(command)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,720 @@
|
||||
use super::*;
|
||||
use crate::{behavior::topology::occurrence, document::Document};
|
||||
use oa_core::{FragmentOperator, Layer, LifelineBinding, MessageKind, language};
|
||||
|
||||
fn add(p: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let e = occurrence(p, parent, name, body);
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn row(p: &mut Project, parent: Id, name: &str) -> Id {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn operand(p: &mut Project, parent: Id, operator: FragmentOperator) -> (Id, Id) {
|
||||
let fragment = add(
|
||||
p,
|
||||
parent,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
(fragment, id)
|
||||
}
|
||||
fn prepare(p: &Project, owner: Id, ids: &[Id]) -> Result<Prepared, String> {
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&ids.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
fn slot(parent: Id, before: Option<Id>) -> Slot {
|
||||
Slot { parent, before }
|
||||
}
|
||||
fn ordered(p: &Project, parent: Id, ids: &[Id]) -> Project {
|
||||
let mut expected = p.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = ids.to_vec();
|
||||
expected
|
||||
}
|
||||
fn exact_transaction(p: &Project, plan: Plan, expected: Project) {
|
||||
let Plan::Change(command) = plan else {
|
||||
panic!("expected a change")
|
||||
};
|
||||
assert_eq!(
|
||||
execute_batch(p, std::slice::from_ref(&command)).unwrap(),
|
||||
expected
|
||||
);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let original_source = doc.bundle().clone();
|
||||
doc.execute(*command).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(doc.project())).unwrap(),
|
||||
expected
|
||||
);
|
||||
let changed_source = doc.bundle().clone();
|
||||
assert_eq!(
|
||||
Document::from_bundle(changed_source.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), p);
|
||||
assert_eq!(doc.bundle(), &original_source);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(doc.bundle(), &changed_source);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
fn four(nested: bool) -> (Project, Id, Id, [Id; 4]) {
|
||||
four_in_layer(nested, Layer::Oa)
|
||||
}
|
||||
fn four_in_layer(nested: bool, layer: Layer) -> (Project, Id, Id, [Id; 4]) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(oa_core::Kind::Scenario, layer, "Scenario", None)
|
||||
.unwrap();
|
||||
let parent = if nested {
|
||||
operand(&mut p, owner, FragmentOperator::Alternative).1
|
||||
} else {
|
||||
owner
|
||||
};
|
||||
let ids = ["A", "B", "C", "D"].map(|name| row(&mut p, parent, name));
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
(p, owner, parent, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_operand_use_semantic_block_order_with_exact_history() {
|
||||
for (nested, layer) in [
|
||||
(false, Layer::Oa),
|
||||
(true, Layer::Oa),
|
||||
(false, Layer::Sa),
|
||||
(true, Layer::Sa),
|
||||
] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four_in_layer(nested, layer);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, None)).unwrap(),
|
||||
ordered(&p, parent, &[b, d, a, c]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_wrapper_noop_preserves_existing_redo_and_source() {
|
||||
for nested in [false, true] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(nested);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let change = super::super::reorder(doc.project(), owner, a, slot(parent, None)).unwrap();
|
||||
doc.execute(change).unwrap();
|
||||
assert_eq!(doc.project(), &ordered(&p, parent, &[b, c, d, a]));
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for before in [Some(a), Some(b)] {
|
||||
let noop =
|
||||
super::super::reorder(doc.project(), owner, a, slot(parent, before)).unwrap();
|
||||
doc.execute(noop).unwrap();
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_anchor_and_exact_noop_preserve_redo_and_frozen_snapshot() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let Plan::Change(command) = prepared.plan(slot(parent, Some(d))).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
doc.execute(*command).unwrap();
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for plan in [
|
||||
prepared.plan(slot(parent, Some(c))),
|
||||
prepare(&p, owner, &[a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(b))),
|
||||
prepare(&p, owner, &[c, d])
|
||||
.unwrap()
|
||||
.plan(slot(parent, None)),
|
||||
] {
|
||||
assert!(matches!(plan.unwrap(), Plan::Noop));
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
// The earlier prepared value still plans from its original owned snapshot.
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_every_invalid_member_list_and_destination_before_noop() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
for ids in [vec![], vec![a, a], vec![a, Id::new_v4()]] {
|
||||
assert!(prepare(&p, owner, &ids).is_err());
|
||||
}
|
||||
for target in [Target::Whole, Target::Placement(a)] {
|
||||
assert!(
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&[Target::ScenarioObject(a), target]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut with_line = p.clone();
|
||||
let line = with_line
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Line", Some(owner))
|
||||
.unwrap();
|
||||
assert!(prepare(&with_line, owner, &[a, line]).is_err());
|
||||
let mut nested = p.clone();
|
||||
let (fragment, branch) = operand(&mut nested, owner, FragmentOperator::Alternative);
|
||||
let child = row(&mut nested, branch, "Child");
|
||||
assert!(prepare(&nested, owner, &[a, child]).is_err());
|
||||
assert!(prepare(&nested, owner, &[fragment, child]).is_err());
|
||||
let prepared = prepare(&p, owner, &[a, c]).unwrap();
|
||||
assert!(prepared.plan(slot(branch, Some(a))).is_err());
|
||||
assert!(prepared.plan(slot(parent, Some(Id::new_v4()))).is_err());
|
||||
for list in [vec![a, b, c, b], vec![a, Id::new_v4(), c, d]] {
|
||||
assert!(prepare(&ordered(&p, parent, &list), owner, &[a, c]).is_err());
|
||||
}
|
||||
let mut wrong_parent = p.clone();
|
||||
wrong_parent.elements.get_mut(&b).unwrap().parent = Some(branch);
|
||||
assert!(prepare(&wrong_parent, owner, &[a, c]).is_err());
|
||||
for body in [
|
||||
OccurrenceBody::Control {
|
||||
kind: oa_core::ControlKind::Choice,
|
||||
},
|
||||
OccurrenceBody::SequenceReference { sequence: None },
|
||||
] {
|
||||
let mut wrong_type = p.clone();
|
||||
if let Some(SharedBody::Occurrence { body: actual, .. }) =
|
||||
wrong_type.elements.get_mut(&b).unwrap().shared_mut()
|
||||
{
|
||||
*actual = body;
|
||||
}
|
||||
assert!(prepare(&wrong_type, owner, &[a, c]).is_err());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = nested.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.clear();
|
||||
}
|
||||
assert!(
|
||||
prepare(&nested, owner, &[child]).is_err(),
|
||||
"parent ancestry cannot admit omitted operand"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_warning_child_is_preserved_but_cannot_be_selected() {
|
||||
let (mut p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let hidden = row(&mut p, parent, "Hidden draft");
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.pop();
|
||||
assert!(
|
||||
oa_core::validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unordered_occurrence")
|
||||
);
|
||||
assert!(prepare(&p, owner, &[hidden]).is_err());
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, owner, &[c, a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(d)))
|
||||
.unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reply_moves_as_explicit_stable_pair_and_invalid_baseline_refuses_noop() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
let prepared = prepare(&p, f.owner, &[f.reply, f.call]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
assert!(
|
||||
prepare(&p, f.owner, &[f.reply])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
let invalid = ordered(&p, f.owner, &[f.reply, x, f.call, y]);
|
||||
assert!(prepare(&invalid, f.owner, &[x]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_fragment_descendant_reply_is_validated_and_subtree_preserved() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(inner);
|
||||
p.elements
|
||||
.get_mut(&inner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, fragment, y]);
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[f.call, y, x, fragment]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_lifetime_and_opaque_local_bindings_are_checked_without_expansion() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = execute_batch(
|
||||
&f.project,
|
||||
&[Command::Delete(f.reply), Command::Delete(f.call)],
|
||||
)
|
||||
.unwrap();
|
||||
let create = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Create",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(f.sender),
|
||||
receiver: Some(f.receiver),
|
||||
message_kind: MessageKind::Create,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
add(
|
||||
&mut p,
|
||||
inner,
|
||||
"Use local",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[create, y, x, fragment]),
|
||||
);
|
||||
let definition = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
|
||||
.unwrap();
|
||||
let represented = match p.elements[&f.receiver].shared().unwrap() {
|
||||
SharedBody::Lifeline { represented } => *represented,
|
||||
_ => panic!(),
|
||||
};
|
||||
let remote = p
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Formal", Some(definition))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::Lifeline { represented };
|
||||
let uses = ["First use", "Second use"].map(|name| {
|
||||
add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![LifelineBinding {
|
||||
referenced: remote,
|
||||
local: f.receiver,
|
||||
}],
|
||||
},
|
||||
)
|
||||
});
|
||||
let prepared = prepare(&p, f.owner, &[uses[1], uses[0]]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, Some(x))).unwrap(),
|
||||
ordered(&p, f.owner, &[create, uses[0], uses[1], x, fragment, y]),
|
||||
);
|
||||
}
|
||||
|
||||
fn delete_line(p: &mut Project, parent: Id, sender: Id, receiver: Id) {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
"Delete participant",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: MessageKind::Delete,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn second_operand(p: &mut Project, fragment: Id) -> Id {
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Other operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "otherwise".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
branches.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_delete_rejects_moving_an_earlier_use_after_the_fragment() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
delete_line(&mut p, branch, f.sender, f.receiver);
|
||||
row(&mut p, f.owner, "Y");
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let prepared = prepare(&p, f.owner, &[x, use_line]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_operand_move_is_legal_but_parallel_baseline_is_invalid() {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (fragment, left) = operand(&mut p, f.owner, operator);
|
||||
let right = second_operand(&mut p, fragment);
|
||||
delete_line(&mut p, left, f.sender, f.receiver);
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
right,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, right, "X");
|
||||
let y = row(&mut p, right, "Y");
|
||||
if operator == FragmentOperator::Alternative {
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[x, use_line])
|
||||
.unwrap()
|
||||
.plan(slot(right, None))
|
||||
.unwrap(),
|
||||
ordered(&p, right, &[y, use_line, x]),
|
||||
);
|
||||
} else {
|
||||
// This is an invalid input control, not a valid model broken by reorder.
|
||||
assert!(
|
||||
oa_core::session::validate_candidate(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[x, use_line]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_reply_warning_does_not_block_independent_sibling_reorder() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Loop);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.reply);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(branch);
|
||||
p.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let x = row(&mut p, branch, "X");
|
||||
let y = row(&mut p, branch, "Y");
|
||||
let z = row(&mut p, branch, "Z");
|
||||
let warning = |project: &Project| {
|
||||
oa_core::validation::validate(project).iter().any(|issue| {
|
||||
issue.element == Some(f.reply)
|
||||
&& issue.code == "reply_repetition"
|
||||
&& issue.severity == oa_core::validation::Severity::Warning
|
||||
})
|
||||
};
|
||||
assert!(warning(&p));
|
||||
let expected = ordered(&p, branch, &[f.reply, y, x, z]);
|
||||
assert!(warning(&expected));
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[z, x])
|
||||
.unwrap()
|
||||
.plan(slot(branch, None))
|
||||
.unwrap(),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_reorder_preserves_affected_and_unrelated_authored_layouts() {
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition,
|
||||
ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
for (alias, offset) in [("group_saved_a", 0.), ("group_saved_b", 1500.)] {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: "Preserve my layout".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: -300. + offset,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.receiver,
|
||||
x: 700. + offset,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 123. + offset,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.reply,
|
||||
},
|
||||
send_y: 567. + offset,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
let unrelated = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Unrelated scenario", None)
|
||||
.unwrap();
|
||||
let line = p
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Unrelated clock",
|
||||
Some(unrelated),
|
||||
)
|
||||
.unwrap();
|
||||
let timer = add(
|
||||
&mut p,
|
||||
unrelated,
|
||||
"Wait",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(line),
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "5 s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "group_unrelated_saved".into(),
|
||||
name: "Unrelated arrangement".into(),
|
||||
description: "Do not touch this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario {
|
||||
scenario: unrelated,
|
||||
},
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: line,
|
||||
x: -123.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: timer,
|
||||
},
|
||||
send_y: 987.,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
);
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[f.reply, f.call])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
}
|
||||
@@ -218,6 +218,7 @@ fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestu
|
||||
let stamp = spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: 17,
|
||||
selection_generation: 0,
|
||||
};
|
||||
ready_scene.coordinate_input = true;
|
||||
ready_scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Immutable semantic reorder operation, independent of its destination UI.
|
||||
use super::{
|
||||
Context, Slot,
|
||||
group_reorder::{Plan, Prepared},
|
||||
presentation, selection,
|
||||
};
|
||||
use crate::{diagram_selection::Snapshot, diagram_usage::Target};
|
||||
use oa_core::{Command, Id, Project, execute_batch};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
Change,
|
||||
Noop,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub(crate) struct Preview {
|
||||
pub(crate) slot: Slot,
|
||||
pub(crate) before: Vec<Id>,
|
||||
pub(crate) after: Vec<Id>,
|
||||
pub(crate) outcome: Outcome,
|
||||
/// Geometry status is independent of semantic validity; requested Saved
|
||||
/// representation is retained even when presentation reports fallback.
|
||||
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
||||
pub(crate) selection_visible: bool,
|
||||
command: Option<Box<Command>>,
|
||||
}
|
||||
|
||||
pub(crate) struct Completion {
|
||||
pub(crate) command: Option<Box<Command>>,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) representation: super::Representation,
|
||||
pub(crate) presentation_status: presentation::PresentationStatus,
|
||||
}
|
||||
|
||||
pub(crate) struct Session {
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
primary: Id,
|
||||
parent: Id,
|
||||
before: Vec<Id>,
|
||||
prepared: Prepared,
|
||||
preview: Option<Preview>,
|
||||
#[cfg(test)]
|
||||
proposal_builds: usize,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Self, String> {
|
||||
if token.is_nil()
|
||||
|| context.document.is_nil()
|
||||
|| context.visit.is_nil()
|
||||
|| snapshot.context() != selection::context(context)
|
||||
|| presentation::checked_owner(&project, context.representation)
|
||||
!= Some(context.scenario)
|
||||
{
|
||||
return Err("The reorder input no longer matches this scenario view.".into());
|
||||
}
|
||||
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
|
||||
return Err("Select ordered scenario occurrences.".into());
|
||||
};
|
||||
let primary = *primary;
|
||||
let visible = selection::visible(&project, context, primary);
|
||||
if !snapshot
|
||||
.targets()
|
||||
.contains(&Target::ScenarioObject(primary))
|
||||
|| snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|target| !visible.contains(target))
|
||||
{
|
||||
return Err("Every selected occurrence must be visible in this scenario view.".into());
|
||||
}
|
||||
let prepared = Prepared::new(project.clone(), context.scenario, snapshot.targets())?;
|
||||
let parent = project.elements[&primary]
|
||||
.parent
|
||||
.expect("prepared occurrence parent");
|
||||
let before = project.elements[&parent]
|
||||
.steps()
|
||||
.expect("prepared sequence")
|
||||
.clone();
|
||||
Ok(Self {
|
||||
project,
|
||||
context,
|
||||
snapshot,
|
||||
token,
|
||||
primary,
|
||||
parent,
|
||||
before,
|
||||
prepared,
|
||||
preview: None,
|
||||
#[cfg(test)]
|
||||
proposal_builds: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
pub(crate) fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
pub(crate) fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
pub(crate) fn parent(&self) -> Id {
|
||||
self.parent
|
||||
}
|
||||
pub(crate) fn before(&self) -> &[Id] {
|
||||
&self.before
|
||||
}
|
||||
|
||||
pub(crate) fn current_preview(&self) -> Option<&Preview> {
|
||||
self.preview.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_preview_mut(&mut self) -> Option<&mut Preview> {
|
||||
self.preview.as_mut()
|
||||
}
|
||||
|
||||
pub(crate) fn matches(
|
||||
&self,
|
||||
current: &Project,
|
||||
context: Context,
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> bool {
|
||||
current == self.project.as_ref()
|
||||
&& context == self.context
|
||||
&& snapshot == &self.snapshot
|
||||
&& token == self.token
|
||||
}
|
||||
|
||||
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
|
||||
if self
|
||||
.preview
|
||||
.as_ref()
|
||||
.is_none_or(|preview| preview.slot != slot)
|
||||
{
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.proposal_builds += 1;
|
||||
}
|
||||
let result = self.prepared.plan(slot).and_then(|plan| match plan {
|
||||
Plan::Noop => Ok((None, self.project.clone())),
|
||||
Plan::Change(command) => {
|
||||
let candidate =
|
||||
execute_batch(&self.project, std::slice::from_ref(command.as_ref()))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((Some(command), Arc::new(candidate)))
|
||||
}
|
||||
});
|
||||
let preview = match result {
|
||||
Ok((command, candidate)) => {
|
||||
let shown = presentation::present(
|
||||
&candidate,
|
||||
self.context.representation,
|
||||
self.primary,
|
||||
);
|
||||
let visible = shown
|
||||
.scene()
|
||||
.map_or_else(Vec::new, |scene| scene.selection_targets());
|
||||
let selection_visible = self
|
||||
.snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target));
|
||||
Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: candidate.elements[&self.parent]
|
||||
.steps()
|
||||
.expect("validated sequence")
|
||||
.clone(),
|
||||
outcome: if command.is_some() {
|
||||
Outcome::Change
|
||||
} else {
|
||||
Outcome::Noop
|
||||
},
|
||||
presentation: Some(shown),
|
||||
selection_visible,
|
||||
command,
|
||||
}
|
||||
}
|
||||
Err(reason) => Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: self.before.clone(),
|
||||
outcome: Outcome::Refused(reason),
|
||||
presentation: None,
|
||||
selection_visible: false,
|
||||
command: None,
|
||||
},
|
||||
};
|
||||
self.preview = Some(preview);
|
||||
}
|
||||
self.preview.as_ref().expect("explicit destination preview")
|
||||
}
|
||||
|
||||
/// Consume once; never execute a Document transaction or alter selection.
|
||||
pub(crate) fn finish(
|
||||
self,
|
||||
current: &Project,
|
||||
context: Context,
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Completion, String> {
|
||||
if !self.matches(current, context, snapshot, token) {
|
||||
return Err("The reorder input changed; choose the operation again.".into());
|
||||
}
|
||||
let preview = self
|
||||
.preview
|
||||
.ok_or("Choose an explicit destination before applying reorder.")?;
|
||||
if let Outcome::Refused(reason) = preview.outcome {
|
||||
return Err(reason);
|
||||
}
|
||||
let presentation_status = preview
|
||||
.presentation
|
||||
.expect("valid plan presentation")
|
||||
.status()
|
||||
.clone();
|
||||
Ok(Completion {
|
||||
command: preview.command,
|
||||
snapshot: self.snapshot,
|
||||
representation: self.context.representation,
|
||||
presentation_status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,354 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
behavior::{
|
||||
scenario::{Representation, direct_tests},
|
||||
topology::occurrence,
|
||||
},
|
||||
diagram_selection::Selection,
|
||||
};
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
fn row(project: &mut Project, parent: Id, name: &str) -> Id {
|
||||
let element = occurrence(
|
||||
project,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn snapshot(context: Context, ids: &[Id]) -> Snapshot {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
for id in ids {
|
||||
selection.add(Target::ScenarioObject(*id), |_| true);
|
||||
}
|
||||
selection.snapshot(selection::context(context)).unwrap()
|
||||
}
|
||||
fn session(project: &Project, context: Context, ids: &[Id]) -> Session {
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, ids),
|
||||
Id::new_v4(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_destination_cache_and_exact_whole_model_saved_presentation() {
|
||||
for mode in 0..3 {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let x = row(&mut project, f.owner, "X");
|
||||
let y = row(&mut project, f.owner, "Y");
|
||||
let mut context = direct_tests::context(f.owner);
|
||||
for name in ["A", "B"] {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: name.into(),
|
||||
name: name.into(),
|
||||
description: "Keep this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![oa_core::ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: if name == "A" { 180. } else { 220. },
|
||||
}],
|
||||
messages: [(f.call, 600.), (f.reply, 800.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
if mode != 0 && name == "A" {
|
||||
context.representation = Representation::Saved { diagram: id };
|
||||
if mode == 2 {
|
||||
project.diagrams.get_mut(&id).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: [(f.call, 600.), (f.reply, 200.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut operation = session(&project, context, &[f.reply, f.call]);
|
||||
assert_eq!(operation.parent(), f.owner);
|
||||
assert_eq!(operation.before(), &[f.call, f.reply, x, y]);
|
||||
assert_eq!(operation.context(), context);
|
||||
let original_snapshot = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
let slot = Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
};
|
||||
let preview = operation.preview(slot);
|
||||
assert_eq!(preview.before, [f.call, f.reply, x, y]);
|
||||
assert_eq!(preview.after, [x, y, f.call, f.reply]);
|
||||
assert_eq!(preview.outcome, Outcome::Change);
|
||||
assert_eq!(
|
||||
preview.presentation.as_ref().unwrap().requested(),
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert_eq!(operation.proposal_builds, 2);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 3);
|
||||
assert_eq!(operation.preview.as_ref().unwrap().slot, slot);
|
||||
let completion = operation
|
||||
.finish(&project, context, &original_snapshot, token)
|
||||
.unwrap();
|
||||
assert_eq!(completion.snapshot, original_snapshot);
|
||||
assert_eq!(completion.representation, context.representation);
|
||||
if mode == 2 {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Fallback { .. }
|
||||
));
|
||||
} else {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
}
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![x, y, f.call, f.reply];
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[*completion.command.unwrap()]).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.diagrams, project.diagrams);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_destination_is_not_noop_and_selected_anchor_is_explicit_noop() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let token = operation.token();
|
||||
assert_eq!(
|
||||
operation
|
||||
.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.reply)
|
||||
})
|
||||
.outcome,
|
||||
Outcome::Noop
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.unwrap()
|
||||
.command
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_candidate_refuses_without_changing_baseline_or_selection() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let later = row(&mut project, f.owner, "Later action");
|
||||
let mut operation = session(&project, context, &[f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
// A previously valid proposal must not survive a new refused destination.
|
||||
let valid = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
assert_eq!(valid.outcome, Outcome::Change);
|
||||
assert_eq!(valid.after, [f.call, later, f.reply]);
|
||||
let preview = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert!(matches!(&preview.outcome, Outcome::Refused(reason) if reason.contains("reply_order")));
|
||||
assert!(preview.presentation.is_none());
|
||||
assert_eq!(preview.before, preview.after);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_cross_parent_and_unrendered_sets_never_reduce_to_primary() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let other = project
|
||||
.add_element(oa_core::Kind::Scenario, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let foreign = row(&mut project, other, "Foreign");
|
||||
let fragment = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: oa_core::FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let fragment_id = fragment.id;
|
||||
project.elements.insert(fragment_id, fragment);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(fragment_id);
|
||||
let branch = occurrence(
|
||||
&project,
|
||||
fragment_id,
|
||||
"Branch",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let branch_id = branch.id;
|
||||
project.elements.insert(branch_id, branch);
|
||||
if let Some(oa_core::SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&fragment_id).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch_id);
|
||||
}
|
||||
let nested = row(&mut project, branch_id, "Nested");
|
||||
assert!(
|
||||
selection::visible(&project, context, f.call).contains(&Target::ScenarioObject(nested))
|
||||
);
|
||||
for ids in [
|
||||
vec![f.sender, f.call],
|
||||
vec![nested, f.call],
|
||||
vec![foreign, f.call],
|
||||
vec![Id::new_v4(), f.call],
|
||||
] {
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, &ids),
|
||||
Id::new_v4()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_checks_every_immutable_input_and_constructor_rejects_nil_lifetimes() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
for altered in 0..8 {
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let mut token = operation.token();
|
||||
let mut captured = operation.snapshot().clone();
|
||||
let mut current = f.project.clone();
|
||||
let mut live = context;
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
match altered {
|
||||
0 => {
|
||||
current.elements.get_mut(&f.call).unwrap().name.push('!');
|
||||
}
|
||||
1 => live.epoch += 1,
|
||||
2 => captured = snapshot(context, &[f.reply, f.call]),
|
||||
3 => token = Id::new_v4(),
|
||||
4 => live.visit = Id::new_v4(),
|
||||
5 => live.document = Id::new_v4(),
|
||||
6 => live.revision += 1,
|
||||
_ => {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
selection.set_primary(&Target::ScenarioObject(f.call));
|
||||
selection.set_primary(&Target::ScenarioObject(f.reply));
|
||||
captured = selection.snapshot(selection::context(context)).unwrap();
|
||||
assert_eq!(captured.targets(), operation.snapshot().targets());
|
||||
assert_eq!(captured.primary(), operation.snapshot().primary());
|
||||
assert_ne!(captured.generation(), operation.snapshot().generation());
|
||||
}
|
||||
}
|
||||
assert!(operation.finish(¤t, live, &captured, token).is_err());
|
||||
}
|
||||
for altered in 0..3 {
|
||||
let mut live = context;
|
||||
let mut token = Id::new_v4();
|
||||
match altered {
|
||||
0 => live.document = Id::nil(),
|
||||
1 => live.visit = Id::nil(),
|
||||
_ => token = Id::nil(),
|
||||
}
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(f.project.clone()),
|
||||
live,
|
||||
snapshot(live, &[f.call]),
|
||||
token
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Candidate-input experiment, not an accepted new interaction policy.
|
||||
//! Exercise production presentation, nearest-Y picking, and exact semantic slots.
|
||||
use super::{Representation, Slot, group_reorder, presentation};
|
||||
use crate::{behavior::Scene, diagram_usage::Target};
|
||||
use iced::Point;
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, Id, OccurrenceEndpoint, Project, ProjectionKind, ScenarioLayout,
|
||||
ScenarioMessagePosition, execute_batch, language, validation,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
diagram: Id,
|
||||
ids: [Id; 4],
|
||||
}
|
||||
|
||||
fn fixture(ys: [f32; 4]) -> Fixture {
|
||||
let mut project = language::parse(SOURCE).unwrap();
|
||||
let named = |name: &str| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let owner = named("conversation");
|
||||
let ids = [named("a"), named("b"), named("c"), named("d")];
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "saved".into(),
|
||||
name: "Authored coordinates".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: ids
|
||||
.into_iter()
|
||||
.zip(ys)
|
||||
.map(|(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.all(|issue| issue.severity != validation::Severity::Error)
|
||||
);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
diagram,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
fn shown(f: &Fixture) -> presentation::ScenarioPresentation {
|
||||
let shown = presentation::present(
|
||||
&f.project,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
f.ids[0],
|
||||
);
|
||||
assert_eq!(shown.status(), &presentation::PresentationStatus::Ready);
|
||||
assert_eq!(shown.requested(), shown.geometry_representation().unwrap());
|
||||
shown
|
||||
}
|
||||
|
||||
fn before(f: &Fixture, id: Id) -> Slot {
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn y(scene: &Scene, slot: Slot) -> f32 {
|
||||
scene.slots.iter().find(|item| item.slot == slot).unwrap().y
|
||||
}
|
||||
|
||||
fn picked(scene: &Scene, f: &Fixture, world_y: f32) -> Slot {
|
||||
scene
|
||||
.slot_at(Point::new(100., world_y), Some(f.owner))
|
||||
.unwrap()
|
||||
.slot
|
||||
}
|
||||
|
||||
fn exact_change(f: &Fixture, selected: &[Id], slot: Slot, order: &[Id]) {
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
Arc::new(f.project.clone()),
|
||||
f.owner,
|
||||
&selected
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let group_reorder::Plan::Change(command) = prepared.plan(slot).unwrap() else {
|
||||
panic!("expected an actual semantic change")
|
||||
};
|
||||
let actual = execute_batch(&f.project, std::slice::from_ref(command.as_ref())).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = order.to_vec();
|
||||
// Literal full-model oracle: every authored position and other field survives.
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&actual)).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
presentation::present(
|
||||
&actual,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
selected[0]
|
||||
)
|
||||
.status(),
|
||||
&presentation::PresentationStatus::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossed_saved_slots_are_reachable_but_screen_order_is_not_semantic_order() {
|
||||
let f = fixture([200., 500., 300., 400.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(
|
||||
f.ids.map(|id| y(scene, before(&f, id))),
|
||||
[158., 458., 258., 358.]
|
||||
);
|
||||
let mut spatial = f.ids.to_vec();
|
||||
spatial.sort_by(|a, b| y(scene, before(&f, *a)).total_cmp(&y(scene, before(&f, *b))));
|
||||
assert_eq!(spatial, [a, c, d, b]);
|
||||
for id in f.ids {
|
||||
assert_eq!(picked(scene, &f, y(scene, before(&f, id))), before(&f, id));
|
||||
}
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_saved_slots_hide_one_destination_while_exact_chooser_can_select_it() {
|
||||
let f = fixture([200., 300., 300., 500.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.);
|
||||
assert!(
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, b))
|
||||
< scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, c))
|
||||
);
|
||||
// The two distances are identical for every finite Y; min_by keeps the first
|
||||
// tie. The sample exercises that production behavior, not an exhaustive proof.
|
||||
for quarter_pixel in -4000..=12000 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, c));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 258.), before(&f, b));
|
||||
exact_change(&f, &[d, a], before(&f, c), &[b, a, d, c]);
|
||||
exact_change(&f, &[d, a], picked(scene, &f, 258.), &[a, d, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_saved_slots_have_subpixel_hit_regions_without_becoming_unreachable_continuously() {
|
||||
let f = fixture([200., 300., 300.125, 300.25]);
|
||||
let [_, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.125);
|
||||
assert_eq!(y(scene, before(&f, d)), 258.25);
|
||||
assert_eq!(picked(scene, &f, 258.125), before(&f, c));
|
||||
// Fixed scale=1/pan=0: C's open Voronoi interval is only
|
||||
// (258.0625, 258.1875). Integer-pixel samples miss it. This does not assert
|
||||
// that all platforms quantize pointer input to integer coordinates.
|
||||
for screen_y in 0..=600 {
|
||||
assert_ne!(picked(scene, &f, screen_y as f32), before(&f, c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_saved_slot_needs_camera_change_or_nonspatial_destination_input() {
|
||||
let f = fixture([200., 300., 400., 2000.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, d)), 1958.);
|
||||
// Fixed scale=1/pan=0 and a 600px-high canvas. The C/D midpoint is
|
||||
// 1158, beyond the whole viewport, so D cannot win within that viewport.
|
||||
for quarter_pixel in 0..=2400 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, d));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 1958.), before(&f, d));
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project slot_probe "Slot probe" id "71000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
|
||||
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000004" { viewpoint OperationalActivity; steps [@a, @b, @c, @d]; }
|
||||
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000005" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @target; }
|
||||
oa_occurrence a "A" id "71000000-0000-4000-8000-000000000007" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence b "B" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence c "C" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence d "D" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Identity-based destination choice, independent of saved screen coordinates.
|
||||
use super::*;
|
||||
use crate::app::scenario_reorder::{Event, Intent};
|
||||
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||
use iced::widget::{column, opaque, scrollable};
|
||||
|
||||
const PAGE_SIZE: usize = 5;
|
||||
|
||||
pub(crate) fn query_id(token: Id) -> iced::widget::Id {
|
||||
iced::widget::Id::from(format!("scenario-reorder-query:{token}"))
|
||||
}
|
||||
|
||||
pub(crate) fn focus_query(token: Id) -> iced::Task<Message> {
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
struct Find {
|
||||
target: iced::widget::Id,
|
||||
present: bool,
|
||||
}
|
||||
impl Operation for Find {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&iced::widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
_: &mut dyn operation::Focusable,
|
||||
) {
|
||||
self.present |= id == Some(&self.target);
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if self.present {
|
||||
operation::Outcome::Chain(Box::new(operation::focusable::focus(
|
||||
self.target.clone(),
|
||||
)))
|
||||
} else {
|
||||
operation::Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic focus would blur every current widget if the old token vanished.
|
||||
// Discover the exact target without changing focus before chaining it.
|
||||
iced::advanced::widget::operate(Find {
|
||||
target: query_id(token),
|
||||
present: false,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_message(app: &App) -> Option<Message> {
|
||||
if !app.scenario_reorder_enabled() {
|
||||
return None;
|
||||
}
|
||||
let session = app.scenario_reorder.session.as_ref()?;
|
||||
let preview = session.current_preview()?;
|
||||
(preview.selection_visible
|
||||
&& !matches!(preview.outcome, reorder_session::Outcome::Refused(_))
|
||||
&& matches!(
|
||||
preview.presentation.as_ref()?.status(),
|
||||
PresentationStatus::Ready | PresentationStatus::Fallback { .. }
|
||||
))
|
||||
.then(|| message(session, Intent::Finish(preview.slot)))
|
||||
}
|
||||
|
||||
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
|
||||
Message::ScenarioReorder(Box::new(Event {
|
||||
context: session.context(),
|
||||
snapshot: session.snapshot().clone(),
|
||||
token: session.token(),
|
||||
intent,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
(app.scenario_reorder_enabled() && app.scenario_reorder.session.is_none()).then_some(())?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Message::ScenarioReorder(Box::new(Event {
|
||||
context: app.scenario_context()?,
|
||||
snapshot,
|
||||
token: Id::new_v4(),
|
||||
intent: Intent::Begin,
|
||||
})))
|
||||
}
|
||||
|
||||
fn label(p: &Project, id: Id) -> String {
|
||||
p.elements.get(&id).map_or_else(
|
||||
|| id.to_string(),
|
||||
|e| format!("{} (@{})", short(&e.name, 38), e.alias),
|
||||
)
|
||||
}
|
||||
|
||||
fn destination(p: &Project, before: &[Id], slot: Slot) -> String {
|
||||
slot.before.map_or_else(
|
||||
|| "End of sequence".into(),
|
||||
|id| {
|
||||
let position = before
|
||||
.iter()
|
||||
.position(|item| *item == id)
|
||||
.map_or(0, |i| i + 1);
|
||||
format!("Before {position}. {}", label(p, id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn order(p: &Project, ids: &[Id], snapshot: &crate::diagram_selection::Snapshot) -> String {
|
||||
// A bounded neighborhood keeps the moved block and its immediate neighbors
|
||||
// visible. Ellipses explicitly mark omitted, unchanged sequence members.
|
||||
let selected = |id: &Id| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(*id))
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
let mut omitted = false;
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
let near = selected(id)
|
||||
|| index.checked_sub(1).is_some_and(|i| selected(&ids[i]))
|
||||
|| ids.get(index + 1).is_some_and(selected);
|
||||
if !near {
|
||||
omitted = true;
|
||||
continue;
|
||||
}
|
||||
if omitted {
|
||||
parts.push("…".into());
|
||||
omitted = false;
|
||||
}
|
||||
parts.push(format!(
|
||||
"{}{}. {}",
|
||||
if selected(id) { "• " } else { "" },
|
||||
index + 1,
|
||||
label(p, *id)
|
||||
));
|
||||
}
|
||||
if omitted {
|
||||
parts.push("…".into());
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Message>> {
|
||||
let session = app.scenario_reorder.session.as_ref()?;
|
||||
let p = app.project();
|
||||
let state = &app.scenario_reorder;
|
||||
let query = state.query.to_lowercase();
|
||||
let slots = session
|
||||
.before()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Some)
|
||||
.chain(std::iter::once(None))
|
||||
.enumerate()
|
||||
.map(|(index, before)| {
|
||||
let slot = Slot {
|
||||
parent: session.parent(),
|
||||
before,
|
||||
};
|
||||
let title = before.map_or_else(
|
||||
|| "End of sequence".into(),
|
||||
|id| format!("Before {}. {}", index + 1, label(p, id)),
|
||||
);
|
||||
(slot, title)
|
||||
})
|
||||
.filter(|(slot, title)| {
|
||||
title.to_lowercase().contains(&query)
|
||||
|| slot
|
||||
.before
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
e.name.to_lowercase().contains(&query)
|
||||
|| e.alias.to_lowercase().contains(&query)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pages = slots.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = state.page.min(pages - 1);
|
||||
let mut choices = column![].spacing(4).width(Fill);
|
||||
for (slot, title) in slots.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
choices = choices.push(
|
||||
button(
|
||||
text(title.clone())
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.on_press(message(session, Intent::Choose(*slot))),
|
||||
);
|
||||
}
|
||||
if slots.is_empty() {
|
||||
choices = choices
|
||||
.push(text("No matching destination. Try its name, alias or position.").size(12));
|
||||
}
|
||||
let mut body = column![
|
||||
text(format!(
|
||||
"Reorder {} selected",
|
||||
session.snapshot().targets().len()
|
||||
))
|
||||
.size(17),
|
||||
text(format!("Sequence: {}", label(p, session.parent())))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
text("Changes execution order; saved positions stay as authored.").size(12),
|
||||
text_input("Find destination by name, alias or position", &state.query)
|
||||
.id(query_id(session.token()))
|
||||
.on_input(move |query| message(session, Intent::Query(query)))
|
||||
.on_submit_maybe(
|
||||
(slots.len() == 1).then(|| message(session, Intent::Choose(slots[0].0)))
|
||||
)
|
||||
.size(13),
|
||||
choices,
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| message(session, Intent::Page(page - 1)))),
|
||||
text(format!("{} / {pages}", page + 1)).size(12),
|
||||
button("Next").on_press_maybe(
|
||||
(page + 1 < pages).then(|| message(session, Intent::Page(page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Fill);
|
||||
let mut apply = None;
|
||||
let mut footer =
|
||||
column![text("Enter previews a unique match. Ctrl+Enter applies; Esc cancels.").size(12)]
|
||||
.spacing(6);
|
||||
if let Some(preview) = session.current_preview() {
|
||||
footer = footer.push(
|
||||
text(format!(
|
||||
"Destination: {}",
|
||||
destination(p, session.before(), preview.slot)
|
||||
))
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
let comparison = row![
|
||||
container(
|
||||
column![
|
||||
text("Before").size(13),
|
||||
text(order(p, &preview.before, session.snapshot()))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
]
|
||||
.width(Fill)
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-reorder-before"),
|
||||
container(
|
||||
column![
|
||||
text("After • selected").size(13),
|
||||
text(order(p, &preview.after, session.snapshot()))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
]
|
||||
.width(Fill)
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-reorder-after")
|
||||
]
|
||||
.spacing(10)
|
||||
.width(Fill);
|
||||
body = body.push(comparison);
|
||||
let feedback = match &preview.outcome {
|
||||
reorder_session::Outcome::Refused(reason) => reason.clone(),
|
||||
_ if !preview.selection_visible => {
|
||||
"This destination would hide a selected occurrence; choose another destination."
|
||||
.into()
|
||||
}
|
||||
_ => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||
Some(PresentationStatus::Unavailable { .. }) | None => {
|
||||
"This order cannot be displayed; choose another destination.".into()
|
||||
}
|
||||
Some(status) => {
|
||||
apply = Some(message(session, Intent::Finish(preview.slot)));
|
||||
if matches!(status, PresentationStatus::Fallback { .. }) {
|
||||
"Apply will display an automatic arrangement. Your requested saved view and authored positions are retained; they are not repaired.".into()
|
||||
} else if matches!(preview.outcome, reorder_session::Outcome::Noop) {
|
||||
"This destination keeps the current order; Apply will close without an edit.".into()
|
||||
} else {
|
||||
"The selected occurrences move together in their existing order. One Undo restores the sequence.".into()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
footer = footer.push(text(feedback).size(12));
|
||||
} else {
|
||||
body = body.push(text("Choose a destination to preview the new sequence.").size(12));
|
||||
}
|
||||
let height = viewport.map_or(500., |size| (size.height - 32.).max(100.));
|
||||
let card = column![
|
||||
scrollable(body)
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.spacing(8)
|
||||
.id("scenario-reorder-body"),
|
||||
footer,
|
||||
row![
|
||||
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
|
||||
button("Apply order (Ctrl+Enter)").on_press_maybe(apply)
|
||||
]
|
||||
.spacing(8)
|
||||
]
|
||||
.spacing(10);
|
||||
Some(
|
||||
container(opaque(
|
||||
container(card)
|
||||
.padding(12)
|
||||
.width(480)
|
||||
.height(height.min(630.))
|
||||
.style(style::card),
|
||||
))
|
||||
.align_right(Fill)
|
||||
.padding(8)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Captured exact Scenario selection input; membership grants no movement permission.
|
||||
use super::{Context, presentation};
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
diagram_selection,
|
||||
diagram_usage::{Surface, Target},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Event {
|
||||
pub context: Context,
|
||||
pub snapshot: diagram_selection::Snapshot,
|
||||
pub intent: Intent,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Intent {
|
||||
Choose(Target, Choice),
|
||||
Clear,
|
||||
Collapse(Target),
|
||||
Refuse(String),
|
||||
}
|
||||
pub(crate) fn context(context: Context) -> diagram_selection::Context {
|
||||
diagram_selection::Context {
|
||||
scope: diagram_selection::Scope {
|
||||
document: context.document,
|
||||
surface: match context.representation {
|
||||
super::Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
super::Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
},
|
||||
revision: context.revision,
|
||||
epoch: context.epoch,
|
||||
}
|
||||
}
|
||||
pub(crate) fn visible(project: &Project, context: Context, selected: Id) -> Vec<Target> {
|
||||
let shown = presentation::present(project, context.representation, selected);
|
||||
shown
|
||||
.scene()
|
||||
.map_or_else(Vec::new, |scene| scene.selection_targets())
|
||||
}
|
||||
pub(crate) fn message(
|
||||
context: Context,
|
||||
snapshot: &diagram_selection::Snapshot,
|
||||
intent: Intent,
|
||||
) -> crate::app::Message {
|
||||
crate::app::Message::ScenarioCanvas(super::CanvasEvent::Selection(Box::new(Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,268 @@
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
Scene, SequenceInput,
|
||||
scenario::{self, CanvasEvent, CanvasTool},
|
||||
};
|
||||
use crate::diagram_selection::Selection;
|
||||
use iced::{
|
||||
Point, Rectangle, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
|
||||
fn fixture(group: bool) -> (scenario::direct_tests::Fixture, Scene, Point) {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let context = scenario::direct_tests::context(f.owner);
|
||||
let mut scene = Scene::sequence(&f.project, f.owner, f.reply);
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let mut selection = Selection::new(super::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
if group {
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
}
|
||||
scene.selection = Some((
|
||||
context,
|
||||
selection.snapshot(super::context(context)).unwrap(),
|
||||
));
|
||||
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
||||
let point = line.label_at + Vector::new(8., 8.);
|
||||
assert_eq!(scene.hit(point), Some(f.call));
|
||||
(f, scene, point)
|
||||
}
|
||||
fn send(
|
||||
scene: &Scene,
|
||||
state: &mut SequenceInput,
|
||||
event: canvas::Event,
|
||||
point: Option<Point>,
|
||||
) -> Option<CanvasEvent> {
|
||||
scene
|
||||
.update(
|
||||
state,
|
||||
&event,
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
.and_then(|message| match message {
|
||||
crate::app::Message::ScenarioCanvas(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn mouse_event(event: mouse::Event) -> canvas::Event {
|
||||
canvas::Event::Mouse(event)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_group_body_press_emits_one_spacing_lifetime_without_collapsing_selection() {
|
||||
use crate::behavior::scenario::spacing::{self, Event};
|
||||
use crate::behavior::scenario_layout::{Representation, resolve};
|
||||
let (doc, owner, diagram, _, first) = spacing::controller::tests::fixture();
|
||||
let second = doc
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == "second")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut context = scenario::direct_tests::context(owner);
|
||||
context.representation = Representation::Saved { diagram };
|
||||
let mut scene = resolve(doc.project(), context.representation, second)
|
||||
.unwrap()
|
||||
.scene;
|
||||
let mut selection = Selection::new(super::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(first), |_| true);
|
||||
selection.add(Target::ScenarioObject(second), |_| true);
|
||||
let snapshot = selection.snapshot(super::context(context)).unwrap();
|
||||
let stamp = spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: 9,
|
||||
selection_generation: snapshot.generation(),
|
||||
};
|
||||
scene.selection = Some((context, snapshot.clone()));
|
||||
scene.selected = second;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
scene.coordinate_input = true;
|
||||
scene.spacing_stamp = Some(stamp);
|
||||
let line = scene.lines.iter().find(|line| line.id == first).unwrap();
|
||||
let point = Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y);
|
||||
assert_eq!(scene.hit(point), Some(first));
|
||||
let mut input = SequenceInput::default();
|
||||
let Some(CanvasEvent::Spacing(Event::Begin {
|
||||
stamp: got,
|
||||
token,
|
||||
target,
|
||||
..
|
||||
})) = send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
)
|
||||
else {
|
||||
panic!("group spacing begins on first press")
|
||||
};
|
||||
assert_eq!(got, stamp);
|
||||
assert_eq!(target, spacing::Target::Message(first));
|
||||
assert!(input.group_press.is_none());
|
||||
let moved = point + Vector::new(0., 24.);
|
||||
assert!(
|
||||
matches!(send(&scene,&mut input,mouse_event(mouse::Event::CursorMoved{position:moved}),Some(moved)),Some(CanvasEvent::Spacing(Event::Move{stamp:s,token:t,..})) if s==stamp && t==token)
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&scene,&mut input,mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),Some(moved)),Some(CanvasEvent::Spacing(Event::Finish{stamp:s,token:t,..})) if s==stamp && t==token)
|
||||
);
|
||||
assert_eq!(scene.selection, Some((context, snapshot)));
|
||||
assert_eq!(scene.selected, second);
|
||||
assert!(input.press.is_none() && input.group_press.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn group_press_retains_snapshot_and_collapses_only_stationary_release_once() {
|
||||
let (f, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
let captured = state.group_press.as_ref().unwrap().snapshot.clone();
|
||||
assert_eq!(captured.primary(), Some(&Target::ScenarioObject(f.reply)));
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("captured collapse")
|
||||
};
|
||||
assert_eq!(event.snapshot, captured);
|
||||
assert!(matches!(event.intent, Intent::Collapse(Target::ScenarioObject(id)) if id == f.call));
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn crossed_threshold_return_to_origin_refuses_whole_set_and_outside_release_cancels() {
|
||||
for outside in [false, true] {
|
||||
let (_, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let moved = point + Vector::new(12., 0.);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: moved }),
|
||||
Some(moved),
|
||||
);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: point }),
|
||||
Some(point),
|
||||
);
|
||||
let event = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
(!outside).then_some(point),
|
||||
);
|
||||
if outside {
|
||||
assert!(event.is_none());
|
||||
} else {
|
||||
let Some(CanvasEvent::Selection(event)) = event else {
|
||||
panic!("whole-set refusal")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Refuse(_)));
|
||||
assert_eq!(event.snapshot.targets().len(), 2);
|
||||
}
|
||||
assert!(state.group_press.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn passive_shift_survives_context_refresh_and_window_loss_clears_it() {
|
||||
let (f, mut scene, point) = fixture(false);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("toggle")
|
||||
};
|
||||
assert!(
|
||||
matches!(event.intent, Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if id == f.call)
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
scene.interaction.as_mut().unwrap().0.epoch += 1;
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
None,
|
||||
);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn stale_selection_and_fallback_release_do_not_move_primary() {
|
||||
let (_, mut scene, point) = fixture(true);
|
||||
scene.coordinate_input = false;
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("fallback stationary selection remains usable")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Collapse(_)));
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
scene.selection.as_mut().unwrap().0.visit = Id::new_v4();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.group_press.is_none());
|
||||
}
|
||||
@@ -5,13 +5,17 @@
|
||||
use crate::behavior::scenario_layout::{
|
||||
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
|
||||
};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
use oa_core::{
|
||||
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
|
||||
ScenarioMessagePosition, SharedBody, execute_batch,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub(crate) mod controller;
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) use controller::{Event, Stamp};
|
||||
|
||||
@@ -30,6 +34,7 @@ impl Target {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refusal {
|
||||
InvalidSelection(&'static str),
|
||||
InvalidSavedView(Id),
|
||||
TargetNotRendered(Target),
|
||||
InvalidDelta,
|
||||
@@ -45,6 +50,7 @@ pub(super) struct Drag {
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
origin: f32,
|
||||
other_origins: Vec<(Target, f32)>,
|
||||
original_geometry: ResolvedScenarioPlan,
|
||||
}
|
||||
|
||||
@@ -58,17 +64,135 @@ pub(super) struct Preview {
|
||||
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
|
||||
/// fallback is not an editable original, even if the target appears in fallback.
|
||||
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
|
||||
let representation = Representation::Saved { diagram };
|
||||
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
|
||||
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
|
||||
capture_targets(project, diagram, target, &[target])
|
||||
}
|
||||
|
||||
/// The exact selected set is one operation, never an eligible subset. Selection
|
||||
/// order and the pressed member do not determine persistence order.
|
||||
pub(super) fn capture_group(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
pressed: Target,
|
||||
targets: &[SelectionTarget],
|
||||
) -> Result<Drag, Refusal> {
|
||||
if targets.is_empty() {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Select at least one occurrence to move.",
|
||||
));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut members = Vec::with_capacity(targets.len());
|
||||
for selected in targets {
|
||||
let SelectionTarget::ScenarioObject(id) = selected else {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Move only local Scenario lifelines or messages.",
|
||||
));
|
||||
};
|
||||
if !seen.insert(*id) {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"The selection contains a duplicate occurrence.",
|
||||
));
|
||||
}
|
||||
let target = match pressed {
|
||||
Target::Lane(_) => Target::Lane(*id),
|
||||
Target::Message(_) => Target::Message(*id),
|
||||
};
|
||||
let Some(element) = project.elements.get(id) else {
|
||||
return Err(Refusal::TargetNotRendered(target));
|
||||
};
|
||||
let same_role = match target {
|
||||
Target::Lane(_) => element.kind() == Kind::Lifeline,
|
||||
Target::Message(_) => matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
),
|
||||
};
|
||||
if !same_role {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Move a group of lifelines or a group of messages; mixed and other occurrence types are not supported.",
|
||||
));
|
||||
}
|
||||
members.push(target);
|
||||
}
|
||||
if !members.contains(&pressed) {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"The pressed occurrence is not in the captured selection.",
|
||||
));
|
||||
}
|
||||
members.sort_by_key(|target| target.id());
|
||||
capture_targets(project, diagram, pressed, &members)
|
||||
}
|
||||
|
||||
// Require the actual local sequence/operand path, not just an ancestor pointer.
|
||||
// Cross-operand groups are allowed; no member changes its existing parent.
|
||||
fn local_message(project: &Project, owner: Id, id: Id) -> bool {
|
||||
let mut current = id;
|
||||
let mut visited = BTreeSet::new();
|
||||
while current != owner && visited.insert(current) {
|
||||
let Some(element) = project.elements.get(¤t) else {
|
||||
return false;
|
||||
};
|
||||
let Some(parent) = element.parent.and_then(|id| project.elements.get(&id)) else {
|
||||
return false;
|
||||
};
|
||||
if element.layer() != project.elements[&owner].layer() {
|
||||
return false;
|
||||
}
|
||||
let listed = match parent.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
) && branches.iter().filter(|&&id| id == current).count() == 1
|
||||
}
|
||||
_ => parent
|
||||
.steps()
|
||||
.is_some_and(|steps| steps.iter().filter(|&&id| id == current).count() == 1),
|
||||
};
|
||||
if !listed {
|
||||
return false;
|
||||
}
|
||||
current = parent.id;
|
||||
}
|
||||
current == owner
|
||||
}
|
||||
|
||||
fn captured_origin(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
geometry: &ResolvedScenarioPlan,
|
||||
target: Target,
|
||||
) -> Result<f32, Refusal> {
|
||||
let element = project.elements.get(&target.id());
|
||||
let origin = match target {
|
||||
Target::Lane(id)
|
||||
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
|
||||
if element.is_some_and(|e| {
|
||||
e.kind() == Kind::Lifeline
|
||||
&& e.parent == Some(owner)
|
||||
&& e.layer() == project.elements[&owner].layer()
|
||||
}) =>
|
||||
{
|
||||
// Unassigned lifelines still have drawn headers. `scene.lanes`
|
||||
// excludes them only from semantic message-endpoint hit testing.
|
||||
geometry.scene.render_lanes.get(&id).copied()
|
||||
// Unassigned headers are still rendered local targets.
|
||||
let headers = geometry
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.id == id && item.role == crate::behavior::SceneHitRole::LifelineHeader
|
||||
})
|
||||
.count();
|
||||
(headers == 1)
|
||||
.then(|| geometry.scene.render_lanes.get(&id).copied())
|
||||
.flatten()
|
||||
}
|
||||
Target::Message(id)
|
||||
if element.is_some_and(|e| {
|
||||
@@ -79,17 +203,16 @@ pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<
|
||||
..
|
||||
})
|
||||
)
|
||||
}) =>
|
||||
}) && local_message(project, owner, id) =>
|
||||
{
|
||||
// A lifecycle row can denote receive y; the public position means
|
||||
// send y. Read the actual outgoing endpoint, including self calls.
|
||||
geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
// A lifecycle receive row is not the stored send coordinate. Reject
|
||||
// ambiguous repeated drawing IDs rather than choosing the first.
|
||||
let mut lines = geometry.scene.lines.iter().filter(|line| line.id == id);
|
||||
let first = lines
|
||||
.next()
|
||||
.and_then(|line| line.points.first())
|
||||
.map(|point| point.y)
|
||||
.map(|point| point.y);
|
||||
if lines.next().is_some() { None } else { first }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -97,11 +220,38 @@ pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<
|
||||
if !origin.is_finite() {
|
||||
return Err(Refusal::TargetNotRendered(target));
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn capture_targets(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
targets: &[Target],
|
||||
) -> Result<Drag, Refusal> {
|
||||
let representation = Representation::Saved { diagram };
|
||||
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
|
||||
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
|
||||
let origins = targets
|
||||
.iter()
|
||||
.map(|member| {
|
||||
captured_origin(project, owner, &geometry, *member).map(|origin| (*member, origin))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let origin = origins
|
||||
.iter()
|
||||
.find(|(member, _)| *member == target)
|
||||
.expect("admitted pressed member")
|
||||
.1;
|
||||
Ok(Drag {
|
||||
original: project.clone(),
|
||||
diagram,
|
||||
target,
|
||||
origin,
|
||||
other_origins: origins
|
||||
.into_iter()
|
||||
.filter(|(member, _)| *member != target)
|
||||
.collect(),
|
||||
original_geometry: geometry,
|
||||
})
|
||||
}
|
||||
@@ -117,28 +267,50 @@ impl Drag {
|
||||
if original_delta == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
|
||||
if position == self.origin {
|
||||
let mut commands = Vec::new();
|
||||
for (target, origin) in
|
||||
std::iter::once((self.target, self.origin)).chain(self.other_origins.iter().copied())
|
||||
{
|
||||
let position = (f64::from(origin) + f64::from(original_delta)) as f32;
|
||||
if !position.is_finite() || position.abs() > oa_core::MAX_COORDINATE {
|
||||
return Err(Refusal::Command(
|
||||
"Scenario positions must be finite and within the shared coordinate bounds."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if position == origin {
|
||||
continue;
|
||||
}
|
||||
let command = match target {
|
||||
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline,
|
||||
x: position,
|
||||
},
|
||||
},
|
||||
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y: position,
|
||||
},
|
||||
},
|
||||
};
|
||||
commands.push((target.id(), command));
|
||||
}
|
||||
if commands.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let command = match self.target {
|
||||
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline,
|
||||
x: position,
|
||||
},
|
||||
},
|
||||
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y: position,
|
||||
},
|
||||
},
|
||||
commands.sort_by_key(|(id, _)| *id);
|
||||
let mut commands: Vec<_> = commands.into_iter().map(|(_, command)| command).collect();
|
||||
let command = if self.other_origins.is_empty() {
|
||||
commands.pop().expect("changed singleton")
|
||||
} else {
|
||||
Command::Batch(commands)
|
||||
};
|
||||
let project = execute_batch(&self.original, std::slice::from_ref(&command))
|
||||
.map_err(|error| Refusal::Command(error.to_string()))?;
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::behavior::{
|
||||
scenario::{CanvasTool, Context, State},
|
||||
scenario_layout::Diagnostic,
|
||||
};
|
||||
use crate::{diagram_selection::Snapshot, diagram_usage::Target as SelectionTarget};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -16,6 +17,7 @@ mod timing_tests;
|
||||
pub struct Stamp {
|
||||
pub(crate) context: Context,
|
||||
pub(crate) camera_epoch: u64,
|
||||
pub(crate) selection_generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -72,11 +74,14 @@ pub(crate) struct Session {
|
||||
drag: Drag,
|
||||
preview: Option<Preview>,
|
||||
refusal: Option<Refusal>,
|
||||
group_refusal: Option<Refusal>,
|
||||
ever_crossed: bool,
|
||||
snapshot: Option<Snapshot>,
|
||||
}
|
||||
|
||||
pub(in crate::behavior) struct Paint<'a> {
|
||||
pub(in crate::behavior) scene: &'a Scene,
|
||||
pub(in crate::behavior) selected_count: usize,
|
||||
#[cfg(test)]
|
||||
pub(in crate::behavior) bounds: Rectangle,
|
||||
pub(in crate::behavior) refusal: Option<&'a Refusal>,
|
||||
@@ -98,7 +103,21 @@ pub(crate) enum Finish {
|
||||
|
||||
impl Session {
|
||||
fn configure(&self, scene: &mut Scene) {
|
||||
scene.selected = self.drag.target.id();
|
||||
scene.selected = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.and_then(|s| match s.primary() {
|
||||
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(self.drag.target.id());
|
||||
scene.selection = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.cloned()
|
||||
.map(|s| (self.stamp.context, s));
|
||||
scene.scale = self.scale;
|
||||
scene.camera_pan = self.pan;
|
||||
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
|
||||
@@ -117,8 +136,18 @@ impl Session {
|
||||
Target::Lane(_) => screen.x - self.press_screen.x,
|
||||
Target::Message(_) => screen.y - self.press_screen.y,
|
||||
};
|
||||
self.ever_crossed |= distance.abs() >= 3.;
|
||||
if !self.ever_crossed {
|
||||
self.ever_crossed |= if self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
screen.distance(self.press_screen) >= 3.
|
||||
} else {
|
||||
distance.abs() >= 3.
|
||||
};
|
||||
if !self.ever_crossed || self.group_refusal.is_some() {
|
||||
// A rejected whole set may retain a pressed singleton only as its
|
||||
// original display witness. It must never prepare a partial move.
|
||||
return;
|
||||
}
|
||||
match self.drag.prepare(distance / self.scale) {
|
||||
@@ -139,6 +168,9 @@ impl State {
|
||||
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
|
||||
self.spacing.as_ref().map(|s| s.stamp)
|
||||
}
|
||||
pub(crate) fn spacing_snapshot(&self) -> Option<&Snapshot> {
|
||||
self.spacing.as_ref()?.snapshot.as_ref()
|
||||
}
|
||||
pub(crate) fn cancel_spacing(&mut self) -> bool {
|
||||
self.spacing.take().is_some()
|
||||
}
|
||||
@@ -149,28 +181,55 @@ impl State {
|
||||
camera_epoch: u64,
|
||||
) -> Option<Paint<'_>> {
|
||||
let session = self.spacing.as_ref()?;
|
||||
if session.stamp
|
||||
!= (Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
})
|
||||
{
|
||||
if session.stamp.context != context || session.stamp.camera_epoch != camera_epoch {
|
||||
return None;
|
||||
}
|
||||
let original = &session.drag.original_geometry;
|
||||
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
|
||||
Some(Paint {
|
||||
scene: &shown.scene,
|
||||
selected_count: session
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.map_or(1, |s| s.targets().len().max(1)),
|
||||
#[cfg(test)]
|
||||
bounds: shown.bounds,
|
||||
refusal: session.refusal.as_ref(),
|
||||
refusal: session.refusal.as_ref().or_else(|| {
|
||||
session
|
||||
.ever_crossed
|
||||
.then_some(session.group_refusal.as_ref())
|
||||
.flatten()
|
||||
}),
|
||||
original_diagnostics: &original.plan.diagnostics,
|
||||
original_latent: &original.latent_messages,
|
||||
diagnostics: &shown.plan.diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
|
||||
self.begin_spacing_capture(project, event, None)
|
||||
}
|
||||
pub(crate) fn begin_spacing_with_snapshot(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
event: Event,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), Refusal> {
|
||||
if snapshot.generation() != event.stamp().selection_generation
|
||||
|| snapshot.context()
|
||||
!= crate::behavior::scenario::selection::context(event.stamp().context)
|
||||
{
|
||||
return Err(Refusal::InvalidGesture);
|
||||
}
|
||||
self.begin_spacing_capture(project, event, Some(snapshot))
|
||||
}
|
||||
fn begin_spacing_capture(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
event: Event,
|
||||
snapshot: Option<Snapshot>,
|
||||
) -> Result<(), Refusal> {
|
||||
let Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
@@ -216,7 +275,34 @@ impl State {
|
||||
let Representation::Saved { diagram } = context.representation else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
let mut drag = capture(project, diagram, target)?;
|
||||
let mut group_refusal = None;
|
||||
let mut drag = if let Some(selected) = snapshot.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if !selected
|
||||
.targets()
|
||||
.contains(&SelectionTarget::ScenarioObject(target.id()))
|
||||
{
|
||||
return Err(Refusal::TargetNotHit(target));
|
||||
}
|
||||
match capture_group(project, diagram, target, selected.targets()) {
|
||||
Ok(drag) => drag,
|
||||
Err(error)
|
||||
if matches!(
|
||||
&error,
|
||||
Refusal::InvalidSelection(_) | Refusal::TargetNotRendered(_)
|
||||
) =>
|
||||
{
|
||||
// Selection clicks do not require movement eligibility.
|
||||
// This capture supplies original paint and the exact hit
|
||||
// only; group_refusal forbids calling its prepare method.
|
||||
let original = capture(project, diagram, target)?;
|
||||
group_refusal = Some(error);
|
||||
original
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else {
|
||||
capture(project, diagram, target)?
|
||||
};
|
||||
let camera = self.presentation.camera(drag.original_geometry.bounds);
|
||||
if self.presentation.viewport != Some(canvas_bounds.size())
|
||||
|| camera.scale != scale
|
||||
@@ -246,7 +332,19 @@ impl State {
|
||||
}
|
||||
// Configure baseline without changing the persistent camera or selection.
|
||||
let scene = &mut drag.original_geometry.scene;
|
||||
scene.selected = target.id();
|
||||
scene.selected = snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.and_then(|s| match s.primary() {
|
||||
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(target.id());
|
||||
scene.selection = snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.cloned()
|
||||
.map(|s| (context, s));
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
@@ -262,7 +360,9 @@ impl State {
|
||||
drag,
|
||||
preview: None,
|
||||
refusal: None,
|
||||
group_refusal,
|
||||
ever_crossed: false,
|
||||
snapshot,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -282,6 +382,9 @@ impl State {
|
||||
if !session.ever_crossed {
|
||||
return Ok(Finish::Click(session.drag.target.id()));
|
||||
}
|
||||
if let Some(error) = session.group_refusal {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(match session.preview {
|
||||
None => Finish::Noop,
|
||||
Some(preview) => Finish::Commit {
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(crate) fn packet(
|
||||
stamp: Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
selection_generation: 0,
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
target,
|
||||
@@ -135,6 +136,55 @@ fn press(event: Event) -> Point {
|
||||
press_screen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_preview_keeps_captured_members_and_nonpressed_primary() {
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target as Selected;
|
||||
let (doc, mut state, mut event) = setup(true);
|
||||
let context = event.stamp().context;
|
||||
let first = match event {
|
||||
Event::Begin { target, .. } => target.id(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let second = doc
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "second")
|
||||
.unwrap()
|
||||
.0;
|
||||
let selection_context = crate::behavior::scenario::selection::context(context);
|
||||
let mut selection = Selection::new(selection_context.scope);
|
||||
selection.add(Selected::ScenarioObject(first), |_| true);
|
||||
selection.add(Selected::ScenarioObject(second), |_| true);
|
||||
let snapshot = selection.snapshot(selection_context).unwrap();
|
||||
if let Event::Begin { stamp, .. } = &mut event {
|
||||
stamp.selection_generation = snapshot.generation();
|
||||
}
|
||||
let start = press(event);
|
||||
state
|
||||
.begin_spacing_with_snapshot(doc.project(), event, snapshot.clone())
|
||||
.unwrap();
|
||||
for delta in [Vector::new(0., 0.), Vector::new(0., 24.)] {
|
||||
state.move_spacing(start + delta);
|
||||
let paint = state
|
||||
.spacing_paint(context, event.stamp().camera_epoch)
|
||||
.unwrap();
|
||||
assert_eq!(paint.scene.selected, second);
|
||||
assert_eq!(
|
||||
paint.scene.selection.as_ref().unwrap(),
|
||||
&(context, snapshot.clone())
|
||||
);
|
||||
assert_eq!(paint.selected_count, 2);
|
||||
}
|
||||
state.move_spacing(start + Vector::new(50., 0.));
|
||||
assert!(matches!(
|
||||
state.finish_spacing(start + Vector::new(50., 0.)).unwrap(),
|
||||
Finish::Noop
|
||||
));
|
||||
assert!(!doc.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
|
||||
for message in [false, true] {
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, ProjectionKind, ScenarioLayout, language};
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let owner = id(&project, "conversation");
|
||||
let right = id(&project, "right");
|
||||
let mut views = Vec::new();
|
||||
for (alias, layout) in [
|
||||
("active_view", layout),
|
||||
(
|
||||
"untouched_view",
|
||||
Some(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 700.,
|
||||
}],
|
||||
messages: vec![],
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: "Preserve authored view metadata".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
views.push(diagram);
|
||||
}
|
||||
(
|
||||
Document::from_project(project, Default::default()).unwrap(),
|
||||
views[0],
|
||||
views[1],
|
||||
)
|
||||
}
|
||||
|
||||
fn targets(ids: &[Id]) -> Vec<SelectionTarget> {
|
||||
ids.iter()
|
||||
.copied()
|
||||
.map(SelectionTarget::ScenarioObject)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn message(occurrence: Id, send_y: f32) -> ScenarioMessagePosition {
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
}
|
||||
}
|
||||
|
||||
// Literal expected authored payload, not another execution of planner commands.
|
||||
fn expected(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
lanes: Vec<ScenarioLanePosition>,
|
||||
messages: Vec<ScenarioMessagePosition>,
|
||||
) -> Project {
|
||||
let mut result = project.clone();
|
||||
result.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout { lanes, messages }));
|
||||
result
|
||||
}
|
||||
|
||||
fn exact_transaction(mut document: Document, preview: Preview, expected: Project) {
|
||||
let original = document.snapshot();
|
||||
let revision = document.revision();
|
||||
assert_eq!(preview.project, expected);
|
||||
assert!(matches!(&preview.command, Command::Batch(_)));
|
||||
document.execute(preview.command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
let changed = document.bundle().clone();
|
||||
assert_ne!(changed, original.bundle);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(document.project())).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &original.decoded.project);
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
assert!(!document.can_redo());
|
||||
let reopened = Document::from_bundle(changed, Default::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert!(!reopened.dirty() && !reopened.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_messages_move_from_each_original_with_press_and_selection_order_independent() {
|
||||
let (document, diagram, other) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let wanted = expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(first, 188.), message(second, 280.)],
|
||||
);
|
||||
for members in [[first, second], [second, first]] {
|
||||
for pressed in [first, second] {
|
||||
let drag = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(pressed),
|
||||
&targets(&members),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = drag.prepare(40.).unwrap().unwrap();
|
||||
assert_eq!(preview.project, wanted);
|
||||
assert_eq!(preview.project.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(drag.original, *before);
|
||||
}
|
||||
}
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(40.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
exact_transaction(document, preview, wanted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_lanes_vacate_each_others_space_in_one_candidate_and_one_history_entry() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let left = id(before, "left");
|
||||
let right = id(before, "right");
|
||||
// Moving only Left into Right's old x cannot render a distinct-endpoint arrow.
|
||||
assert!(matches!(
|
||||
capture(before, diagram, Target::Lane(left))
|
||||
.unwrap()
|
||||
.prepare(240.),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
let wanted = expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: left,
|
||||
x: 400.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 640.,
|
||||
},
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
for pressed in [left, right] {
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(pressed),
|
||||
&targets(&[right, left]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(240.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview.project, wanted);
|
||||
assert_eq!(preview.geometry.scene.render_lanes[&left], 400.);
|
||||
assert_eq!(preview.geometry.scene.render_lanes[&right], 640.);
|
||||
}
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(right),
|
||||
&targets(&[left, right]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(240.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
exact_transaction(document, preview, wanted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_and_reply_are_validated_together_but_partial_candidate_is_refused() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let call = id(before, "call");
|
||||
let reply = id(before, "reply");
|
||||
let first = id(before, "first");
|
||||
assert!(matches!(
|
||||
capture(before, diagram, Target::Message(call))
|
||||
.unwrap()
|
||||
.prepare(118.),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
let drag = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(reply),
|
||||
&targets(&[reply, call]),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = drag.prepare(118.).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(call, 450.), message(reply, 542.)]
|
||||
)
|
||||
);
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == call)
|
||||
.unwrap();
|
||||
assert_eq!(line.points.first().unwrap().y, 450.);
|
||||
assert_eq!(line.points.last().unwrap().y, 474.);
|
||||
let partial = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, call]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(partial.prepare(118.), Err(Refusal::Geometry(_))));
|
||||
assert_eq!(partial.original, *before);
|
||||
assert!(!document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_set_admission_rejects_empty_duplicates_mixed_unsupported_missing_and_unpressed() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let left = id(before, "left");
|
||||
let source = id(before, "source");
|
||||
let unfinished = id(before, "unfinished");
|
||||
for selection in [
|
||||
vec![],
|
||||
targets(&[first, first]),
|
||||
targets(&[first, left]),
|
||||
targets(&[first, source]),
|
||||
targets(&[first, Id::new_v4()]),
|
||||
targets(&[second]),
|
||||
targets(&[first, unfinished]),
|
||||
vec![
|
||||
SelectionTarget::ScenarioObject(first),
|
||||
SelectionTarget::Whole,
|
||||
],
|
||||
vec![
|
||||
SelectionTarget::ScenarioObject(first),
|
||||
SelectionTarget::Placement(second),
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
capture_group(before, diagram, Target::Message(first), &selection).is_err(),
|
||||
"{selection:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
before,
|
||||
Id::nil(),
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!document.dirty() && !document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_unlisted_and_ambiguous_local_messages_cannot_enter_a_group() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let owner = id(before, "conversation");
|
||||
for case in 0..3 {
|
||||
let mut project = before.clone();
|
||||
match case {
|
||||
0 => {
|
||||
let foreign = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
project.elements.get_mut(&second).unwrap().parent = Some(foreign);
|
||||
}
|
||||
1 => project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != second),
|
||||
_ => project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(second),
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_baseline_is_refused_before_any_zero_motion_can_be_planned() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let call = id(document.project(), "call");
|
||||
let reply = id(document.project(), "reply");
|
||||
let invalid = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(call, 450.)],
|
||||
);
|
||||
let shown = crate::behavior::scenario_layout::presentation::present(
|
||||
&invalid,
|
||||
Representation::Saved { diagram },
|
||||
call,
|
||||
);
|
||||
assert!(shown.capabilities().geometry_navigation);
|
||||
assert!(!shown.capabilities().coordinate_input);
|
||||
assert!(matches!(
|
||||
capture_group(
|
||||
&invalid,
|
||||
diagram,
|
||||
Target::Message(reply),
|
||||
&targets(&[reply, call])
|
||||
),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_noops_preserve_sparse_overrides_complete_source_and_existing_redo() {
|
||||
for sparse in [true, false] {
|
||||
let (mut document, diagram, _) = fixture((!sparse).then(ScenarioLayout::default));
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let owner = id(document.project(), "conversation");
|
||||
document
|
||||
.execute(Command::Rename(owner, "Redo sentinel".into()))
|
||||
.unwrap();
|
||||
let redo = document.snapshot();
|
||||
assert!(document.undo());
|
||||
let original = document.snapshot();
|
||||
let revision = document.revision();
|
||||
let drag = capture_group(
|
||||
document.project(),
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(drag.prepare(30.).unwrap().is_some());
|
||||
for delta in [0., -0., f32::from_bits(1)] {
|
||||
assert!(drag.prepare(delta).unwrap().is_none());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert_eq!(drag.original, original.decoded.project);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &redo.decoded.project);
|
||||
assert_eq!(document.bundle(), &redo.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subprecision_unchanged_member_does_not_materialize_an_absent_override() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(first, 0.)],
|
||||
);
|
||||
let delta = 0.000001_f32;
|
||||
assert_eq!(240_f32 + delta, 240.);
|
||||
let preview = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(delta)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(&project, diagram, vec![], vec![message(first, delta)])
|
||||
);
|
||||
assert!(matches!(preview.command, Command::Batch(ref commands) if commands.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_delta_or_one_out_of_bounds_member_refuses_the_whole_group() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let left = id(document.project(), "left");
|
||||
let right = id(document.project(), "right");
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 999_990.,
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let drag = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Lane(left),
|
||||
&targets(&[left, right]),
|
||||
)
|
||||
.unwrap();
|
||||
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
|
||||
}
|
||||
for delta in [20., f32::MAX, -f32::MAX] {
|
||||
assert!(matches!(drag.prepare(delta), Err(Refusal::Command(_))));
|
||||
}
|
||||
assert_eq!(drag.original, project);
|
||||
assert!(drag.prepare(0.).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_capture_group_retains_existing_singleton_command_and_result() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
for target in [
|
||||
Target::Lane(id(document.project(), "right")),
|
||||
Target::Message(id(document.project(), "second")),
|
||||
] {
|
||||
let single = capture(document.project(), diagram, target)
|
||||
.unwrap()
|
||||
.prepare(25.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let group = capture_group(
|
||||
document.project(),
|
||||
diagram,
|
||||
target,
|
||||
&targets(&[target.id()]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(25.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(group.project, single.project);
|
||||
assert!(!matches!(group.command, Command::Batch(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_layout_order_and_unselected_values_are_preserved_exactly() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let reply = id(document.project(), "reply");
|
||||
let right = id(document.project(), "right");
|
||||
let lanes = vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 400.,
|
||||
}];
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
lanes.clone(),
|
||||
vec![message(reply, 424.), message(first, 148.)],
|
||||
);
|
||||
let preview = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(40.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
&project,
|
||||
diagram,
|
||||
lanes,
|
||||
vec![
|
||||
message(reply, 424.),
|
||||
message(first, 188.),
|
||||
message(second, 280.)
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_messages_in_different_operands_move_without_reparenting_or_reordering() {
|
||||
use crate::behavior::topology::occurrence;
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let mut project = document.project().clone();
|
||||
let owner = id(&project, "conversation");
|
||||
let first = id(&project, "first");
|
||||
let second = id(&project, "second");
|
||||
let fragment = occurrence(
|
||||
&project,
|
||||
owner,
|
||||
"Alternatives",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: oa_core::FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let frame = fragment.id;
|
||||
project.elements.insert(frame, fragment);
|
||||
let mut branches = Vec::new();
|
||||
for member in [first, second] {
|
||||
let branch = occurrence(
|
||||
&project,
|
||||
frame,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![member],
|
||||
},
|
||||
);
|
||||
let branch_id = branch.id;
|
||||
project.elements.insert(branch_id, branch);
|
||||
project.elements.get_mut(&member).unwrap().parent = Some(branch_id);
|
||||
branches.push(branch_id);
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
branches: items, ..
|
||||
},
|
||||
..
|
||||
}) = project.elements.get_mut(&frame).unwrap().shared_mut()
|
||||
{
|
||||
*items = branches.clone();
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
let steps = project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap();
|
||||
steps.retain(|id| ![first, second].contains(id));
|
||||
steps.insert(0, frame);
|
||||
let document = Document::from_project(project, Default::default()).unwrap();
|
||||
let project = document.project();
|
||||
let baseline = resolve(project, Representation::Saved { diagram }, first).unwrap();
|
||||
let original_send = |id| {
|
||||
baseline
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
.y
|
||||
};
|
||||
let preview = capture_group(
|
||||
project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(-5.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
project,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![
|
||||
message(first, original_send(first) - 5.),
|
||||
message(second, original_send(second) - 5.)
|
||||
]
|
||||
)
|
||||
);
|
||||
assert_eq!(preview.project.elements, project.elements);
|
||||
let mut omitted = project.clone();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
branches: items, ..
|
||||
},
|
||||
..
|
||||
}) = omitted.elements.get_mut(&frame).unwrap().shared_mut()
|
||||
{
|
||||
items.retain(|id| *id != branches[1]);
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
&omitted,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project spacing_group "Saved group spacing" id "71000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
|
||||
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
|
||||
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
|
||||
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
|
||||
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -75,6 +75,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// Resolve once per accepted view. An active spacing session owns its exact
|
||||
@@ -90,7 +91,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
},
|
||||
ScenarioPresentation::capabilities,
|
||||
);
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let coordinate_input =
|
||||
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||
let notice = if let Some(paint) = &preview {
|
||||
display_notice_parts(
|
||||
p,
|
||||
@@ -123,6 +125,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean
|
||||
&& app.scenario_reorder.session.is_none()
|
||||
&& capabilities.geometry_navigation
|
||||
&& super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
@@ -138,15 +141,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.selection = snapshot.clone().map(|snapshot| (context, snapshot));
|
||||
scene.spacing_stamp = (coordinate_input
|
||||
&& !app.source_rename.open()
|
||||
&& keyboard
|
||||
&& matches!(context.representation, Representation::Saved { .. }))
|
||||
.then_some(spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
});
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
.then(|| {
|
||||
snapshot.as_ref().map(|snapshot| spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
selection_generation: snapshot.generation(),
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
scene.coordinate_input =
|
||||
capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
@@ -158,8 +167,27 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
||||
// on Begin would discard its pointer capture before Move/Finish.
|
||||
let mut layers = iced::widget::stack![raw_canvas];
|
||||
if let Some((_, summary)) = &app.scenario_reorder.summary {
|
||||
layers = layers.push(
|
||||
container(iced::widget::opaque(
|
||||
container(
|
||||
text(format!("{summary} Use Undo to restore the previous order.")).size(12),
|
||||
)
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
))
|
||||
.align_bottom(Fill)
|
||||
.width(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
if let Some(paint) = &preview {
|
||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||
let feedback = if paint.selected_count > 1 {
|
||||
format!("{} selected - {feedback}", paint.selected_count)
|
||||
} else {
|
||||
feedback
|
||||
};
|
||||
layers = layers.push(
|
||||
container(
|
||||
container(text(feedback).size(12))
|
||||
@@ -196,7 +224,46 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
||||
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
||||
};
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
// The chooser must sit outside Viewport: that camera wrapper deliberately
|
||||
// consumes Space, arrows and wheel before its child receives input.
|
||||
let mut canvas_layers = iced::widget::stack![canvas];
|
||||
if let Some(panel) = reorder_view::panel(app, presentation.viewport) {
|
||||
canvas_layers = canvas_layers.push(panel);
|
||||
}
|
||||
let canvas = canvas_layers.into();
|
||||
let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
text(format!("{selected_count} selected")).size(12)
|
||||
]
|
||||
.spacing(6);
|
||||
let reorder = reorder_view::begin(app);
|
||||
navigation = navigation.push(super::super::canvas_workspace::Control::new(
|
||||
button(text("Reorder…").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(reorder.clone()),
|
||||
scope,
|
||||
"Reorder",
|
||||
keyboard,
|
||||
reorder,
|
||||
camera,
|
||||
));
|
||||
if selected_count > 1 {
|
||||
let removal = app
|
||||
.scenario_removal_enabled()
|
||||
.then(|| crate::app::scenario_removal::begin(app))
|
||||
.flatten();
|
||||
navigation = navigation.push(super::super::canvas_workspace::Control::new(
|
||||
button(text("Remove…").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(removal.clone()),
|
||||
scope,
|
||||
"Remove selected",
|
||||
keyboard,
|
||||
removal,
|
||||
camera,
|
||||
));
|
||||
}
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
@@ -262,7 +329,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let entries = [
|
||||
(
|
||||
"S",
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
if selected_count > 1 && matches!(context.representation, Representation::Saved { .. })
|
||||
{
|
||||
"Drag messages vertically or lifelines horizontally; Shift-click to change selection"
|
||||
} else if selected_count > 1 {
|
||||
"Shift-click to change selection; click a member to select only it"
|
||||
} else if matches!(context.representation, Representation::Saved { .. }) {
|
||||
"Select / move messages and lifelines"
|
||||
} else {
|
||||
"Select / reorder occurrences"
|
||||
@@ -343,7 +415,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
}),
|
||||
);
|
||||
let mut details = column![
|
||||
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
||||
text(if selected_count > 1 {
|
||||
format!(
|
||||
"{selected_count} selected; properties: {}",
|
||||
p.named(app.selected)
|
||||
)
|
||||
} else {
|
||||
format!("Selected: {}", p.named(app.selected))
|
||||
})
|
||||
.size(12),
|
||||
container(button(text("Properties").size(12)).on_press_maybe(
|
||||
(clean && properties_target(p, context, app.selected)).then_some(
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
|
||||
@@ -367,7 +447,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
sequence_rows(p, context, owner, details, clean, snapshot.as_ref()).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
@@ -685,7 +765,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.into();
|
||||
}
|
||||
}
|
||||
let editor = if pending {
|
||||
let editor = if app.scenario_reorder.session.is_some() {
|
||||
None
|
||||
} else if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
@@ -724,9 +806,13 @@ fn display_notice_parts<'a>(
|
||||
for diagnostic in diagnostics.iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
"Saved positions place @{} below @{}, contrary to execution order.",
|
||||
p.elements
|
||||
.get(before)
|
||||
.map_or("?", |element| element.alias.as_str()),
|
||||
p.elements
|
||||
.get(after)
|
||||
.map_or("?", |element| element.alias.as_str())
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
@@ -751,7 +837,7 @@ fn display_notice_parts<'a>(
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||
}
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").size(12));
|
||||
}
|
||||
PresentationStatus::Unavailable { .. } => {
|
||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||
@@ -908,7 +994,24 @@ fn sequence_rows<'a>(
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
enabled: bool,
|
||||
snapshot: Option<&crate::diagram_selection::Snapshot>,
|
||||
) -> Column<'a, Message> {
|
||||
let choose = |id| {
|
||||
snapshot.map_or_else(
|
||||
|| Message::ScenarioCanvas(CanvasEvent::Select(context, id)),
|
||||
|snapshot| {
|
||||
selection::message(
|
||||
context,
|
||||
snapshot,
|
||||
selection::Intent::Choose(
|
||||
crate::diagram_usage::Target::ScenarioObject(id),
|
||||
crate::app::multiselect::Choice::Replace,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
let order_enabled = enabled && snapshot.is_none_or(|snapshot| snapshot.targets().len() <= 1);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
@@ -917,10 +1020,10 @@ fn sequence_rows<'a>(
|
||||
for branch in branches {
|
||||
out = out.push(sequence_action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
Message::Select(*branch),
|
||||
choose(*branch),
|
||||
enabled,
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out, enabled);
|
||||
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
@@ -929,17 +1032,17 @@ fn sequence_rows<'a>(
|
||||
row![
|
||||
sequence_action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
Message::Select(*id),
|
||||
choose(*id),
|
||||
enabled
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
||||
.on_press_maybe((order_enabled && i > 0).then_some(Message::Command(
|
||||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||||
))),
|
||||
button("↓").padding(8).on_press_maybe(
|
||||
(enabled && i + 1 < steps.len())
|
||||
(order_enabled && i + 1 < steps.len())
|
||||
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
||||
)
|
||||
]
|
||||
@@ -960,7 +1063,7 @@ fn sequence_rows<'a>(
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out, enabled);
|
||||
out = sequence_rows(p, context, *id, out, enabled, snapshot);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -388,13 +388,16 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
|
||||
CanvasEvent::Viewport(c, Size::new(900., 600.))
|
||||
});
|
||||
open_details(&mut app);
|
||||
let begin = packet(
|
||||
let mut begin = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
Target::Message(message),
|
||||
);
|
||||
if let Spacing::Begin { stamp, .. } = &mut begin {
|
||||
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
|
||||
}
|
||||
let Spacing::Begin { press_screen, .. } = begin else {
|
||||
panic!()
|
||||
};
|
||||
@@ -443,3 +446,180 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_selection_shift_survives_properties_focus_busy_rebuild_and_clears_on_window_loss() {
|
||||
use crate::app::multiselect::Choice;
|
||||
use crate::behavior::canvas_workspace::tests::focused;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn at(mounted: &mut Mounted, app: &App, target: Id) -> Point {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let geometry = resolve(app.project(), context.representation, app.selected).unwrap();
|
||||
let line = geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == target)
|
||||
.unwrap();
|
||||
let world = line.label_at + Vector::new(8., 8.);
|
||||
assert_eq!(geometry.scene.hit_item(world).unwrap().id, target);
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
|
||||
assert!(bounds.contains(point));
|
||||
point
|
||||
}
|
||||
async fn focus_name(mounted: &mut Mounted, app: &mut App) -> Point {
|
||||
let shown = mounted.geometry(app);
|
||||
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
|
||||
let input = shown
|
||||
.text_inputs
|
||||
.iter()
|
||||
.map(|bounds| bounds.center() - offset)
|
||||
.filter(|point| inspector.contains(*point))
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("visible Name input");
|
||||
mounted.click(app, input).await;
|
||||
assert!(!focused(mounted, app, "viewport"));
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, yes)| *yes && bounds.contains(input + offset))
|
||||
);
|
||||
assert!(!app.draft_dirty());
|
||||
input
|
||||
}
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if held {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
let (mut app, diagram, first) = setup();
|
||||
let second = id(&app, "updated_report");
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
open_details(&mut app);
|
||||
mounted.settle(&mut app).await;
|
||||
let shown = mounted.geometry(&app);
|
||||
let (details, offset) = shown.scrolls[&widget::Id::new("scenario-details")];
|
||||
let properties = shown.bounds("scenario-properties").center() - offset;
|
||||
assert!(details.contains(properties));
|
||||
mounted.click(&mut app, properties).await;
|
||||
mounted.settle(&mut app).await;
|
||||
let input = focus_name(&mut mounted, &mut app).await;
|
||||
let before = app.document.snapshot();
|
||||
let camera = stored_camera(&app);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets(), &[Target::ScenarioObject(first)]);
|
||||
let passive = mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
|
||||
.await;
|
||||
assert!(
|
||||
!passive
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
let second_at = at(&mut mounted, &app, second);
|
||||
let messages = mounted.click(&mut app, second_at).await;
|
||||
assert_eq!(messages.iter().filter(|message| matches!(message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(event))
|
||||
if matches!(&event.intent, selection::Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if *id == second))).count(), 1);
|
||||
assert!(!messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(_) | CanvasEvent::Reorder { .. })
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[shift(false)],
|
||||
mouse::Cursor::Available(second_at),
|
||||
)
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let first_at = at(&mut mounted, &app, first);
|
||||
let messages = mounted.click(&mut app, first_at).await;
|
||||
// Saved group clicks now complete the captured spacing lifetime; its
|
||||
// stationary outcome must retain the same selection-only collapse.
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
super::super::spacing::Event::Finish { .. }
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
|
||||
let input = focus_name(&mut mounted, &mut app).await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let second_at = at(&mut mounted, &app, second);
|
||||
mounted.click(&mut app, second_at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let first_at = at(&mut mounted, &app, first);
|
||||
mounted.click(&mut app, first_at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(first))
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
}
|
||||
|
||||
@@ -436,3 +436,204 @@ async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_c
|
||||
assert_eq!(app.project(), &redo.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &redo.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_first_press_spaces_unselected_message_with_existing_singleton_selection() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram) = setup(false).await;
|
||||
let initial = id(&app, "initial_report");
|
||||
let target = id(&app, "updated_report");
|
||||
let context = app.scenario_context().unwrap();
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let task = app.update(selection::message(
|
||||
context,
|
||||
&snapshot,
|
||||
selection::Intent::Choose(
|
||||
Target::ScenarioObject(initial),
|
||||
crate::app::multiselect::Choice::Replace,
|
||||
),
|
||||
));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(initial)]
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = stored_camera(&app);
|
||||
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
|
||||
let start = Point::new(projected.x.round(), projected.y.round());
|
||||
let geometry = resolved(&app);
|
||||
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
assert_eq!(effective.scale, 0.5);
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let world = Point::new(
|
||||
(start.x - bounds.x - effective.pan.x) / effective.scale,
|
||||
(start.y - bounds.y - effective.pan.y) / effective.scale,
|
||||
);
|
||||
let hit = geometry.scene.hit_item(world).unwrap();
|
||||
assert_eq!(hit.id, target);
|
||||
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
|
||||
let messages = press(&mut mounted, &mut app, start).await;
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(_))))
|
||||
);
|
||||
let token = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_token()
|
||||
.expect("first press starts saved spacing despite different selected singleton");
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
let end = start + Vector::new(0., 10.);
|
||||
move_to(&mut mounted, &mut app, end).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
|
||||
let messages = release(&mut mounted, &mut app, end).await;
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
set_expected_message(&mut expected, diagram, target, 272.);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(target)]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(target))
|
||||
);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert_reopens_exactly(&app);
|
||||
let after = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let duplicate = release(&mut mounted, &mut app, end).await;
|
||||
assert!(!duplicate.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
|
||||
)));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&after, &app.document.snapshot()));
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let task = app.update(Message::Redo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &after.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_blank_left_drag_clears_selection_and_pans_on_that_same_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
for group in [false, true] {
|
||||
for same_batch in [false, true] {
|
||||
let (mut app, mut mounted, _) = setup(false).await;
|
||||
let first = id(&app, "initial_report");
|
||||
let second = id(&app, "updated_report");
|
||||
for target in [Some(first), group.then_some(second)].into_iter().flatten() {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let task = app.update(selection::message(
|
||||
context,
|
||||
&snapshot,
|
||||
selection::Intent::Choose(
|
||||
Target::ScenarioObject(target),
|
||||
crate::app::multiselect::Choice::Add,
|
||||
),
|
||||
));
|
||||
mounted.perform(&mut app, task).await;
|
||||
}
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
if group { 2 } else { 1 }
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let start = bounds.position() + Vector::new(bounds.width - 25., bounds.height - 25.);
|
||||
let delta = Vector::new(-25., -20.);
|
||||
let end = start + delta;
|
||||
let geometry = resolved(&app);
|
||||
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
let world = Point::new(
|
||||
(start.x - bounds.x - camera.pan.x) / camera.scale,
|
||||
(start.y - bounds.y - camera.pan.y) / camera.scale,
|
||||
);
|
||||
assert!(
|
||||
geometry.scene.hit_item(world).is_none(),
|
||||
"test must start on actual blank geometry"
|
||||
);
|
||||
let stored = stored_camera(&app);
|
||||
let messages = if same_batch {
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
mouse_event(mouse::Event::CursorMoved { position: start }),
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
mouse_event(mouse::Event::CursorMoved { position: end }),
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let mut messages = press(&mut mounted, &mut app, start).await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
mounted.settle(&mut app).await;
|
||||
messages.extend(move_to(&mut mounted, &mut app, end).await);
|
||||
messages.extend(release(&mut mounted, &mut app, end).await);
|
||||
messages
|
||||
};
|
||||
assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(event)) if matches!(&event.intent, selection::Intent::Clear))).count(), 1);
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
let actual = stored_camera(&app);
|
||||
let difference = actual.0 - (stored.0 + delta);
|
||||
assert!(
|
||||
difference.x.hypot(difference.y) < 0.01,
|
||||
"same-press pan lost after Clear: group={group}, same_batch={same_batch}, {stored:?} -> {actual:?}"
|
||||
);
|
||||
assert_eq!(actual.1, stored.1);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,13 @@ impl Rendering {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Exact painted marker subjects and centers for shared selection adapters.
|
||||
pub(in crate::behavior) fn selection_markers(&self) -> impl Iterator<Item = (Id, Point)> + '_ {
|
||||
self.markers
|
||||
.iter()
|
||||
.map(|marker| (marker.event.occurrence, marker.center))
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
||||
self.markers
|
||||
.iter()
|
||||
|
||||
@@ -29,6 +29,7 @@ use std::{
|
||||
pub const GUIDANCE_HEIGHT: f32 = 42.;
|
||||
|
||||
pub struct StateCanvas<'a> {
|
||||
pub removal_input: &'a crate::removal_input::Lifetime,
|
||||
pub project: &'a Project,
|
||||
pub diagram: Id,
|
||||
pub context: Context,
|
||||
@@ -44,6 +45,8 @@ pub struct StateCanvas<'a> {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_lease: Option<crate::removal_input::Lease>,
|
||||
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
context: Option<Context>,
|
||||
tool: Option<Tool>,
|
||||
pointer: Option<Point>,
|
||||
@@ -877,6 +880,21 @@ impl StateCanvas<'_> {
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
if *modifiers == keyboard::Modifiers::SHIFT {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Event::ModelRemoval(Box::new(
|
||||
crate::app::saved_removal::Event::Begin {
|
||||
input_generation,
|
||||
context: self.context,
|
||||
snapshot: Box::new(snapshot),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let event = if modifiers.is_empty()
|
||||
&& crate::app::multiselect::placements(&snapshot).is_some()
|
||||
{
|
||||
@@ -885,7 +903,7 @@ impl StateCanvas<'_> {
|
||||
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
|
||||
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
|
||||
} else {
|
||||
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
|
||||
"Use Shift+Delete to review model deletion or Hide selected for placements."
|
||||
}.into())
|
||||
};
|
||||
return Some(Action::publish(Event::Selection(event)).and_capture());
|
||||
@@ -1511,9 +1529,8 @@ fn kind_for_tool(tool: Tool) -> Kind {
|
||||
}
|
||||
}
|
||||
|
||||
impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
impl StateCanvas<'_> {
|
||||
fn update_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -2002,6 +2019,51 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.press.is_some()
|
||||
|| state.group.is_some()
|
||||
|| state.source.is_some()
|
||||
|| state.bend.is_some()
|
||||
|| state.pan_from.is_some(),
|
||||
);
|
||||
self.removal_input.observe(event);
|
||||
let viewport = (self.zoom, self.pan, bounds);
|
||||
if state
|
||||
.removal_viewport
|
||||
.is_some_and(|previous| previous != viewport)
|
||||
{
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
state.removal_viewport = Some(viewport);
|
||||
let action = self.update_input(state, event, bounds, cursor);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
) {
|
||||
state.reset();
|
||||
}
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.press.is_some()
|
||||
|| state.group.is_some()
|
||||
|| state.source.is_some()
|
||||
|| state.bend.is_some()
|
||||
|| state.pan_from.is_some(),
|
||||
);
|
||||
action
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
@@ -2597,6 +2659,7 @@ mod tests {
|
||||
include!("state_multiselect_canvas_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
machine: Id,
|
||||
@@ -2658,6 +2721,7 @@ mod tests {
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
machine,
|
||||
@@ -2858,6 +2922,7 @@ mod tests {
|
||||
|
||||
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
|
||||
StateCanvas {
|
||||
removal_input: &f.removal_input,
|
||||
project: &f.project,
|
||||
diagram: f.diagram,
|
||||
context: Context {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub enum Tool {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Selection(crate::app::multiselect::Event),
|
||||
ModelRemoval(Box<crate::app::saved_removal::Event>),
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
@@ -104,7 +105,9 @@ impl Event {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Selection(_) | Self::ModelRemoval(_) | Self::Choose(_) | Self::CreateView(_) => {
|
||||
None
|
||||
}
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
|
||||
@@ -328,7 +328,10 @@ fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
|
||||
let mut state = State::default();
|
||||
use keyboard::key::Named;
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let Some(Event::ModelRemoval(removal)) = group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT) else {
|
||||
panic!("Shift+Delete must capture the complete model-removal group");
|
||||
};
|
||||
assert!(matches!(*removal, crate::app::saved_removal::Event::Begin { snapshot, .. } if snapshot.targets() == selection.targets()));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
fn event(e: Event) -> Message {
|
||||
match e {
|
||||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||||
Event::ModelRemoval(removal) => Message::SavedRemoval(removal),
|
||||
other => Message::StateDiagram(other),
|
||||
}
|
||||
}
|
||||
@@ -169,6 +170,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
);
|
||||
}
|
||||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
project: p,
|
||||
diagram: id,
|
||||
context,
|
||||
@@ -223,15 +225,22 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
controls = controls.push(
|
||||
button(text("Delete from model…").size(12))
|
||||
.padding([6, 8])
|
||||
.on_press_maybe(
|
||||
crate::app::saved_removal::begin(app).filter(|_| app.saved_removal_enabled()),
|
||||
),
|
||||
);
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
"Clear",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
"Edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 12)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
use crate::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,148 @@
|
||||
//! 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;
|
||||
#[cfg(test)]
|
||||
use crate::removal_effects::Interpretation;
|
||||
pub(crate) use crate::removal_effects::InterpretationChange;
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
fn changed_records<T: PartialEq>(
|
||||
before: &BTreeMap<Id, T>,
|
||||
after: &BTreeMap<Id, T>,
|
||||
changed: &mut BTreeSet<Id>,
|
||||
) {
|
||||
changed.extend(
|
||||
before
|
||||
.iter()
|
||||
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let 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 crate::removal_effects::DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()))),
|
||||
));
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_gesture;
|
||||
pub(crate) mod group_removal;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
@@ -52,6 +53,7 @@ pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub removal_input: &'a crate::removal_input::Lifetime,
|
||||
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
@@ -71,6 +73,8 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_lease: Option<crate::removal_input::Lease>,
|
||||
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
@@ -606,9 +610,8 @@ impl DiagramCanvas<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
impl DiagramCanvas<'_> {
|
||||
fn update_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -1243,6 +1246,67 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.group.is_some()
|
||||
|| state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.connection_press.is_some()
|
||||
|| state.port_drag.is_some(),
|
||||
);
|
||||
self.removal_input.observe(event);
|
||||
let viewport = (self.zoom, self.pan, bounds);
|
||||
if state
|
||||
.removal_viewport
|
||||
.is_some_and(|previous| previous != viewport)
|
||||
{
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
state.removal_viewport = Some(viewport);
|
||||
let action = self.update_input(state, event, bounds, cursor);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
) {
|
||||
// The viewport can synthesize Unfocused when it takes camera input.
|
||||
// Never leave a private drag or its lease waiting for a lost release.
|
||||
state.group = None;
|
||||
state.work = None;
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.pan_start = None;
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
state.connection_press = None;
|
||||
state.connection_hover = None;
|
||||
state.port_drag = None;
|
||||
state.pointer = None;
|
||||
}
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.group.is_some()
|
||||
|| state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.connection_press.is_some()
|
||||
|| state.port_drag.is_some(),
|
||||
);
|
||||
action
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
@@ -2328,6 +2392,7 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2406,6 +2471,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2535,6 +2601,7 @@ mod tests {
|
||||
};
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2711,6 +2778,7 @@ mod tests {
|
||||
for zoom in [0.5, 1., 1.5] {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2790,6 +2858,7 @@ mod tests {
|
||||
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2890,6 +2959,7 @@ mod tests {
|
||||
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3021,6 +3091,7 @@ mod tests {
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3078,6 +3149,7 @@ mod tests {
|
||||
offset: Position { x: 0., y: -160. },
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3155,6 +3227,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3191,6 +3264,7 @@ mod tests {
|
||||
routed.edges.insert(duplicate, duplicate_route);
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
|
||||
@@ -98,6 +98,7 @@ fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
|
||||
@@ -34,6 +34,7 @@ fn fixture() -> App {
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
|
||||
@@ -206,6 +206,16 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
// The focus task runs after the native batch. Retain focus for its
|
||||
// traversal count, but reject later keys in this same batch now.
|
||||
state.traversing = true;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&InputEvent::Window(iced::window::Event::Unfocused),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
shell.publish((self.traverse)(modifiers.shift()));
|
||||
shell.capture_event();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Pure, whole-selection Delete from model planning for saved architecture and
|
||||
//! State views. UI lifetime, drafts and confirmation remain the caller's concern.
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{
|
||||
Command, Id, Kind, Project, execute_batch, projection, references, validate_candidate,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
/// Semantic identities resolved from every admitted exact target.
|
||||
pub(crate) selected: BTreeSet<Id>,
|
||||
/// Actual identity and surviving-record differences after the complete batch.
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
pub(crate) interpretation: Vec<crate::removal_effects::InterpretationChange>,
|
||||
/// Changed or deleted definitions and surviving transitive callers.
|
||||
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let saved = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or("The saved view no longer exists.")?;
|
||||
if !saved.projection.is_architecture() && saved.projection.state_machine().is_none() {
|
||||
return Err("Delete from model supports saved Architecture and State views here.".into());
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one visible model object to delete.".into());
|
||||
}
|
||||
validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let view = projection::project_diagram(project, saved);
|
||||
let mut selected = BTreeSet::new();
|
||||
// Resolve every original typed identity BEFORE removing any cascade overlap.
|
||||
// A stale/hidden/wrong-kind target cannot become admissible because another
|
||||
// selection member would happen to delete its semantic identity.
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
if targets[..index].contains(target) {
|
||||
return Err("Every selected exact target must be unique.".into());
|
||||
}
|
||||
let id = match target {
|
||||
Target::Placement(id) => saved.nodes.get(id)
|
||||
.filter(|node| view.nodes.iter().any(|visible| visible.id == *id && visible.element == node.element))
|
||||
.map(|node| node.element)
|
||||
.ok_or("A selected placement is no longer visible in this saved view.")?,
|
||||
Target::Port { port, placement } => {
|
||||
let owner = saved.nodes.get(placement)
|
||||
.ok_or("A selected port's exact owner placement no longer exists in this view.")?;
|
||||
if !view.ports.iter().any(|visible| visible.element == *port && visible.owner == *placement)
|
||||
|| !project.elements.get(port).is_some_and(|element| element.kind() == Kind::Port && element.parent == Some(owner.element))
|
||||
{
|
||||
return Err("A selected port is no longer visible on its exact owner placement.".into());
|
||||
}
|
||||
*port
|
||||
}
|
||||
Target::Edge(id) => {
|
||||
if !view.edges.iter().any(|edge| edge.id == *id)
|
||||
|| !(project.relations.contains_key(id) || project.traces.contains_key(id))
|
||||
{
|
||||
return Err("Every selected edge must be a visible stored relation or trace; inferred edges are read-only.".into());
|
||||
}
|
||||
*id
|
||||
}
|
||||
Target::StateRow { placement, relation } => {
|
||||
if !crate::behavior::state_canvas::visible_row_target(project, diagram, &view, *placement, *relation) {
|
||||
return Err("A selected State row is no longer visible on its exact placement.".into());
|
||||
}
|
||||
*relation
|
||||
}
|
||||
_ => return Err("The complete selection must contain only visible placements, ports, stored edges or State rows; grouping and read-only targets cannot be deleted here.".into()),
|
||||
};
|
||||
selected.insert(id);
|
||||
}
|
||||
|
||||
// Prefer cascades covering the most remaining selected subjects. Selecting a
|
||||
// parent together with its child/port/incident relation must not issue Delete
|
||||
// for an identity already removed by that parent. Stable ID order breaks ties.
|
||||
// Core defers conditional exchange-allocation cleanup until batch completion;
|
||||
// admission and the final result are therefore never singleton-validated.
|
||||
let impacts: BTreeMap<_, _> = selected
|
||||
.iter()
|
||||
.map(|&id| (id, references::deletion_impact(project, id)))
|
||||
.collect();
|
||||
let mut pending = selected.clone();
|
||||
let mut commands = Vec::new();
|
||||
while !pending.is_empty() {
|
||||
let id = *pending
|
||||
.iter()
|
||||
.max_by_key(|&&id| {
|
||||
(
|
||||
impacts[&id].intersection(&pending).count(),
|
||||
std::cmp::Reverse(id),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
commands.push(Command::Delete(id));
|
||||
pending.retain(|remaining| !impacts[&id].contains(remaining));
|
||||
}
|
||||
let command = Command::Batch(commands);
|
||||
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.traces, &candidate.traces, &mut changed);
|
||||
changed_records(
|
||||
&project.artifact_links,
|
||||
&candidate.artifact_links,
|
||||
&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 crate::removal_effects::DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
|
||||
Ok(Plan {
|
||||
command,
|
||||
selected,
|
||||
removed,
|
||||
changed,
|
||||
interpretation,
|
||||
affected_uses,
|
||||
})
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conditional_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Independent conditional-cascade checks for overlapping selected endpoint allocations.
|
||||
use super::prepare;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
selected_allocations: [Id; 2],
|
||||
exchange_allocation: Id,
|
||||
functional_exchange: Id,
|
||||
}
|
||||
|
||||
fn relation(project: &mut Project, number: u128, alias: &str, body: RelationBody) -> Id {
|
||||
let id = Id::from_u128(number);
|
||||
assert!(
|
||||
project
|
||||
.relations
|
||||
.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
body,
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(reverse_ids: bool, invalid_dual_mapping: bool) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let performer = project
|
||||
.add_element(Kind::System, Layer::Sa, "Send performer", None)
|
||||
.unwrap();
|
||||
let receiver = if invalid_dual_mapping {
|
||||
performer
|
||||
} else {
|
||||
project
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Receive performer", None)
|
||||
.unwrap()
|
||||
};
|
||||
let source = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let retained = project.relate(RelationKind::Allocation, "Performs send", performer, source);
|
||||
project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Performs receive",
|
||||
receiver,
|
||||
target,
|
||||
);
|
||||
let functional_exchange = project.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Information",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
let (output, input) = match project.relations[&functional_exchange].body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => (source_port, target_port),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut component_ports = vec![];
|
||||
for (owner, name) in [
|
||||
(performer, "First component port"),
|
||||
(receiver, "Second component port"),
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&project,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::InOut,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(id, port);
|
||||
component_ports.push(id);
|
||||
}
|
||||
let [a, b] = [component_ports[0], component_ports[1]];
|
||||
let boundary = relation(
|
||||
&mut project,
|
||||
300,
|
||||
"boundary",
|
||||
RelationBody::BoundaryExchange {
|
||||
source: a,
|
||||
target: b,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
);
|
||||
// Either endpoint allocation is required. Their overlapping conditional
|
||||
// cascade must remove the exchange allocation once, after one whole batch.
|
||||
let numbers = if reverse_ids { [200, 100] } else { [100, 200] };
|
||||
let first = relation(
|
||||
&mut project,
|
||||
numbers[0],
|
||||
"first_output",
|
||||
RelationBody::PortAllocation {
|
||||
source: a,
|
||||
target: output,
|
||||
},
|
||||
);
|
||||
let second = relation(
|
||||
&mut project,
|
||||
numbers[1],
|
||||
"second_input",
|
||||
RelationBody::PortAllocation {
|
||||
source: b,
|
||||
target: input,
|
||||
},
|
||||
);
|
||||
if invalid_dual_mapping {
|
||||
// Two simultaneous direct/reverse mappings would need the same actual
|
||||
// performer at both ends, violating the different-boundary-owners rule.
|
||||
relation(
|
||||
&mut project,
|
||||
400,
|
||||
"first_input",
|
||||
RelationBody::PortAllocation {
|
||||
source: a,
|
||||
target: input,
|
||||
},
|
||||
);
|
||||
relation(
|
||||
&mut project,
|
||||
500,
|
||||
"second_output",
|
||||
RelationBody::PortAllocation {
|
||||
source: b,
|
||||
target: output,
|
||||
},
|
||||
);
|
||||
}
|
||||
let exchange_allocation = relation(
|
||||
&mut project,
|
||||
150,
|
||||
"realizes_exchange",
|
||||
RelationBody::ExchangeAllocation {
|
||||
source: boundary,
|
||||
target: functional_exchange,
|
||||
},
|
||||
);
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
for (element, x) in [
|
||||
(performer, 40.),
|
||||
(receiver, 1360.),
|
||||
(source, 480.),
|
||||
(target, 920.),
|
||||
] {
|
||||
project.place(diagram, element, Position { x, y: 200. });
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes = [first, retained]
|
||||
.into_iter()
|
||||
.map(|edge| EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 360., y: 350. }],
|
||||
label: None,
|
||||
})
|
||||
.collect();
|
||||
Fixture {
|
||||
project,
|
||||
diagram,
|
||||
selected_allocations: [first, second],
|
||||
exchange_allocation,
|
||||
functional_exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_conditional_allocation_loss_is_reviewed_from_final_candidate() {
|
||||
for reverse_ids in [false, true] {
|
||||
let f = fixture(reverse_ids, false);
|
||||
validate_candidate(&f.project)
|
||||
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||
for selected in f.selected_allocations {
|
||||
assert!(
|
||||
references::deletion_impact(&f.project, selected).contains(&f.exchange_allocation),
|
||||
"Both selected allocations overlap on the same conditional dependent"
|
||||
);
|
||||
let singleton = execute_batch(&f.project, &[Command::Delete(selected)]).unwrap();
|
||||
assert!(!singleton.relations.contains_key(&f.exchange_allocation));
|
||||
}
|
||||
let targets = f.selected_allocations.map(Target::Edge);
|
||||
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||
let reversed = prepare(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&[targets[1].clone(), targets[0].clone()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&plan.command).unwrap(),
|
||||
serde_json::to_value(&reversed.command).unwrap(),
|
||||
"Click order must not change normalization"
|
||||
);
|
||||
let removed = BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.exchange_allocation,
|
||||
]);
|
||||
assert_eq!(plan.selected, BTreeSet::from(f.selected_allocations));
|
||||
assert_eq!(plan.removed, removed);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||
// Literal record oracle, independent of deletion_impact / planner output.
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.remove(0);
|
||||
let actual = execute_batch(&f.project, &[plan.command]).unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Only two port allocations, their conditional exchange allocation and the deleted edge's authored route change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_exchange_cascade_and_conditional_support_loss_share_one_batch() {
|
||||
for reverse_ids in [false, true] {
|
||||
let f = fixture(reverse_ids, false);
|
||||
validate_candidate(&f.project)
|
||||
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||
let targets = [
|
||||
Target::Edge(f.selected_allocations[0]),
|
||||
Target::Edge(f.functional_exchange),
|
||||
Target::Edge(f.selected_allocations[1]),
|
||||
];
|
||||
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||
let removed = BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.functional_exchange,
|
||||
f.exchange_allocation,
|
||||
]);
|
||||
assert_eq!(
|
||||
plan.selected,
|
||||
BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.functional_exchange
|
||||
])
|
||||
);
|
||||
assert_eq!(plan.removed, removed);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
execute_batch(&f.project, &[plan.command]).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternative_pairing_counterexample_is_not_a_valid_saved_model() {
|
||||
let f = fixture(false, true);
|
||||
assert!(validation::validate(&f.project).iter().any(
|
||||
|issue| issue.code == "boundary_owner" && issue.severity == validation::Severity::Error
|
||||
));
|
||||
let before = f.project.clone();
|
||||
assert!(
|
||||
prepare(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&f.selected_allocations.map(Target::Edge)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -178,10 +178,25 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
if *modifiers == iced::keyboard::Modifiers::SHIFT {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::SavedRemoval(Box::new(
|
||||
crate::app::saved_removal::Event::Begin {
|
||||
input_generation,
|
||||
context: self.context,
|
||||
snapshot: Box::new(snapshot),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let action = if modifiers.is_empty() {
|
||||
Event::Hide(snapshot)
|
||||
} else {
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
Event::Refuse(snapshot, "Use plain Delete to hide placements or Shift+Delete to review deletion from the model.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
@@ -70,6 +71,7 @@ impl Fixture {
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
Self {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
a,
|
||||
@@ -105,6 +107,7 @@ impl Fixture {
|
||||
_ => self.diagram,
|
||||
};
|
||||
DiagramCanvas {
|
||||
removal_input: &self.removal_input,
|
||||
selection: Some(selection),
|
||||
project: &self.project,
|
||||
view_index: None,
|
||||
|
||||
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
fn canvas<'a>(
|
||||
project: &'a Project,
|
||||
diagram: Id,
|
||||
zoom: f32,
|
||||
removal_input: &'a crate::removal_input::Lifetime,
|
||||
) -> DiagramCanvas<'a> {
|
||||
DiagramCanvas {
|
||||
removal_input,
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
@@ -58,7 +64,8 @@ fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, zoom, &removal_input);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
@@ -91,7 +98,8 @@ fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let mut canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
@@ -147,7 +155,8 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -184,6 +184,10 @@ impl Selection {
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod recovery;
|
||||
mod removal_effects;
|
||||
mod removal_input;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_annotations;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct Request {
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub process: Option<Box<crate::behavior::topology::Context>>,
|
||||
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
@@ -34,6 +35,7 @@ impl Request {
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
scenario: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -55,6 +57,7 @@ pub struct Opened {
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
scenario_selection: Option<crate::diagram_selection::Snapshot>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
@@ -67,9 +70,24 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.topology_context())
|
||||
|| request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.scenario_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scenario_selection = if request.scenario.is_some()
|
||||
|| request.process.is_some()
|
||||
|| (request.canvas.is_some()
|
||||
&& app
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1))
|
||||
{
|
||||
Some(app.multiselect_snapshot()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
@@ -79,6 +97,7 @@ impl Opened {
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
scenario_selection,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
@@ -99,6 +118,15 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.topology_context())
|
||||
&& self
|
||||
.request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.scenario_context())
|
||||
&& self
|
||||
.scenario_selection
|
||||
.as_ref()
|
||||
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
@@ -109,9 +137,19 @@ impl Opened {
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
if request.scenario.is_some()
|
||||
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if let Some(context) = request.scenario.as_deref() {
|
||||
return Some(*context) == app.scenario_context()
|
||||
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
|
||||
&& app.multiselect_target_visible(target);
|
||||
}
|
||||
if (request.canvas.is_none() && request.process.is_none())
|
||||
|| !app.multiselect_target_visible(target)
|
||||
{
|
||||
@@ -159,6 +197,17 @@ fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| {
|
||||
app.scenario_context()
|
||||
.map(|context| match context.representation {
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner } => {
|
||||
owner
|
||||
}
|
||||
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
|
||||
diagram
|
||||
}
|
||||
})
|
||||
})
|
||||
.or_else(|| app.state_context().map(|context| context.diagram))
|
||||
} else {
|
||||
app.active_diagram
|
||||
@@ -172,49 +221,56 @@ pub fn open(object: Id, anchor: Point) -> Message {
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn actions_request(app: &App, anchor: Point) -> Request {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
crate::diagram_usage::Target::ScenarioObject(id) => {
|
||||
request.object = *id;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
@@ -261,6 +317,78 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if let Some(context) = request.scenario.as_deref().copied() {
|
||||
use crate::app::multiselect::Choice;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection};
|
||||
let snapshot = opened.scenario_selection.as_ref()?;
|
||||
let selection_message = |intent| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
};
|
||||
if let Some(target) = request.selection_target.as_deref() {
|
||||
let selected = snapshot.targets().contains(target);
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if snapshot.targets().len() > 1 {
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
// A menu on an unselected subject must not expose singleton model actions
|
||||
// while the canvas still depicts a group. Selection changes are explicit.
|
||||
if snapshot.targets().len() > 1 {
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(Entry::title(
|
||||
"Use Reorder above the canvas to change sequence order",
|
||||
));
|
||||
if let Some(removal) = crate::app::scenario_removal::begin(app) {
|
||||
entries.push(item("Remove selected from Scenario…", removal, true));
|
||||
}
|
||||
entries.push(Entry::title(
|
||||
"Select one occurrence for its individual actions",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (request.canvas.is_some() || request.process.is_some())
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
@@ -314,22 +442,50 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
)),
|
||||
true,
|
||||
));
|
||||
// Navigation belongs to the exact clicked preview, even when it
|
||||
// is outside the current group. Keep it alongside aggregate removal
|
||||
// without selecting that preview or exposing singleton mutations.
|
||||
let qualified_definition = request.process.as_deref().copied().and_then(|context| {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
}?;
|
||||
Some((context, endpoint))
|
||||
});
|
||||
if let Some((context, endpoint)) = &qualified_definition {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
*context,
|
||||
endpoint.clone(),
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& selected
|
||||
&& (selected || request.process.is_some() || request.canvas.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(
|
||||
@@ -343,9 +499,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",
|
||||
@@ -353,9 +513,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()
|
||||
&& let Some(removal) = crate::app::saved_removal::begin(app)
|
||||
{
|
||||
entries.push(item("Delete selected from model…", removal, true));
|
||||
}
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
@@ -364,34 +526,13 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if let Some(context) = request.process.as_deref().copied() {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(endpoint) = endpoint {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint,
|
||||
),
|
||||
),
|
||||
true,
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if qualified_definition.is_some() {
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,6 +939,10 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_menu_scenario_tests.rs"]
|
||||
mod scenario_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
behavior::scenario::{CanvasEvent, Context, selection},
|
||||
behavior::scenario_layout::Representation,
|
||||
diagram_usage::Target,
|
||||
};
|
||||
|
||||
fn saved(project: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("scenario_menu_view_{}", project.diagrams.len()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
nodes: Default::default(),
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture() -> (App, [Id; 3], Id, [Id; 2]) {
|
||||
let (mut project, [owner, _, _], [first, second, hidden]) =
|
||||
crate::behavior::scenario::direct_tests::navigation_fixture();
|
||||
let mut third = project.elements[&first].clone();
|
||||
third.id = Id::new_v4();
|
||||
third.alias = "third_menu_reference".into();
|
||||
third.name = "Third reference".into();
|
||||
let third_id = third.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(third)]).unwrap();
|
||||
let views = [
|
||||
saved(&mut project, owner, "A"),
|
||||
saved(&mut project, owner, "B"),
|
||||
];
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(owner));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
(app, [first, second, third_id], hidden, views)
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id, choice: Choice) {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event))),
|
||||
);
|
||||
}
|
||||
|
||||
fn request(app: &App, id: Id) -> Request {
|
||||
let mut request = Request::object(id, Point::new(120., 80.));
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(id)));
|
||||
request
|
||||
}
|
||||
|
||||
fn action(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled Scenario menu action {caption}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_requests_require_exact_visible_target_and_complete_exclusive_context() {
|
||||
let (mut app, [first, second, _], hidden, [view, _]) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let valid = request(&app, first);
|
||||
assert!(Opened::new(&app, valid.clone(), 1).is_some());
|
||||
let current = app.scenario_context().unwrap();
|
||||
for captured in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
scenario: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
representation: Representation::Saved { diagram: view },
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
visit: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
let mut stale = valid.clone();
|
||||
stale.scenario = Some(Box::new(captured));
|
||||
assert!(Opened::new(&app, stale, 2).is_none());
|
||||
}
|
||||
let mut wrong = valid.clone();
|
||||
wrong.object = second;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
assert!(app.project().contains(hidden));
|
||||
assert!(!app.multiselect_target_visible(&Target::ScenarioObject(hidden)));
|
||||
assert!(Opened::new(&app, request(&app, hidden), 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.placement = Some(Id::new_v4());
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.canvas = Some(route_edit::Context {
|
||||
document: current.document,
|
||||
diagram: view,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
});
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.process = Some(Box::new(crate::behavior::topology::Context {
|
||||
document: current.document,
|
||||
owner: current.scenario,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
}));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.selection_target = Some(Box::new(Target::Edge(first)));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid;
|
||||
wrong.scenario = None;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_rejects_saved_visit_roundtrip_even_with_equal_generic_snapshot() {
|
||||
let (mut app, [first, second, _], _, [a, b]) = fixture();
|
||||
let switch = |app: &mut App, diagram| {
|
||||
let context = app.scenario_context().unwrap();
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Saved { diagram },
|
||||
)),
|
||||
);
|
||||
};
|
||||
switch(&mut app, a);
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let opened = Opened::new(&app, request(&app, first), 1).unwrap();
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.diagram_selection.take().unwrap();
|
||||
switch(&mut app, b);
|
||||
assert!(!opened.valid(&app));
|
||||
switch(&mut app, a);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, old_context.visit);
|
||||
// Restore the same shared selection and epoch deliberately. Only the full
|
||||
// Scenario visit may distinguish the old request from this returned view.
|
||||
app.canvas_epoch = old_context.epoch;
|
||||
app.diagram_selection = Some(selected);
|
||||
app.selected = second;
|
||||
assert_eq!(app.multiselect_snapshot(), opened.scenario_selection);
|
||||
assert!(!opened.valid(&app));
|
||||
assert!(Opened::new(&app, opened.request.clone(), 2).is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_menus_capture_set_and_never_expose_partial_model_actions() {
|
||||
let (mut app, [first, second, third], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let before = app.document.snapshot();
|
||||
let capture = actions_request(&app, Point::ORIGIN);
|
||||
assert_eq!(capture.object, second);
|
||||
assert_eq!(
|
||||
capture.selection_target.as_deref(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
assert_eq!(capture.scenario.as_deref().copied(), app.scenario_context());
|
||||
assert!(capture.canvas.is_none() && capture.process.is_none() && capture.placement.is_none());
|
||||
|
||||
for target in [first, third] {
|
||||
let captured_request = request(&app, target);
|
||||
send(&mut app, Message::Menu(Event::Open(captured_request)));
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let menu = popup(&app).unwrap();
|
||||
assert!(
|
||||
menu.entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, Entry::Title(label) if label == "2 selected"))
|
||||
);
|
||||
let mut enabled = 0;
|
||||
let mut removals = 0;
|
||||
for entry in menu.entries {
|
||||
match entry {
|
||||
Entry::Action {
|
||||
label,
|
||||
message: Some(Message::Menu(Event::Invoke(_, message))),
|
||||
..
|
||||
} => {
|
||||
if let Message::ScenarioRemoval(event) = &*message {
|
||||
let crate::app::scenario_removal::Event::Begin {
|
||||
context,
|
||||
snapshot: captured,
|
||||
} = &**event
|
||||
else {
|
||||
panic!("Group menu must capture a fresh removal review");
|
||||
};
|
||||
assert_eq!(label, "Remove selected from Scenario…");
|
||||
assert_eq!(Some(*context), app.scenario_context());
|
||||
assert_eq!(captured.as_ref(), &snapshot);
|
||||
removals += 1;
|
||||
continue;
|
||||
}
|
||||
let Message::ScenarioCanvas(CanvasEvent::Selection(event)) = *message else {
|
||||
panic!("Scenario group menu exposed a singleton model action");
|
||||
};
|
||||
assert_eq!(event.context, app.scenario_context().unwrap());
|
||||
assert_eq!(event.snapshot, snapshot);
|
||||
let selection::Intent::Choose(actual_target, choice) = event.intent else {
|
||||
panic!("Unexpected group selection intent");
|
||||
};
|
||||
assert_eq!(actual_target, Target::ScenarioObject(target));
|
||||
assert!(matches!(
|
||||
(label.as_str(), choice),
|
||||
("Add to selection", Choice::Add)
|
||||
| ("Remove from selection", Choice::Remove)
|
||||
| ("Inspect this element", Choice::Inspect)
|
||||
| ("Select only this element", Choice::Replace)
|
||||
));
|
||||
enabled += 1;
|
||||
}
|
||||
Entry::Action {
|
||||
label,
|
||||
message: None,
|
||||
..
|
||||
} => {
|
||||
assert!(!label.contains("Hide"));
|
||||
}
|
||||
Entry::Action {
|
||||
message: Some(_), ..
|
||||
} => panic!("Uncaptured menu action"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(enabled, if target == first { 3 } else { 2 });
|
||||
assert_eq!(removals, 1, "One captured whole-selection removal action");
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(app.selected, second);
|
||||
}
|
||||
let selected_request = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(selected_request)));
|
||||
let inspect = action(&app, "Inspect this element");
|
||||
send(&mut app, inspect);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
let remove_request = request(&app, second);
|
||||
send(&mut app, Message::Menu(Event::Open(remove_request)));
|
||||
let remove = action(&app, "Remove from selection");
|
||||
send(&mut app, remove);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_snapshot_rejects_selection_aba_and_singleton_actions_stay_available() {
|
||||
let (mut app, [first, second, _], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let captured = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(captured)));
|
||||
let _ = action(&app, "Properties");
|
||||
let _ = action(&app, "Delete from model…");
|
||||
let opened = app.model_menu.take().unwrap();
|
||||
let context = app.scenario_context();
|
||||
// Isolate generation admission from the adapter's additional epoch defense.
|
||||
let selected = app.diagram_selection.as_mut().unwrap();
|
||||
selected.add(Target::ScenarioObject(second), |_| true);
|
||||
selected.remove(&Target::ScenarioObject(second));
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert_eq!(app.selected, first);
|
||||
assert!(!opened.valid(&app));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Definition-level Process/FunctionalChain effects of a complete removal candidate.
|
||||
//! Stored record differences and unchanged interpretation modes do not establish
|
||||
//! unchanged caller behavior. This bounded analysis does not expand reuse trees.
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
use oa_core::{Id, Kind, OccurrenceBody, Project, RelationKind, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DerivedEffects {
|
||||
pub(crate) interpretation: Vec<InterpretationChange>,
|
||||
/// Definitions whose records or authored topology changed, including deleted
|
||||
/// definitions. Values identify surviving direct and transitive callers in
|
||||
/// either project, even if their own stored records or modes did not change.
|
||||
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");
|
||||
// All-exchange steps exclude every non-exchange explicit marker, so only
|
||||
// local SequenceLinks remain to test (the desktop discriminator).
|
||||
// legacy_order alone loses the explicit-versus-empty distinction.
|
||||
let local_order = (!project.relations.values().any(|relation| {
|
||||
relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|element| element.parent)
|
||||
== Some(e.id)
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.target())
|
||||
.and_then(|element| element.parent)
|
||||
== Some(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 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(|_| {
|
||||
matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Control { .. }
|
||||
| OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
/// Analyze the actual accepted before/after projects and their complete record
|
||||
/// differences. Both graphs participate: deleting a definition can clear its
|
||||
/// surviving reference records, but must not erase their previous dependency.
|
||||
pub(crate) fn analyze(
|
||||
project: &Project,
|
||||
candidate: &Project,
|
||||
removed: &BTreeSet<Id>,
|
||||
changed: &BTreeSet<Id>,
|
||||
) -> DerivedEffects {
|
||||
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()
|
||||
})
|
||||
.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();
|
||||
DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 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(crate) struct Lifetime(Arc<AtomicU64>);
|
||||
|
||||
impl Lifetime {
|
||||
/// Keep a retained canvas's private gesture ownership visible to App even
|
||||
/// when its update returns early or emits no application message.
|
||||
pub(crate) fn sync(&self, lease: &mut Option<Lease>, busy: bool) {
|
||||
if !busy
|
||||
|| lease
|
||||
.as_ref()
|
||||
.is_some_and(|lease| !Arc::ptr_eq(&self.0, &lease.lifetime.0))
|
||||
{
|
||||
*lease = None;
|
||||
}
|
||||
if busy && lease.is_none() {
|
||||
*lease = self.acquire();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observe(&self, event: &iced::widget::canvas::Event) {
|
||||
use iced::{keyboard, mouse, widget::canvas, window};
|
||||
let boundary = matches!(
|
||||
event,
|
||||
canvas::Event::Window(window::Event::Unfocused | window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_)
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::CursorLeft
|
||||
)
|
||||
) || matches!(event, canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, .. })
|
||||
if *key != keyboard::Key::Named(keyboard::key::Named::Delete));
|
||||
if boundary {
|
||||
self.revoke_idle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Even generations are idle; odd generations belong to one live gesture.
|
||||
pub(crate) fn idle(&self) -> Option<u64> {
|
||||
let generation = self.0.load(Ordering::SeqCst);
|
||||
generation.is_multiple_of(2).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(crate) fn revoke_idle(&self) {
|
||||
if let Some(generation) = self.idle() {
|
||||
// Keep overflow terminal and unavailable rather than reusing zero.
|
||||
let next = generation.saturating_add(2);
|
||||
let _ = self
|
||||
.0
|
||||
.compare_exchange(generation, next, Ordering::SeqCst, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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(crate) 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_input_saturation_and_replaced_owner_cannot_reuse_a_packet() {
|
||||
let terminal = Lifetime(Arc::new(AtomicU64::new(u64::MAX - 1)));
|
||||
terminal.revoke_idle();
|
||||
assert!(terminal.idle().is_none() && terminal.acquire().is_none());
|
||||
let original = Lifetime::default();
|
||||
let replacement = Lifetime::default();
|
||||
let captured = original.idle();
|
||||
let mut lease = original.acquire();
|
||||
replacement.sync(&mut lease, true);
|
||||
assert_ne!(original.idle(), captured);
|
||||
assert!(replacement.idle().is_none());
|
||||
drop(lease);
|
||||
assert!(replacement.idle().is_some());
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,27 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) {
|
||||
if target.saved.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::saved_removal::view::view(app, target),
|
||||
700.,
|
||||
);
|
||||
}
|
||||
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,
|
||||
crate::app::scenario_removal::view::view(app, target),
|
||||
650.,
|
||||
);
|
||||
}
|
||||
return modal(
|
||||
root,
|
||||
column![
|
||||
@@ -468,6 +489,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
@@ -660,17 +682,25 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls.push(
|
||||
button(text("Delete from model…").size(12))
|
||||
.padding([6, 8])
|
||||
.on_press_maybe(
|
||||
crate::app::saved_removal::begin(app)
|
||||
.filter(|_| app.saved_removal_enabled()),
|
||||
),
|
||||
);
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
"Clear",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
"Edit only {}",
|
||||
short(app.project().named(app.selected), 12)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
@@ -2279,6 +2279,28 @@ transition row, uses confirmed relation deletion for either key. States and
|
||||
referenced Activity/Function definitions remain. Hidden or collapsed rows are
|
||||
not keyboard deletion targets.
|
||||
|
||||
For a group in a saved architecture or state view, plain Delete retains its
|
||||
placement-only Hide meaning; a mixture containing ports, edges or state rows
|
||||
MUST refuse Hide rather than silently delete model objects. Explicit group
|
||||
model deletion, including focused Shift+Delete, resolves each exact visible
|
||||
placement to its semantic element, each port to that port, each stored edge to
|
||||
its relation or trace, and each state row to its maintained relation. Navigation
|
||||
groupings and inferred edges are not semantic deletion subjects. Every original
|
||||
typed member MUST be admitted before redundant semantic subjects or cascade
|
||||
commands are reduced. A selected ancestor does not make a hidden or invalid
|
||||
member admissible. Existing project validity constraints, including placement
|
||||
uniqueness within a diagram, remain in force.
|
||||
|
||||
Saved-view aggregate review distinguishes selected visual targets, unique
|
||||
semantic subjects, actual deleted records and changed surviving records. Model
|
||||
deletion affects all placements of deleted elements in all views. A trace does
|
||||
not cause deletion of its counterpart endpoints; an explicitly selected
|
||||
counterpart remains an independent deletion subject. Removing a state action
|
||||
row deletes its maintained relation, not the referenced Activity or Function.
|
||||
The combined candidate and source update MUST form one atomic transaction;
|
||||
neither partial primary-only deletion nor intermediate singleton history is
|
||||
permitted.
|
||||
|
||||
Process and scenario projections have no independent occurrence placements.
|
||||
Delete and Shift+Delete request confirmed removal of the selected occurrence,
|
||||
participant or maintained link. A link's endpoints and outbound reusable
|
||||
@@ -2292,6 +2314,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; }; }; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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; }; }; }
|
||||
}
|
||||
@@ -62,9 +62,11 @@ command or source schema is unnecessary. Preview and commit must consume the
|
||||
same plan; tests independently construct expected geometry rather than merely
|
||||
comparing those two uses of the implementation.
|
||||
|
||||
Process and Scenario layouts are derived from semantic order, not free XY node
|
||||
placements. Their group drag must reorder a stable block while retaining its
|
||||
internal order and applicable owner/column/operand boundaries. Existing topology
|
||||
Process reorder and Scenario occurrence reorder change semantic sequence order.
|
||||
Saved Scenario presentations can additionally store lane/message coordinates;
|
||||
semantic group reordering preserves those authored records. A sequence group drag
|
||||
must reorder a stable block while retaining its internal order and applicable
|
||||
owner/column/operand boundaries. Existing topology
|
||||
and temporal/lifecycle validation remains authoritative. Qualified reused
|
||||
Process content must not silently edit the reusable definition. These adapters
|
||||
are required work for the issue, not waived because ordinary diagrams are easier.
|
||||
@@ -84,8 +86,11 @@ The [explicit Arrange amendment](allocation-arrange-amendment-20260923.md) defin
|
||||
the bounded one-or-more work/performer footprint and diagnostic collision domain.
|
||||
Its [pure planner evidence](allocation-arrange-planner-20260923.md) and the
|
||||
[ordinary assignment foundation](allocation-assignment-planner-20260923.md)
|
||||
establish bounded source/planner behavior. App/native group input acceptance
|
||||
remains pending.
|
||||
establish bounded source/planner behavior. The [Allocation App/native checkpoint](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/57aec979f640ba1b8aa6fcbb7d9e26b29cf51596/harness/allocation-group-app-20260924.md)
|
||||
adds bounded Windows group assignment, explicit Arrange and ordinary performer
|
||||
acceptance; its limitations and source identity remain explicit. The
|
||||
[Scenario reorder policy](scenario-group-reorder-policy-20260924.md) defines the
|
||||
strict sibling-block increment without implying adapter or removal acceptance.
|
||||
|
||||
## Related group actions
|
||||
|
||||
@@ -99,6 +104,20 @@ is refused with an explanation. Never act on only the primary object while the
|
||||
interface depicts a group. Alignment, distribution and duplication are separate
|
||||
capabilities to assess after this foundation; no speculative semantics are adopted.
|
||||
|
||||
The [Scenario removal policy](scenario-group-removal-policy-20260925.md) defines
|
||||
whole-set local admission, aggregate impact and one-step history. Its
|
||||
[Windows App/native evidence](scenario-group-removal-app-20260925.md) records the
|
||||
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. Its
|
||||
[Windows App/native evidence](process-group-removal-app-20260925.md) and
|
||||
[compact review follow-up](process-removal-compact-20260925.md) identify the
|
||||
tested source and bounded acceptance. Default-branch integration remains open;
|
||||
the policy alone does not establish implementation or runtime acceptance.
|
||||
|
||||
## Acceptance and dependencies
|
||||
|
||||
1. Prove simultaneous geometry planning with unrelated roots/internal routes,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Process aggregate removal — App implementation and verification, 2026-09-25 UTC
|
||||
|
||||
Local-only contribution for issue 29. Source `572e96ba03522cfb822b13fe4abb50e857a5de48` builds and runs on Windows. Process/FunctionalChain aggregate removal has source review, filtered regression checks and a native OA model/source/history/reopen receipt. The [compact follow-up](process-removal-compact-20260925.md) additionally verifies the bounded removal review at 1102x732. No integration, release or remote publication is claimed.
|
||||
|
||||
## Behavior and reviewed corrections
|
||||
|
||||
The [removal policy](process-group-removal-policy-20260925.md) defines this scope. Process/FunctionalChain removal captures the complete typed group of listed local contributions and maintained links, admits every original member, and evaluates one Batch. Occurrence cascade coverage suppresses redundant selected-link commands without hiding those selections from review. Qualified previews cause whole-set refusal; local ContextualLinks use explicit owner even with qualified endpoints. Actual before/after candidates determine removed identities and changed surviving records, including embedded placements. Referenced definitions, other uses and opposite-layer counterparts are preserved under existing core cascade rules.
|
||||
|
||||
Review separately reports stored impact, local listed order, expanded indexed order, contextual-analysis changes and surviving caller definitions. Caller impact includes ordinary explicit-to-explicit topology changes, and warns that reused entry/exit connectivity and diagnostics may change without creating stored identities. It does not claim complete expanded graph analysis or count repeated reference instances as separate caller definitions.
|
||||
|
||||
Source review found and corrected:
|
||||
|
||||
- P1: interpretation-only caller closure missed changed explicit topology. `affected_uses` now derives from actual changed definition topology, independently of interpretation deltas; literal OA/SA direct, repeated and transitive caller discriminators were added.
|
||||
- P2: empty local-order wording falsely denied surviving unlisted contributions. Review now says “Empty order” / “One item in this order,” retaining incomplete-flow qualifications and separate dimensions when they differ.
|
||||
- A same-input-batch queued Delete could precede a later private gesture while App admission lacked that evidence. Synchronous shared input generation and a gesture lease now revoke stale captures; mounted production-tree and lease lifecycle discriminators were added. The bounded source review found the specific race addressed, without claiming every viewport gesture is represented.
|
||||
- Independent planner review prompted literal tests for covered plus uncovered selected links and global attachment/trace/placement/diagram bookkeeping. Final source review at `fe637dcd277b17653139ffa234e601b29775c565` found no new actionable production finding in its bounded scope. Its later menu-correction addendum likewise found no actionable issue. These reviews did not execute runtime/native gates.
|
||||
|
||||
Exact historical review notes: [policy critique](process-group-removal-evidence-20260925/policy-critique.md), [planner](process-group-removal-evidence-20260925/planner-review.md), [adapter](process-group-removal-evidence-20260925/adapter-review.md), and [final review/addendum](process-group-removal-evidence-20260925/final-review.md). Their original source/runtime boundaries remain intact; source-only conclusions are not retroactively represented as executed tests.
|
||||
|
||||
## Closed failed gate history
|
||||
|
||||
Both runs used Windows `stable-x86_64-pc-windows-gnullvm`, Cargo/rustc 1.98.1, one build job, incremental compilation disabled, and:
|
||||
|
||||
```text
|
||||
cargo test -p oa-desktop --message-format=json -- process_ topology scenario_removal removal --test-threads=1
|
||||
```
|
||||
|
||||
The identified local closed receipts for `process-removal-tests-1` and `process-removal-tests-2` were inspected with their exact source proofs and logs. Raw evidence publication is pending; these local files are not a peer-accessible handoff. Both closed with exit 101 and unchanged captured inputs. Each separate source proof passed exact captured inventory/Git-blob comparison; that proof does not change the failed test outcome. Neither command is the full suite (1469 tests filtered out).
|
||||
|
||||
1. **Gate 1**, source `fe637dcd277b17653139ffa234e601b29775c565`, finished 03:12:51 UTC: **185 passed, 2 failed**. The existing qualified-definition navigation regression exposed a real menu omission. Correction `f20497b29bf637619bc35ffe41274fc0f66c387b` restores qualified navigation before the aggregate menu early return, preserving exact endpoint/context and whole-group removal. The other failure was the newly authored v1 lexical oracle: complete semantic Project equality passed, then exact Document source equality failed. Independent source-policy investigation derived retained declaration trivia (22/20/16 spaces and existing annotation LF boundaries) and unformatted surviving `[@e1,@e2]`. V2 was derived from the checksum-pinned baseline before separate comparison with observed output; it matched 2258 bytes, SHA256 `b4a2b07ad4c796f555c61945ca42b1c221b454b35488c60fefc86f96f7aa71ba`. V1 and failed evidence remain preserved. This was an incorrect lexical assumption, not evidence of a semantic deletion defect or a new writer regression. See the [exact source-oracle investigation](process-group-removal-evidence-20260925/source-oracle-investigation.md).
|
||||
2. **Gate 2**, source `f20497b29bf637619bc35ffe41274fc0f66c387b`, finished 03:20:16 UTC: **186 passed, 1 failed**. Qualified-navigation regression passed. In the literal test, complete Project and exact Document/v2 source assertions passed; the later `app.source_workspace.bundle()` equality failed. This is distinct from v1: the fixture helper replaced Document after `open_project` but retained its generated split workspace. `sync` preserves discovered files; it is not a new-document initializer. Commit `d93c0a801249f361d95721fb0fc239a95159af5c` initializes `Workspace::new(app.document.bundle(), true)` before sync and asserts baseline workspace equality. No production writer change or weakened source equality was needed. Undo/Redo assertions later in this failing test were not reached.
|
||||
|
||||
|
||||
## Closed correction and final-source gates
|
||||
|
||||
All gates below closed with unchanged captured inputs and a separate PASS proving exact input inventory/Git-blob bytes. The result/input-map hashes were checked against those proofs. A source-proof PASS means source binding; it does not turn a failed gate into a pass.
|
||||
|
||||
| Gate | Identified source | Result |
|
||||
| --- | --- | --- |
|
||||
| tests-3 | d93c0a801249f361d95721fb0fc239a95159af5c | 187 passed, 0 failed; 1469 filtered out |
|
||||
| clippy-1 | d93c0a801249f361d95721fb0fc239a95159af5c | Exit 101: two style lints under warnings-as-errors |
|
||||
| tests-4 | 572e96ba03522cfb822b13fe4abb50e857a5de48 | 27 passed, 0 failed; 1629 filtered out |
|
||||
| format-3 | 572e96ba03522cfb822b13fe4abb50e857a5de48 | PASS, exit 0 |
|
||||
| clippy-2 | 572e96ba03522cfb822b13fe4abb50e857a5de48 | PASS, exit 0 |
|
||||
| build-1 | 572e96ba03522cfb822b13fe4abb50e857a5de48 | Studio and CLI PASS, exit 0 |
|
||||
|
||||
Gate names have the prefix `process-removal-`. Tests-3 used the broad filtered test command above and passed the literal model/source/one-Undo/Redo case after the fixture initialization correction. Clippy-1 identified manual parity and manual saturating arithmetic in the input lifetime helper. Commit `572e96ba03522cfb822b13fe4abb50e857a5de48` replaces `generation % 2 == 0` with `generation.is_multiple_of(2)`, and `checked_add(2).unwrap_or(u64::MAX)` with `saturating_add(2)`; these retain the same unsigned-integer and terminal-overflow semantics. Tests-4 reran the affected Process removal, input generation and gesture coverage on that final source. The broad 187-test result belongs to d93c0a80, not a rerun of all 187 on 572e96ba.
|
||||
|
||||
Final-source commands:
|
||||
|
||||
```text
|
||||
cargo test -p oa-desktop --message-format=json -- process_removal removal_input group_gesture --test-threads=1
|
||||
cargo fmt --all --check
|
||||
cargo clippy -p oa-desktop --all-targets -- -D warnings
|
||||
cargo build -p oa-desktop -p oa-cli --message-format=json
|
||||
```
|
||||
|
||||
Runtime results are filtered Windows evidence, not a full-suite or Linux claim. Both passing test runs include the literal aggregate removal model/source history oracle and mounted queued-Delete/camera-boundary discriminator.
|
||||
|
||||
Receipt SHA-256 anchors (result.json; raw package publication pending):
|
||||
|
||||
| Gate | SHA-256 |
|
||||
| --- | --- |
|
||||
| tests-3 | 82709fef9b1aabdf2fcc07a7cb9300d16474bc62370d7157e6bb6270cd8264f8 |
|
||||
| tests-4 | 8513d24b9f05645908f4a475fcc90b5887362473f8046282bfd8af6a24c61dd5 |
|
||||
| format-3 | 7ec891fa7a881b4f04fc3f58047a15f3972b397b7d2595e4a0bb04a9f658d9a3 |
|
||||
| clippy-1 | 35e1afdd586853dbd992b3b944045e479297bf6dc2bf33db5186be0a51c3e41f |
|
||||
| clippy-2 | 12f3352607a1608fcd1242b48336ae877b81677df38d96d17bbe9dc6ed3ff49b |
|
||||
|
||||
## Native verification and portable records
|
||||
|
||||
The closed build has 832 unchanged captured inputs and an exact Git-blob source proof. Its copied Studio binary and explicitly pinned libunwind.dll launch with only Windows system directories on PATH. Current CLI preflight accepts both oracle-v2 files with warning-only model diagnostics. Run 1 removes Action @a, Control @c (both named Review gate) and their selected authored link @approved. The review distinguishes them by alias and exposes all ten effects through scrolling and two pages: three selected removals, one updated owner, before/after implicit-order consequences, the two remaining exchange positions and direct/transitive caller definitions. Search finds @outer; Enter does not confirm; empty search supplies recovery guidance. Cancel and Escape retain the full group and link primary. Header, context menu over an unselected object, focused Delete and focused Shift+Delete all open the complete review.
|
||||
|
||||
After restoring/reactivating the window, the captured review is expired and Remove is disabled; that native sequence does not isolate resizing from focus changes. Explicit confirmation of a fresh review leaves two exchange cards and their implicit connection. Save produces exactly 2258 oracle bytes; one Undo and Save restores all 2609 baseline bytes; one Redo and Save reproduces all 2258 removed bytes. Run 2 starts a fresh process from the saved Redo file, displays the same two exchanges/implicit connection and saves identical bytes. Both processes exit normally with empty logs and unchanged executable, runtime and fixture pins. These exact bytes preserve underlying work/exchanges, caller definitions/references and SA sentinels.
|
||||
|
||||
The [native action record](process-group-removal-evidence-20260925/native-actions.md) identifies capture labels, actual keys, source snapshots and automation failures. Captures are observations, not an instrumented input log. Membership used context Add, not native Shift-click. After Escape, Shift+Delete required F6 to restore canvas focus. A restored-window capture initially failed as minimized and recovered through fresh window selection/reactivation; a later out-of-bounds positioning drag was rejected before capture. These are recorded separately from application behavior. True compact 1100px layout is unverified; the restored view was partly clipped by the desktop. Maximum-window review reachability is established at 1605x872.
|
||||
|
||||
Portable [Cargo records and offline verifier](process-group-removal-evidence-20260925/checks/README.md) preserve all ten attempts, including three failures, with four distinct source input capsules. [Native archive](process-group-removal-evidence-20260925/native/native-evidence.zip) and [standalone verifier](process-group-removal-evidence-20260925/native/verify.py) preserve original v1, separately derived v2, 53 raw capture records, exact saved snapshots and closed process receipts. Both standalone verifiers pass. Native ZIP: 5,617,713 bytes, SHA256 `f77fd3abe7ecc5838395b94a95ba935c8dc5c10a36c8e3a2c1ec4a1da1521b84`, 327 members, six omitted executable/DLL payloads represented by pins. These evidence archives are not runnable releases.
|
||||
|
||||
Cargo ZIP: 13,162,535 bytes, SHA256 `13aeacd8b1326ec5d57d49a13e8fd0c2e8611f85da1244cbcb1362edcb6fd4d5`. The [independent native review](process-group-removal-evidence-20260925/native-review.md) found no blocking issue within the bounded flow, recomputed both runs' immutable file pins and checked all saved-source results. It preserves the distinction between parent-reported actions and captured states, and leaves compact layout and accessibility unaccepted. Exact source comparison and archive integrity do not substitute for this visual review. Recheck records offline with Python 3.11+:
|
||||
|
||||
```text
|
||||
python -B harness/process-group-removal-evidence-20260925/checks/verify.py
|
||||
python -B harness/process-group-removal-evidence-20260925/native/verify.py harness/process-group-removal-evidence-20260925/native
|
||||
```
|
||||
|
||||
## Remaining scope and publication
|
||||
|
||||
This original native receipt covers OA derived layout and the bounded fixture. SA/qualified paths, cascades, stale input races and command-failure preservation have separate filtered-test evidence. The [follow-up](process-removal-compact-20260925.md) closes the compact review gap for this fixture. Actual Process source-budget failure, native Shift-click, comprehensive accessibility, full-suite and Linux acceptance remain unclaimed. Static investigation found no valid source-growth case for the admitted Process removal; see the [evaluator boundary](process-group-removal-evidence-20260925/source-failure-investigation.md). This is not a formal monotonicity proof or actual Process source-write-failure coverage. Preserve these limits when integrating; no issue closure is justified by this branch alone.
|
||||
|
||||
Fresh guarded push was rejected with `remote unpack failed: unable to create temporary object directory`; a Gitea MCP issue-comment write also failed. Read operations work, main AGENTS remains blob e76647f49913b314e1543c4464f6199533909bae, and the remote contribution branch remains 43340b94. WSL DNS still timed out after ten seconds; Linux builds remain explicitly user-paused. These are observed failed attempts, not a newly verified disk-full root cause. Pending Scenario follow-up and this Process contribution remain local-only. Reconcile and publish the buffered issue-29 handoff when writes recover; do not overwrite remote history. Historical local paths inside copied reviews identify their original context, not peer-accessible deliverables.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Independent Process removal adapter source review
|
||||
|
||||
Read-only inspection of uncommitted production adapter surfaces on base
|
||||
`c62622cce71e06f0beae49bc3b4fe8e890eba951`. Inspected `app/process_removal.rs`,
|
||||
its `view.rs`, and the diffs/context in `app.rs`, `app/diagram_removal.rs`,
|
||||
`app/escape.rs`, `model_menu.rs`, `views.rs`, `behavior/topology/view.rs` and
|
||||
`behavior/topology/direct_selection.rs`. Adapter tests were still being authored;
|
||||
no Cargo/UI execution and no production edits. This is not native acceptance.
|
||||
|
||||
## P2: local empty-order wording can deny surviving unlisted contributions
|
||||
|
||||
At inspection, `matching_rows` rendered `Some([])` as “No remaining
|
||||
contributions; no ordered pair” for both order dimensions; `Some([E])` rendered
|
||||
“One contribution; no ordered pair.” This is false when describing local listed
|
||||
order in the supported owned-but-unlisted case.
|
||||
|
||||
Literal discriminator: owner P lists Action A and owns unlisted Action U. Remove
|
||||
A. U survives; local order changes from explicit to `Some([])`, expanded order
|
||||
stays explicit. The review must not tell the user that no contributions remain.
|
||||
Use “No listed contributions” for the local dimension, “No indexed
|
||||
contributions” for the expanded dimension, or dimension-neutral “Empty order.”
|
||||
Similarly qualify “One contribution.” This is a wording defect introduced by
|
||||
the new review, not a request to repair preexisting desktop/core semantics.
|
||||
Reported to parent for the adapter owner.
|
||||
|
||||
## P2 candidate: same input-batch newer gesture is invisible to App admission
|
||||
|
||||
`process_removal_enabled` checks modal/draft/tool state but has no Process
|
||||
gesture token. `direct_selection::selection_input` correctly captures Delete
|
||||
when `Input.group` already exists, and blocks ordinary press/source gestures.
|
||||
However starting a group press sets only private canvas `Input.group` and
|
||||
publishes a redraw, leaving the App context and selection generation unchanged.
|
||||
|
||||
A source-supported event ordering to test, instead of arbitrary packet replay:
|
||||
|
||||
1. Production tree has local A/B selected and a cursor over selected A.
|
||||
2. The same UI event batch receives Delete, then LeftPressed over A.
|
||||
3. Delete publishes a captured removal Begin. LeftPressed starts private
|
||||
`Input.group` with the same context/snapshot and publishes redraw only.
|
||||
4. App consumes Begin after the UI batch; admission has no evidence of that
|
||||
newer gesture and opens review, increments epoch and retires the gesture.
|
||||
|
||||
Source evidence: `direct_selection.rs:209–229` publishes Begin;
|
||||
`:316–334` creates private GroupPress without an App gesture message;
|
||||
`app/process_removal.rs:51–92` admits using App state alone.
|
||||
`app/escape.rs` explicitly documents Iced's batching behavior, and existing
|
||||
production `Mounted.batch` topology tests exercise multiple input events before
|
||||
App messages are applied. This is a concrete plausible production-event path,
|
||||
but this reviewer did not execute it. Parent was asked to run the literal batch
|
||||
discriminator before treating it as a reproduced defect.
|
||||
|
||||
This can violate the policy's “active gestures refuse admission” promise, but
|
||||
does not silently delete: an explicit current review confirmation is still
|
||||
required. It therefore does not justify P1/data-loss severity from current
|
||||
evidence. The successful admission represents an earlier Delete followed by a
|
||||
later gesture in one batch; the project should either test/enforce the promised
|
||||
lifetime ordering or state a narrower supported guarantee. Do not claim the App
|
||||
proves absence of every private canvas gesture.
|
||||
|
||||
## Verified source properties
|
||||
|
||||
- Whole-group admission happens before shared source/canvas boundaries.
|
||||
`process_removal_matches` validates exact topology context, selection
|
||||
generation/membership/primary and actual graph target visibility. The planner
|
||||
subsequently refuses mixed local/qualified content as a whole.
|
||||
- Header and focused Delete/Shift+Delete capture complete snapshots. Keyboard
|
||||
repeat and unsupported modifiers do not issue deletion. Active GroupPress
|
||||
captures keyboard input before Delete can reach removal.
|
||||
- Process context-menu requests retain selection. Menu invocation accepts the
|
||||
new group message before ordinary clicked-object `select`, so a menu opened
|
||||
over an unselected object does not silently replace the group. Menu validity
|
||||
now also captures the Process selection snapshot.
|
||||
- Legacy `Message::Delete` and singleton TopologyCanvas Remove packets are
|
||||
refused while a group exists before shared state is disturbed. Qualified
|
||||
singleton previews remain read-only. A direct arbitrary Command injection is
|
||||
not an established production removal entry point and was not presented as a
|
||||
user-reachable bypass.
|
||||
- Query/Page/Cancel packets are token scoped; ConfirmDelete is checked before
|
||||
forwarding. Unstamped CancelDelete/Escape cannot close a later Process review.
|
||||
Root Escape emits the captured current review's token before focused search
|
||||
receives it. Search has no Enter-submit confirmation handler.
|
||||
- Confirmation checks current document/revision/owner/epoch, exact selection,
|
||||
actual visibility, primary inspector identity, pending source/property/
|
||||
behavior drafts, source rename and Select tool. It executes one captured
|
||||
Document command. Failed source preparation remains the Document layer's
|
||||
responsibility; this source review does not independently establish its
|
||||
runtime preservation behavior.
|
||||
- Initial enabled gates include hosted/workflow/file/pending/menu/usages/
|
||||
reference-input states via `keyboard_owner_enabled`. Existing modal dispatch
|
||||
prevents ordinary new modal actions while review is open. Confirm's local
|
||||
guard does not re-enumerate every other modal, but no concrete production path
|
||||
establishing an intervening competing modal was found. Do not promote a
|
||||
manually mutated App test alone into a claimed user-reachable failure.
|
||||
- Review distinguishes actual removed/updated records, local/expanded order,
|
||||
contextual-analysis changes and caller definitions. Affected-caller rows merge
|
||||
the prior P1 correction with interpretation caller closure. Repeated reference
|
||||
instances are not falsely counted as separate caller rows. The explanatory
|
||||
text acknowledges possible reused entry/exit changes and disclaims complete
|
||||
expanded graph analysis.
|
||||
- All impact rows are searchable/pageable with full names and aliases. Long
|
||||
text wraps. Footer is separate from the scrolling body. These structural
|
||||
choices support usability but do not establish native layout/focus behavior.
|
||||
|
||||
## Remaining acceptance work
|
||||
|
||||
Resolve or narrow the two items above. Run App and actual production-tree tests
|
||||
for stale/ABA snapshots, menu selection preservation, wrong/old tokens,
|
||||
source-write failure, exact cancel/Undo/Redo and keyboard focus. Inspect compact
|
||||
native modal layout, every impact category and search/page access, including a
|
||||
long implicit order and multiple caller definitions. Validate save/reopen on the
|
||||
identified tested source. No additional P1 production defect was found in this
|
||||
bounded review.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Process aggregate removal: captured checks
|
||||
|
||||
Python 3.11+ offline packaging/verification for explicitly named closed
|
||||
`process-removal-*` runs under `CHECKOUT/.local/scenario-group-gates`.
|
||||
These tools reuse the Scenario removal verifier unchanged except for the
|
||||
manifest kind and run-name prefix. They do not run Cargo, Git, network requests
|
||||
or archived code. Input capsules are deduplicated by hash; every original
|
||||
logical path and failed attempt remains in the manifest.
|
||||
|
||||
```text
|
||||
python -B prepare.py --root CHECKOUT --runs CLOSED_RUN_NAMES --output NEW_DIRECTORY
|
||||
python -B NEW_DIRECTORY/verify.py
|
||||
```
|
||||
|
||||
Supply the complete explicit attempt inventory, including failures. Existing
|
||||
output directories, incomplete runs, changed-input runs and missing source
|
||||
proofs are refused. Verification checks exact outer/raw/nested ZIP inventories
|
||||
and hashes, safe paths, command/process/time consistency, dynamic input counts,
|
||||
unchanged captures, executable Cargo-record relationships, source-proof bindings
|
||||
and pinned comparator provenance. All 71 tracked patched-widget files must be
|
||||
present. Failed exits remain failures even when archive verification passes.
|
||||
|
||||
An offline proof check does not execute the comparator or read Git objects.
|
||||
The parent separately compares all captured inputs to the named Git commit.
|
||||
Executables are omitted; only their pins and Cargo metadata are retained.
|
||||
Neither package integrity nor compilation establishes native usability,
|
||||
integration, release acceptance or completion of the application.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 6319,
|
||||
"sha256": "3d82c4784109d41feb969c94e406358e42c19d08e56952b04df768fef2a3d3f0"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 11528,
|
||||
"sha256": "d9fd0f14d5a034acf82830b0bf66577be72e9002693f5b7397b3fefdf921a047"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 1517,
|
||||
"sha256": "d1d3087d55d54ad9afa8d451166ddbae61c846396c8d81381c0f27a5f39e6d3c"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 28845,
|
||||
"sha256": "91455965138d6dfa19d8359da6114ed7ece995390d8026b1a944cd473058c34c"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 13162535,
|
||||
"sha256": "13aeacd8b1326ec5d57d49a13e8fd0c2e8611f85da1244cbcb1362edcb6fd4d5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kind": "process-aggregate-removal-cargo-gates",
|
||||
"runs": {
|
||||
"process-removal-tests-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"process_",
|
||||
"topology",
|
||||
"scenario_removal",
|
||||
"removal",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"exitCode": 101,
|
||||
"inputCount": 831,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"pin": {
|
||||
"bytes": 602,
|
||||
"sha256": "4074db859ff385c14941ba8e45530a7e748e0db45101339d881937663c0ef512"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-tests-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"process_",
|
||||
"topology",
|
||||
"scenario_removal",
|
||||
"removal",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"exitCode": 101,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"pin": {
|
||||
"bytes": 602,
|
||||
"sha256": "4ab508f91efc34fde8cc1fc99fcc174d22f9eaf141730f608582b0067007eb56"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-tests-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"process_",
|
||||
"topology",
|
||||
"scenario_removal",
|
||||
"removal",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"pin": {
|
||||
"bytes": 602,
|
||||
"sha256": "0407b22338c2d7bf725c8fe7098515a484cf67067578b4bf4992fa0a49bd3f93"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-tests-4": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--message-format=json",
|
||||
"--",
|
||||
"process_removal",
|
||||
"removal_input",
|
||||
"group_gesture",
|
||||
"--test-threads=1"
|
||||
],
|
||||
"head": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"pin": {
|
||||
"bytes": 602,
|
||||
"sha256": "ff045bf753ec4e09abc980018dd90f38338889dcd0877200364f5460418df5c6"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-format-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--",
|
||||
"--check"
|
||||
],
|
||||
"head": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"exitCode": 0,
|
||||
"inputCount": 831,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"pin": {
|
||||
"bytes": 603,
|
||||
"sha256": "d36d50d20b92c49c0867d1468ed9d8062e23aa8cda9a995ad8bd91938e337335"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-format-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--",
|
||||
"--check"
|
||||
],
|
||||
"head": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"pin": {
|
||||
"bytes": 603,
|
||||
"sha256": "6f6a756569f3877223932253db0cea5380d646dd221f467c69b3572289d9a2fc"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-format-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--check"
|
||||
],
|
||||
"head": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"pin": {
|
||||
"bytes": 603,
|
||||
"sha256": "05d86dd797bf3c1a0d9c8b0c00a49294009ead98cb9e9c3e258d0cb59e9331b1"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-clippy-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"exitCode": 101,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"pin": {
|
||||
"bytes": 603,
|
||||
"sha256": "a758a8df8dbc2aa86b907856b69ca74f8219a9b462a83d690f06929ba3cc1797"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-clippy-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"pin": {
|
||||
"bytes": 603,
|
||||
"sha256": "d27fd807235a480aa0fe76f3ceefddbdc1756cc1782727829be99f86b00d7550"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
},
|
||||
"process-removal-build-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"-p",
|
||||
"oa-cli",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0,
|
||||
"inputCount": 832,
|
||||
"helper": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"sourceProof": {
|
||||
"commit": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"pin": {
|
||||
"bytes": 602,
|
||||
"sha256": "3c40d37ad31dc3f23e2c3f57fa0ab780f8b7891f491f9f5382948a625e8ecf84"
|
||||
},
|
||||
"comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py"
|
||||
}
|
||||
}
|
||||
},
|
||||
"captureHelpers": {
|
||||
"gate.py": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"source-proof.py": "helpers/c86f51d9b8465341dab73d045923ec306b15ebabf7c97b81beac7afe73bd72a1.py"
|
||||
},
|
||||
"logicalFiles": {
|
||||
"cargo/process-removal-build-1/inputs.json": "capsules/df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb.json",
|
||||
"cargo/process-removal-build-1/inputs.zip": "capsules/dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b.zip",
|
||||
"cargo/process-removal-build-1/process.json": "cargo/process-removal-build-1/process.json",
|
||||
"cargo/process-removal-build-1/result.json": "cargo/process-removal-build-1/result.json",
|
||||
"cargo/process-removal-build-1/source-proof.json": "cargo/process-removal-build-1/source-proof.json",
|
||||
"cargo/process-removal-build-1/started.json": "cargo/process-removal-build-1/started.json",
|
||||
"cargo/process-removal-build-1/stderr.log": "cargo/process-removal-build-1/stderr.log",
|
||||
"cargo/process-removal-build-1/stdout.log": "cargo/process-removal-build-1/stdout.log",
|
||||
"cargo/process-removal-clippy-1/inputs.json": "capsules/f199e1a7a58f7d9093a2cb9da288120c35bd87c650bb971ecad3c9d964d1c76a.json",
|
||||
"cargo/process-removal-clippy-1/inputs.zip": "capsules/b2504689cfe1a461949673ba5ed67323571aa70ee7965230f980c20cafd317ba.zip",
|
||||
"cargo/process-removal-clippy-1/process.json": "cargo/process-removal-clippy-1/process.json",
|
||||
"cargo/process-removal-clippy-1/result.json": "cargo/process-removal-clippy-1/result.json",
|
||||
"cargo/process-removal-clippy-1/source-proof.json": "cargo/process-removal-clippy-1/source-proof.json",
|
||||
"cargo/process-removal-clippy-1/started.json": "cargo/process-removal-clippy-1/started.json",
|
||||
"cargo/process-removal-clippy-1/stderr.log": "cargo/process-removal-clippy-1/stderr.log",
|
||||
"cargo/process-removal-clippy-1/stdout.log": "cargo/process-removal-clippy-1/stdout.log",
|
||||
"cargo/process-removal-clippy-2/inputs.json": "capsules/df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb.json",
|
||||
"cargo/process-removal-clippy-2/inputs.zip": "capsules/dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b.zip",
|
||||
"cargo/process-removal-clippy-2/process.json": "cargo/process-removal-clippy-2/process.json",
|
||||
"cargo/process-removal-clippy-2/result.json": "cargo/process-removal-clippy-2/result.json",
|
||||
"cargo/process-removal-clippy-2/source-proof.json": "cargo/process-removal-clippy-2/source-proof.json",
|
||||
"cargo/process-removal-clippy-2/started.json": "cargo/process-removal-clippy-2/started.json",
|
||||
"cargo/process-removal-clippy-2/stderr.log": "cargo/process-removal-clippy-2/stderr.log",
|
||||
"cargo/process-removal-clippy-2/stdout.log": "cargo/process-removal-clippy-2/stdout.log",
|
||||
"cargo/process-removal-format-1/inputs.json": "capsules/b09d4d557af5882c4d23c546394da9f1ff7aa0d6ac3de6f509e923d96c48f929.json",
|
||||
"cargo/process-removal-format-1/inputs.zip": "capsules/6e49c576b61f844341d6e84e4da44eea24045f6064467a9cf060646289a706ea.zip",
|
||||
"cargo/process-removal-format-1/process.json": "cargo/process-removal-format-1/process.json",
|
||||
"cargo/process-removal-format-1/result.json": "cargo/process-removal-format-1/result.json",
|
||||
"cargo/process-removal-format-1/source-proof.json": "cargo/process-removal-format-1/source-proof.json",
|
||||
"cargo/process-removal-format-1/started.json": "cargo/process-removal-format-1/started.json",
|
||||
"cargo/process-removal-format-1/stderr.log": "cargo/process-removal-format-1/stderr.log",
|
||||
"cargo/process-removal-format-1/stdout.log": "cargo/process-removal-format-1/stdout.log",
|
||||
"cargo/process-removal-format-2/inputs.json": "capsules/46a4532cb0bccafe9260df88d02781b29bf979c3277a45cad261852a2cfaf998.json",
|
||||
"cargo/process-removal-format-2/inputs.zip": "capsules/34d335f010637739cbb763f1436f4c640a2c472051c30e2a5a6beb39a2ed7e78.zip",
|
||||
"cargo/process-removal-format-2/process.json": "cargo/process-removal-format-2/process.json",
|
||||
"cargo/process-removal-format-2/result.json": "cargo/process-removal-format-2/result.json",
|
||||
"cargo/process-removal-format-2/source-proof.json": "cargo/process-removal-format-2/source-proof.json",
|
||||
"cargo/process-removal-format-2/started.json": "cargo/process-removal-format-2/started.json",
|
||||
"cargo/process-removal-format-2/stderr.log": "cargo/process-removal-format-2/stderr.log",
|
||||
"cargo/process-removal-format-2/stdout.log": "cargo/process-removal-format-2/stdout.log",
|
||||
"cargo/process-removal-format-3/inputs.json": "capsules/df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb.json",
|
||||
"cargo/process-removal-format-3/inputs.zip": "capsules/dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b.zip",
|
||||
"cargo/process-removal-format-3/process.json": "cargo/process-removal-format-3/process.json",
|
||||
"cargo/process-removal-format-3/result.json": "cargo/process-removal-format-3/result.json",
|
||||
"cargo/process-removal-format-3/source-proof.json": "cargo/process-removal-format-3/source-proof.json",
|
||||
"cargo/process-removal-format-3/started.json": "cargo/process-removal-format-3/started.json",
|
||||
"cargo/process-removal-format-3/stderr.log": "cargo/process-removal-format-3/stderr.log",
|
||||
"cargo/process-removal-format-3/stdout.log": "cargo/process-removal-format-3/stdout.log",
|
||||
"cargo/process-removal-tests-1/inputs.json": "capsules/b09d4d557af5882c4d23c546394da9f1ff7aa0d6ac3de6f509e923d96c48f929.json",
|
||||
"cargo/process-removal-tests-1/inputs.zip": "capsules/6e49c576b61f844341d6e84e4da44eea24045f6064467a9cf060646289a706ea.zip",
|
||||
"cargo/process-removal-tests-1/process.json": "cargo/process-removal-tests-1/process.json",
|
||||
"cargo/process-removal-tests-1/result.json": "cargo/process-removal-tests-1/result.json",
|
||||
"cargo/process-removal-tests-1/source-proof.json": "cargo/process-removal-tests-1/source-proof.json",
|
||||
"cargo/process-removal-tests-1/started.json": "cargo/process-removal-tests-1/started.json",
|
||||
"cargo/process-removal-tests-1/stderr.log": "cargo/process-removal-tests-1/stderr.log",
|
||||
"cargo/process-removal-tests-1/stdout.log": "cargo/process-removal-tests-1/stdout.log",
|
||||
"cargo/process-removal-tests-2/inputs.json": "capsules/46a4532cb0bccafe9260df88d02781b29bf979c3277a45cad261852a2cfaf998.json",
|
||||
"cargo/process-removal-tests-2/inputs.zip": "capsules/34d335f010637739cbb763f1436f4c640a2c472051c30e2a5a6beb39a2ed7e78.zip",
|
||||
"cargo/process-removal-tests-2/process.json": "cargo/process-removal-tests-2/process.json",
|
||||
"cargo/process-removal-tests-2/result.json": "cargo/process-removal-tests-2/result.json",
|
||||
"cargo/process-removal-tests-2/source-proof.json": "cargo/process-removal-tests-2/source-proof.json",
|
||||
"cargo/process-removal-tests-2/started.json": "cargo/process-removal-tests-2/started.json",
|
||||
"cargo/process-removal-tests-2/stderr.log": "cargo/process-removal-tests-2/stderr.log",
|
||||
"cargo/process-removal-tests-2/stdout.log": "cargo/process-removal-tests-2/stdout.log",
|
||||
"cargo/process-removal-tests-3/inputs.json": "capsules/f199e1a7a58f7d9093a2cb9da288120c35bd87c650bb971ecad3c9d964d1c76a.json",
|
||||
"cargo/process-removal-tests-3/inputs.zip": "capsules/b2504689cfe1a461949673ba5ed67323571aa70ee7965230f980c20cafd317ba.zip",
|
||||
"cargo/process-removal-tests-3/process.json": "cargo/process-removal-tests-3/process.json",
|
||||
"cargo/process-removal-tests-3/result.json": "cargo/process-removal-tests-3/result.json",
|
||||
"cargo/process-removal-tests-3/source-proof.json": "cargo/process-removal-tests-3/source-proof.json",
|
||||
"cargo/process-removal-tests-3/started.json": "cargo/process-removal-tests-3/started.json",
|
||||
"cargo/process-removal-tests-3/stderr.log": "cargo/process-removal-tests-3/stderr.log",
|
||||
"cargo/process-removal-tests-3/stdout.log": "cargo/process-removal-tests-3/stdout.log",
|
||||
"cargo/process-removal-tests-4/inputs.json": "capsules/df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb.json",
|
||||
"cargo/process-removal-tests-4/inputs.zip": "capsules/dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b.zip",
|
||||
"cargo/process-removal-tests-4/process.json": "cargo/process-removal-tests-4/process.json",
|
||||
"cargo/process-removal-tests-4/result.json": "cargo/process-removal-tests-4/result.json",
|
||||
"cargo/process-removal-tests-4/source-proof.json": "cargo/process-removal-tests-4/source-proof.json",
|
||||
"cargo/process-removal-tests-4/started.json": "cargo/process-removal-tests-4/started.json",
|
||||
"cargo/process-removal-tests-4/stderr.log": "cargo/process-removal-tests-4/stderr.log",
|
||||
"cargo/process-removal-tests-4/stdout.log": "cargo/process-removal-tests-4/stdout.log",
|
||||
"comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py",
|
||||
"helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py": "helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py",
|
||||
"helpers/c86f51d9b8465341dab73d045923ec306b15ebabf7c97b81beac7afe73bd72a1.py": "helpers/c86f51d9b8465341dab73d045923ec306b15ebabf7c97b81beac7afe73bd72a1.py"
|
||||
},
|
||||
"members": {
|
||||
"capsules/34d335f010637739cbb763f1436f4c640a2c472051c30e2a5a6beb39a2ed7e78.zip": {
|
||||
"bytes": 3317469,
|
||||
"sha256": "34d335f010637739cbb763f1436f4c640a2c472051c30e2a5a6beb39a2ed7e78"
|
||||
},
|
||||
"capsules/46a4532cb0bccafe9260df88d02781b29bf979c3277a45cad261852a2cfaf998.json": {
|
||||
"bytes": 139818,
|
||||
"sha256": "46a4532cb0bccafe9260df88d02781b29bf979c3277a45cad261852a2cfaf998"
|
||||
},
|
||||
"capsules/6e49c576b61f844341d6e84e4da44eea24045f6064467a9cf060646289a706ea.zip": {
|
||||
"bytes": 3316503,
|
||||
"sha256": "6e49c576b61f844341d6e84e4da44eea24045f6064467a9cf060646289a706ea"
|
||||
},
|
||||
"capsules/b09d4d557af5882c4d23c546394da9f1ff7aa0d6ac3de6f509e923d96c48f929.json": {
|
||||
"bytes": 139641,
|
||||
"sha256": "b09d4d557af5882c4d23c546394da9f1ff7aa0d6ac3de6f509e923d96c48f929"
|
||||
},
|
||||
"capsules/b2504689cfe1a461949673ba5ed67323571aa70ee7965230f980c20cafd317ba.zip": {
|
||||
"bytes": 3317568,
|
||||
"sha256": "b2504689cfe1a461949673ba5ed67323571aa70ee7965230f980c20cafd317ba"
|
||||
},
|
||||
"capsules/dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b.zip": {
|
||||
"bytes": 3317564,
|
||||
"sha256": "dec4a591276d0920e0b7940c9785a2fb9bf92af71a4c206ef3d692db7798f45b"
|
||||
},
|
||||
"capsules/df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb.json": {
|
||||
"bytes": 139818,
|
||||
"sha256": "df764fd132235169050a761757a2761dcc36cdf08fb812edbfd3e18af50a5dfb"
|
||||
},
|
||||
"capsules/f199e1a7a58f7d9093a2cb9da288120c35bd87c650bb971ecad3c9d964d1c76a.json": {
|
||||
"bytes": 139818,
|
||||
"sha256": "f199e1a7a58f7d9093a2cb9da288120c35bd87c650bb971ecad3c9d964d1c76a"
|
||||
},
|
||||
"cargo/process-removal-build-1/process.json": {
|
||||
"bytes": 153,
|
||||
"sha256": "dc004e0235427ba30d729f43829567c953fc8813805493d88aa8026735f38855"
|
||||
},
|
||||
"cargo/process-removal-build-1/result.json": {
|
||||
"bytes": 4454,
|
||||
"sha256": "5699e510ec2541de2b2f484da17fd99685182baf80a0046e5ad3f651d54749db"
|
||||
},
|
||||
"cargo/process-removal-build-1/source-proof.json": {
|
||||
"bytes": 602,
|
||||
"sha256": "3c40d37ad31dc3f23e2c3f57fa0ab780f8b7891f491f9f5382948a625e8ecf84"
|
||||
},
|
||||
"cargo/process-removal-build-1/started.json": {
|
||||
"bytes": 1527,
|
||||
"sha256": "4db0bbcf6a8bc0123b383f2652490b32398c8aaa67c01040fa8d0ac04db2b531"
|
||||
},
|
||||
"cargo/process-removal-build-1/stderr.log": {
|
||||
"bytes": 134,
|
||||
"sha256": "653b17b1fe092ae2a63bd8de1567a002926ba1e5490fc7cc6e2def5bc194f8a5"
|
||||
},
|
||||
"cargo/process-removal-build-1/stdout.log": {
|
||||
"bytes": 285562,
|
||||
"sha256": "02fd17e99b96025280cab7ce7cfe19bfcdc406530bbaa31d4277744b7f8ee219"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/process.json": {
|
||||
"bytes": 154,
|
||||
"sha256": "e41df8b0608174388c261d28f7c3ee3cb20137ec33f5f2caa1ce6df9917a24ba"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/result.json": {
|
||||
"bytes": 1927,
|
||||
"sha256": "35e1afdd586853dbd992b3b944045e479297bf6dc2bf33db5186be0a51c3e41f"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/source-proof.json": {
|
||||
"bytes": 603,
|
||||
"sha256": "a758a8df8dbc2aa86b907856b69ca74f8219a9b462a83d690f06929ba3cc1797"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/started.json": {
|
||||
"bytes": 1533,
|
||||
"sha256": "af25bb05f73bb02e64ab8f367aa33bcfefce11d23cafa4f2a0f0f82181a60c2b"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/stderr.log": {
|
||||
"bytes": 1354,
|
||||
"sha256": "ab0b2ce019708e42eb13591a678c0c53a03805326568f07bf7a31849993031b7"
|
||||
},
|
||||
"cargo/process-removal-clippy-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/process.json": {
|
||||
"bytes": 154,
|
||||
"sha256": "012134abc527a95c97027e1b969d50bff5e5b2fc1017521ec9999e8aea772543"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/result.json": {
|
||||
"bytes": 1923,
|
||||
"sha256": "12f3352607a1608fcd1242b48336ae877b81677df38d96d17bbe9dc6ed3ff49b"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/source-proof.json": {
|
||||
"bytes": 603,
|
||||
"sha256": "d27fd807235a480aa0fe76f3ceefddbdc1756cc1782727829be99f86b00d7550"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/started.json": {
|
||||
"bytes": 1533,
|
||||
"sha256": "2e54d3d7bb6f1dca1ba771644dfd52d7c2557da4058fd9ce46f5758747e0ca20"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/stderr.log": {
|
||||
"bytes": 134,
|
||||
"sha256": "fe9cc79b9f5e01c55fac8b73f57930aeb698f3a311a8e8eaab9dd9e5f45904ac"
|
||||
},
|
||||
"cargo/process-removal-clippy-2/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-1/process.json": {
|
||||
"bytes": 116,
|
||||
"sha256": "6c78f04e1dacf3c96dbc71e106c9fa634a1f5f7d74d63659a52c71a51ef92956"
|
||||
},
|
||||
"cargo/process-removal-format-1/result.json": {
|
||||
"bytes": 1868,
|
||||
"sha256": "27181d72da07441a265453b2a76228ee92b77dfdd81995affbc874b61f10e26b"
|
||||
},
|
||||
"cargo/process-removal-format-1/source-proof.json": {
|
||||
"bytes": 603,
|
||||
"sha256": "d36d50d20b92c49c0867d1468ed9d8062e23aa8cda9a995ad8bd91938e337335"
|
||||
},
|
||||
"cargo/process-removal-format-1/started.json": {
|
||||
"bytes": 1480,
|
||||
"sha256": "30398f79fb193f3348222f7561c6b39a873ba4f832a71e82f3494ea8a5b357e0"
|
||||
},
|
||||
"cargo/process-removal-format-1/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-2/process.json": {
|
||||
"bytes": 116,
|
||||
"sha256": "7f289173389898a25c8313c5a91e1f66ce6249688aa7241dac0b171a78b85eaf"
|
||||
},
|
||||
"cargo/process-removal-format-2/result.json": {
|
||||
"bytes": 1868,
|
||||
"sha256": "dce00e380220b98c5ff51eb63a4a8ef71b4b3f936db08160ecef40f26169acdc"
|
||||
},
|
||||
"cargo/process-removal-format-2/source-proof.json": {
|
||||
"bytes": 603,
|
||||
"sha256": "6f6a756569f3877223932253db0cea5380d646dd221f467c69b3572289d9a2fc"
|
||||
},
|
||||
"cargo/process-removal-format-2/started.json": {
|
||||
"bytes": 1480,
|
||||
"sha256": "2892f3c304419b97ff95c9a554e6cee623751eda9764b98466e474e1fb80c034"
|
||||
},
|
||||
"cargo/process-removal-format-2/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-2/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-3/process.json": {
|
||||
"bytes": 110,
|
||||
"sha256": "17035416a3896914c8b79e0371362503ffa0ab21e8f4bc4e922cc21b32e15a79"
|
||||
},
|
||||
"cargo/process-removal-format-3/result.json": {
|
||||
"bytes": 1858,
|
||||
"sha256": "7ec891fa7a881b4f04fc3f58047a15f3972b397b7d2595e4a0bb04a9f658d9a3"
|
||||
},
|
||||
"cargo/process-removal-format-3/source-proof.json": {
|
||||
"bytes": 603,
|
||||
"sha256": "05d86dd797bf3c1a0d9c8b0c00a49294009ead98cb9e9c3e258d0cb59e9331b1"
|
||||
},
|
||||
"cargo/process-removal-format-3/started.json": {
|
||||
"bytes": 1470,
|
||||
"sha256": "6dae3900f3e53d89878ee0a9b89552bec60f351f56aa88505bbdba0a33348af8"
|
||||
},
|
||||
"cargo/process-removal-format-3/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-format-3/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/process-removal-tests-1/process.json": {
|
||||
"bytes": 217,
|
||||
"sha256": "007c489991c9d8ab793dbaa530aa96020e991b2428188e06a6e3ff4b7c369bc9"
|
||||
},
|
||||
"cargo/process-removal-tests-1/result.json": {
|
||||
"bytes": 4695,
|
||||
"sha256": "f95efe6a49ecd21625cb93fc13a3c759fc5703ed41d16689a075964464b443b9"
|
||||
},
|
||||
"cargo/process-removal-tests-1/source-proof.json": {
|
||||
"bytes": 602,
|
||||
"sha256": "4074db859ff385c14941ba8e45530a7e748e0db45101339d881937663c0ef512"
|
||||
},
|
||||
"cargo/process-removal-tests-1/started.json": {
|
||||
"bytes": 1611,
|
||||
"sha256": "ab8ca8306f1ea1a909cb7c049d0503646b738570840dd800865c89d2a0b4d434"
|
||||
},
|
||||
"cargo/process-removal-tests-1/stderr.log": {
|
||||
"bytes": 355,
|
||||
"sha256": "8106f2b580d08ae4991b1bae18a158f7cc7cf0b6d27e62114492553f69fd6f83"
|
||||
},
|
||||
"cargo/process-removal-tests-1/stdout.log": {
|
||||
"bytes": 322947,
|
||||
"sha256": "aeb571241a21c246ac94ae64f698b478ded69988b9f811c806bb689bb8af6dc9"
|
||||
},
|
||||
"cargo/process-removal-tests-2/process.json": {
|
||||
"bytes": 217,
|
||||
"sha256": "2884ec9947c403f3185f7963b2313500f79409cde575f5279827b8cc4227a868"
|
||||
},
|
||||
"cargo/process-removal-tests-2/result.json": {
|
||||
"bytes": 4693,
|
||||
"sha256": "ebf26a86bf954f0e0f6819ff1f6dc8cb261b02d0c73cde548827ce5bbcc0812a"
|
||||
},
|
||||
"cargo/process-removal-tests-2/source-proof.json": {
|
||||
"bytes": 602,
|
||||
"sha256": "4ab508f91efc34fde8cc1fc99fcc174d22f9eaf141730f608582b0067007eb56"
|
||||
},
|
||||
"cargo/process-removal-tests-2/started.json": {
|
||||
"bytes": 1611,
|
||||
"sha256": "6e5e873ac87130f4e352fda135d31ade35d248eb7e9c0d826a92346a57264e1c"
|
||||
},
|
||||
"cargo/process-removal-tests-2/stderr.log": {
|
||||
"bytes": 355,
|
||||
"sha256": "fa12c89f816b914e7b32627e498d2913f175cec41e5c3b100f815e32f471e9a4"
|
||||
},
|
||||
"cargo/process-removal-tests-2/stdout.log": {
|
||||
"bytes": 325669,
|
||||
"sha256": "c557894a0c8dfeae7ca91191a97df758638f0cdb32b0bb8e871fa8d06c48177f"
|
||||
},
|
||||
"cargo/process-removal-tests-3/process.json": {
|
||||
"bytes": 217,
|
||||
"sha256": "cb98374a36dc5591268b0420213399e5bc4607b9877a4707ad265daa08ad2025"
|
||||
},
|
||||
"cargo/process-removal-tests-3/result.json": {
|
||||
"bytes": 4692,
|
||||
"sha256": "82709fef9b1aabdf2fcc07a7cb9300d16474bc62370d7157e6bb6270cd8264f8"
|
||||
},
|
||||
"cargo/process-removal-tests-3/source-proof.json": {
|
||||
"bytes": 602,
|
||||
"sha256": "0407b22338c2d7bf725c8fe7098515a484cf67067578b4bf4992fa0a49bd3f93"
|
||||
},
|
||||
"cargo/process-removal-tests-3/started.json": {
|
||||
"bytes": 1611,
|
||||
"sha256": "bf71f073afc1fcc56df6c6f51841d75cd189c8576602b8e43ecb4e7470593c86"
|
||||
},
|
||||
"cargo/process-removal-tests-3/stderr.log": {
|
||||
"bytes": 284,
|
||||
"sha256": "ae9e8161a2438edc48c8667594310c21c3103583ce9c289efabb6f2e031574af"
|
||||
},
|
||||
"cargo/process-removal-tests-3/stdout.log": {
|
||||
"bytes": 317009,
|
||||
"sha256": "ace568a568723a784e0736f0b2d9287401f7fbe6a9b5fc025dc047205406f22a"
|
||||
},
|
||||
"cargo/process-removal-tests-4/process.json": {
|
||||
"bytes": 215,
|
||||
"sha256": "07992dcb5311d345837f37093dc9eb1d699d1ede9f812496a7f964d2c0dc363d"
|
||||
},
|
||||
"cargo/process-removal-tests-4/result.json": {
|
||||
"bytes": 4685,
|
||||
"sha256": "8513d24b9f05645908f4a475fcc90b5887362473f8046282bfd8af6a24c61dd5"
|
||||
},
|
||||
"cargo/process-removal-tests-4/source-proof.json": {
|
||||
"bytes": 602,
|
||||
"sha256": "ff045bf753ec4e09abc980018dd90f38338889dcd0877200364f5460418df5c6"
|
||||
},
|
||||
"cargo/process-removal-tests-4/started.json": {
|
||||
"bytes": 1604,
|
||||
"sha256": "1c408cdce0995388fcc2ba6d17ab2786daef461a656384684b02777e3b5d887d"
|
||||
},
|
||||
"cargo/process-removal-tests-4/stderr.log": {
|
||||
"bytes": 284,
|
||||
"sha256": "ed791ebad3050c571f427686faacb85afa112e958c6a190b272881f0fa661fc9"
|
||||
},
|
||||
"cargo/process-removal-tests-4/stdout.log": {
|
||||
"bytes": 297004,
|
||||
"sha256": "39100d92c647f02ca6c172b0a859e9ee39caaafca9fa60426fd628bceeea633b"
|
||||
},
|
||||
"comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": {
|
||||
"bytes": 10094,
|
||||
"sha256": "7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a"
|
||||
},
|
||||
"helpers/3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2.py": {
|
||||
"bytes": 3778,
|
||||
"sha256": "3e4cd38fd3807a4010a5a7102354f9139cab236ca55163c96090e416161e5fd2"
|
||||
},
|
||||
"helpers/c86f51d9b8465341dab73d045923ec306b15ebabf7c97b81beac7afe73bd72a1.py": {
|
||||
"bytes": 1396,
|
||||
"sha256": "c86f51d9b8465341dab73d045923ec306b15ebabf7c97b81beac7afe73bd72a1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Package explicitly selected closed gate captures without running Cargo."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
from verify import RAW_FILES, pin, parsed, need, run_name, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs, helpers, comparators=()):
|
||||
gates = root / ".local/scenario-group-gates"
|
||||
candidates = [gates / name for name in ("gate.py", "source-proof.py")] + helpers
|
||||
helper_bytes = [path.read_bytes() for path in candidates]
|
||||
comparator_paths = [root / ".local/process-source-evidence-tools/verify.py", *comparators]
|
||||
comparator_bytes = [path.read_bytes() for path in comparator_paths if path.is_file()]
|
||||
payload, logical, specs = {}, {}, {}
|
||||
def add(name, data):
|
||||
stored = ("capsules/" + pin(data)["sha256"] + Path(name).suffix
|
||||
if name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"} else name)
|
||||
need(stored not in payload or payload[stored] == data, "deduplication collision")
|
||||
payload[stored] = data
|
||||
logical[name] = stored
|
||||
helper_catalog = {}
|
||||
for name, data in zip(("gate.py", "source-proof.py"), helper_bytes):
|
||||
helper = "helpers/" + pin(data)["sha256"] + ".py"
|
||||
add(helper, data)
|
||||
helper_catalog[name] = helper
|
||||
for run in runs:
|
||||
folder = gates / run
|
||||
need(folder.is_dir(), "run directory missing: " + run)
|
||||
paths = list(folder.iterdir())
|
||||
names = {path.name for path in paths}
|
||||
need(all(path.is_file() and not path.is_symlink() for path in paths)
|
||||
and names == RAW_FILES | {"source-proof.json"}, "exact closed raw files and source proof required: " + run)
|
||||
raw = {path.name: path.read_bytes() for path in paths}
|
||||
result = parsed(raw["result.json"])
|
||||
need("finish" in result and result["inputsUnchanged"] is True, "unfinished/changed run refused: " + run)
|
||||
matches = [data for data in helper_bytes if pin(data) == result["helper"]]
|
||||
need(bool(matches), "exact historical helper unavailable for " + run + "; supply --helper PATH")
|
||||
helper = "helpers/" + result["helper"]["sha256"] + ".py"
|
||||
add(helper, matches[0])
|
||||
for name, data in sorted(raw.items()):
|
||||
add("cargo/" + run + "/" + name, data)
|
||||
specs[run] = {key: result[key] for key in ("command", "head", "exitCode", "inputCount")}
|
||||
specs[run]["helper"] = helper
|
||||
if "source-proof.json" in raw:
|
||||
proof = parsed(raw["source-proof.json"])
|
||||
matches = [data for data in comparator_bytes if pin(data) == proof["comparator"]]
|
||||
need(bool(matches), "exact source comparator unavailable for " + run + "; supply --comparator PATH")
|
||||
comparator = "comparators/" + proof["comparator"]["sha256"] + ".py"
|
||||
add(comparator, matches[0])
|
||||
specs[run]["sourceProof"] = {"commit": proof["commit"], "pin": pin(raw["source-proof.json"]), "comparator": comparator}
|
||||
manifest = {"schema": 1, "kind": "process-aggregate-removal-cargo-gates", "runs": specs,
|
||||
"captureHelpers": helper_catalog,
|
||||
"logicalFiles": dict(sorted(logical.items())),
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as output:
|
||||
output.write((json.dumps(value, indent=2) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, required=True, help="checkout holding .local/scenario-group-gates")
|
||||
parser.add_argument("--runs", nargs="+", required=True, help="explicit run names, including failures to retain")
|
||||
parser.add_argument("--helper", type=Path, action="append", default=[], help="additional exact historical helper (repeatable)")
|
||||
parser.add_argument("--comparator", type=Path, action="append", default=[], help="additional exact historical source comparator (repeatable)")
|
||||
parser.add_argument("--output", type=Path, help="fresh, nonexistent package directory (required unless --check-only)")
|
||||
parser.add_argument("--check-only", action="store_true", help="validate raw captures in memory without creating an archive")
|
||||
args = parser.parse_args()
|
||||
need(all(run_name(run) for run in args.runs) and len(args.runs) == len(set(args.runs)), "unique safe run names required")
|
||||
need(args.check_only or args.output is not None, "--output is required unless --check-only")
|
||||
if not args.check_only:
|
||||
need(not args.output.exists(), "output must be a fresh directory: " + str(args.output))
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper, args.comparator)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no package written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing package output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
destination = out / name
|
||||
if destination.exists():
|
||||
need(destination.read_bytes() == data, "different existing package tool: " + name)
|
||||
else:
|
||||
with destination.open("xb") as output:
|
||||
output.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated captures")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Offline consistency checks only; never execute or extract archived inputs."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
RAW_FILES = {"started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"}
|
||||
OUTER_FILES = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def valid_pin(value):
|
||||
return (isinstance(value, dict) and set(value) == {"bytes", "sha256"}
|
||||
and type(value["bytes"]) is int and value["bytes"] >= 0
|
||||
and isinstance(value["sha256"], str)
|
||||
and re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is not None)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and all(ord(char) >= 32 for char in name)
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def run_name(name):
|
||||
return isinstance(name, str) and re.fullmatch(r"process-removal-[A-Za-z0-9][A-Za-z0-9._-]*", name) is not None
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(value):
|
||||
raise ValueError("nonfinite JSON constant: " + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
|
||||
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP membership")
|
||||
for entry in archive.infolist():
|
||||
need(safe(entry.filename) and not entry.is_dir(), "unsafe ZIP member: " + entry.filename)
|
||||
need(not stat.S_ISLNK(entry.external_attr >> 16), "ZIP symlink: " + entry.filename)
|
||||
need(valid_pin(expected[entry.filename]), "invalid member pin: " + entry.filename)
|
||||
need(entry.file_size == expected[entry.filename]["bytes"], "ZIP recorded size: " + entry.filename)
|
||||
|
||||
|
||||
def ordered_times(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(value.utcoffset() is not None for value in times) and times == sorted(times)
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
"""Shared read-only verifier for packaged bytes or prepare's in-memory payload."""
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "process-aggregate-removal-cargo-gates", "manifest schema")
|
||||
runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"]
|
||||
helpers = manifest["captureHelpers"]
|
||||
need(isinstance(helpers, dict) and set(helpers) == {"gate.py", "source-proof.py"}, "capture helper names")
|
||||
need(all(isinstance(name, str) and re.fullmatch(r"helpers/[0-9a-f]{64}\.py", name)
|
||||
for name in helpers.values()), "capture helper mapping")
|
||||
need(isinstance(runs, dict) and bool(runs) and all(run_name(run) for run in runs), "explicit run inventory")
|
||||
need(all(safe(name) for name in logical) and all(safe(name) for name in stored), "unsafe manifest path")
|
||||
need(set(logical.values()) == set(stored), "unreferenced or absent stored members")
|
||||
for name, expected in stored.items():
|
||||
need(valid_pin(expected) and pin(read(name)) == expected, "stored member hash: " + name)
|
||||
expected_logical = {"cargo/" + run + "/" + name for run in runs for name in RAW_FILES}
|
||||
expected_logical.update(spec["helper"] for spec in runs.values())
|
||||
expected_logical.update(helpers.values())
|
||||
for run, spec in runs.items():
|
||||
need("sourceProof" in spec, run + ": source proof required")
|
||||
if "sourceProof" in spec:
|
||||
proof_spec = spec["sourceProof"]
|
||||
need(set(proof_spec) == {"commit", "pin", "comparator"}, run + ": exact source proof spec")
|
||||
need(isinstance(proof_spec["commit"], str) and re.fullmatch(r"[0-9a-f]{40}", proof_spec["commit"]), run + ": named full source commit")
|
||||
need(valid_pin(proof_spec["pin"]), run + ": source proof pin")
|
||||
need(isinstance(proof_spec["comparator"], str) and re.fullmatch(r"comparators/[0-9a-f]{64}\.py", proof_spec["comparator"]), run + ": comparator path")
|
||||
expected_logical.update({"cargo/" + run + "/source-proof.json", proof_spec["comparator"]})
|
||||
need(set(logical) == expected_logical, "exact logical raw/helper inventory")
|
||||
for name, destination in logical.items():
|
||||
if name.startswith("cargo/") and name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"}:
|
||||
expected = "capsules/" + stored[destination]["sha256"] + PurePosixPath(name).suffix
|
||||
need(destination == expected, "input capsule mapping: " + name)
|
||||
else:
|
||||
need(destination == name, "only input maps/archives may be deduplicated: " + name)
|
||||
def raw(name):
|
||||
return read(logical[name])
|
||||
for name, helper in helpers.items():
|
||||
need(helper == "helpers/" + pin(raw(helper))["sha256"] + ".py", "named helper hash: " + name)
|
||||
seen_capsules = set()
|
||||
for run, spec in runs.items():
|
||||
prefix = "cargo/" + run + "/"
|
||||
start = parsed(raw(prefix + "started.json"))
|
||||
process = parsed(raw(prefix + "process.json"))
|
||||
result = parsed(raw(prefix + "result.json"))
|
||||
required_start = {"start", "command", "cwd", "head", "scope", "inputs", "archive",
|
||||
"inputCount", "environment", "helper", "cargo", "rustc"}
|
||||
need(required_start <= start.keys(), run + ": incomplete start receipt")
|
||||
need(all(result.get(key) == value for key, value in start.items()), run + ": start/result relationship")
|
||||
command = result["command"]
|
||||
need(isinstance(command, list) and command and command[0] == "cargo"
|
||||
and all(isinstance(arg, str) for arg in command), run + ": Cargo command")
|
||||
need(command == process["command"] == spec["command"], run + ": command relationship")
|
||||
need(result["head"] == spec["head"] and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", result["head"]), run + ": HEAD")
|
||||
need(type(result["exitCode"]) is int and result["exitCode"] == spec["exitCode"], run + ": recorded exit")
|
||||
need(result["inputsUnchanged"] is True, run + ": changed inputs refused")
|
||||
need(type(result["seconds"]) in (int, float) and math.isfinite(result["seconds"])
|
||||
and result["seconds"] >= 0, run + ": duration")
|
||||
need(type(process["pid"]) is int and process["pid"] > 0, run + ": process identity")
|
||||
need(ordered_times(start["start"], process["observed"], result["finish"]), run + ": completed timestamp order")
|
||||
for field, filename in [("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log")]:
|
||||
need(result[field] == pin(raw(prefix + filename)), run + ": " + field + " pin")
|
||||
need(valid_pin(result["helper"]) and result["helper"] == pin(raw(spec["helper"])), run + ": helper pin")
|
||||
need(spec["helper"] == "helpers/" + result["helper"]["sha256"] + ".py", run + ": helper mapping")
|
||||
inputs = parsed(raw(prefix + "inputs.json"))
|
||||
need(isinstance(inputs, dict) and all(safe(name) for name in inputs), run + ": safe input inventory")
|
||||
need(type(result["inputCount"]) is int and len(inputs) == result["inputCount"] == spec["inputCount"], run + ": input counts")
|
||||
if "sourceProof" in spec:
|
||||
proof_spec = spec["sourceProof"]
|
||||
proof_bytes = raw(prefix + "source-proof.json")
|
||||
proof = parsed(proof_bytes)
|
||||
need(pin(proof_bytes) == proof_spec["pin"], run + ": source proof pinned bytes")
|
||||
need(proof["run"] == run and proof["commit"] == proof_spec["commit"], run + ": proof run/commit identity")
|
||||
need(proof["status"] == "PASS exact captured input inventory and Git blob bytes", run + ": source proof status")
|
||||
need(proof["inputCount"] == len(inputs) and proof["inputMap"] == result["inputs"], run + ": proof input relationship")
|
||||
need(proof["result"] == pin(raw(prefix + "result.json")), run + ": proof result relationship")
|
||||
need(ordered_times(result["finish"], proof["verified"]), run + ": proof time after closed gate")
|
||||
comparator = proof_spec["comparator"]
|
||||
need(valid_pin(proof["comparator"]) and pin(raw(comparator)) == proof["comparator"], run + ": comparator bytes")
|
||||
need(comparator == "comparators/" + proof["comparator"]["sha256"] + ".py", run + ": comparator identity")
|
||||
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
|
||||
capsule = (logical[prefix + "inputs.json"], logical[prefix + "inputs.zip"])
|
||||
if capsule not in seen_capsules:
|
||||
with zipfile.ZipFile(io.BytesIO(raw(prefix + "inputs.zip"))) as source:
|
||||
inventory(source, inputs)
|
||||
for name, expected in inputs.items():
|
||||
need(pin(source.read(name)) == expected, run + ": source hash " + name)
|
||||
seen_capsules.add(capsule)
|
||||
artifacts = []
|
||||
for line in raw(prefix + "stdout.log").decode("utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("reason") == "compiler-artifact" and value.get("executable"):
|
||||
artifacts.append(value)
|
||||
need(isinstance(result["artifacts"], list), run + ": artifact records")
|
||||
need(artifacts == [record["cargo"] for record in result["artifacts"]], run + ": Cargo JSON artifact relationship")
|
||||
for record in result["artifacts"]:
|
||||
need(set(record) == {"cargo", "readback"}
|
||||
and (record["readback"] is None or valid_pin(record["readback"])), run + ": artifact readback pin")
|
||||
print(run, "recorded exit", result["exitCode"], "inputs", len(inputs), "executable records", len(artifacts))
|
||||
return {"runs": len(runs), "recordedFailures": sum(spec["exitCode"] != 0 for spec in runs.values()),
|
||||
"logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules),
|
||||
"captureHelpers": helpers, "sourceProofs": sum("sourceProof" in spec for spec in runs.values())}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER_FILES, "outer file inventory")
|
||||
for name, expected in files.items():
|
||||
need(valid_pin(expected) and pin((root / name).read_bytes()) == expected, "outer hash: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS offline consistency:", verify_package(args.package))
|
||||
print("Historical receipts only; no source/binary execution or native acceptance claim.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
# Compact Process native follow-up, 2026-09-25 UTC
|
||||
|
||||
Run 3 uses the unchanged Windows Studio executable built from source
|
||||
572e96ba03522cfb822b13fe4abb50e857a5de48. PID 6252; normal Alt+F4 close,
|
||||
exit 0, empty logs and unchanged immutable inputs. Parent Computer Use actions
|
||||
below are observations, not an instrumented input log.
|
||||
|
||||
- Captures 01–16: prepare a fully visible 1102x732 window. Initial window was
|
||||
partly off-screen. Title-bar movement and edge dragging were unreliable;
|
||||
one edge attempt scrolled the Home view. Native System menu > Size, Right,
|
||||
pointer placement at x1100 set width; Size, Down, placement at y730 set height.
|
||||
Failed eval-based helper loading was rejected before execution; capture
|
||||
function was then defined directly. These are automation/setup limitations.
|
||||
- 17–26: native Open picker for this run's working.arcadia. UIA set_value failed
|
||||
(property absent from CacheRequest), then fresh observation, file-name click
|
||||
and literal typing entered the path. UIA Open click did not close the picker;
|
||||
reobservation plus Return loaded the correct model. The accessibility focus
|
||||
field said Search Box while the visible caret/text were in File name. No
|
||||
model data was typed into another editor or external destination.
|
||||
- 27–34: select Assessment, Behavior, Fit (59%), then select Action @a and use
|
||||
context Add on Control @c and SequenceLink @approved. Count 3, link primary.
|
||||
Native Shift-click is not claimed. Canvas palette uses narrow abbreviations;
|
||||
full canvas/palette accessibility and compact authoring are outside this test.
|
||||
- 35: header review at 1102x732, title, 3 removed/1 updated, 10 effects, search,
|
||||
paging, fixed Cancel and explicit Remove all visible.
|
||||
- 36: scroll page 1 to the explicit-before / implicit-after order rows; footer
|
||||
remains visible. 37: page 2 displays @e1 then @e2 and full direct @caller and
|
||||
transitive @outer names/aliases, without clipping the confirmation controls.
|
||||
- 38–40: search @outer finds one caller; append -missing gives zero results
|
||||
with recovery guidance. The review remains open and footer stays reachable.
|
||||
- 41–42: Cancel preserves count 3 and @approved primary; Save all, exact
|
||||
compact-cancelled snapshot equals all 2609 baseline bytes.
|
||||
- 43–45: fresh header review resets search; explicit Remove succeeds, group
|
||||
clears, owner selected, two exchange cards connected implicitly. Save all;
|
||||
compact-removed snapshot equals all 2258 independent v2 oracle bytes.
|
||||
- 46–47: one Ctrl+Z restores all four cards/authored link. Save all;
|
||||
compact-undo snapshot equals all 2609 baseline bytes. Then Alt+F4; supervisor
|
||||
confirms normal closure. No additional Redo/reopen claim from this run;
|
||||
original runs 1–2 retain that evidence separately.
|
||||
|
||||
Scope: OA derived Process and this fixture's compact removal review. This is
|
||||
not SA native acceptance, full keyboard-only navigation, native Shift-click,
|
||||
comprehensive accessibility, a full-suite rerun or a release. No production
|
||||
source changed and no Linux build/check ran.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,300 @@
|
||||
"""Offline archive/source/receipt checks. Never executes archived helpers or native binaries."""
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
KIND = 'process-aggregate-removal-native-evidence'
|
||||
PHASES = {'baseline': 'baseline.arcadia', 'removed': 'expected-removed.arcadia'}
|
||||
V2 = 'process-removal-v2-preserved-trivia'
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
p = PurePosixPath(name)
|
||||
assert name and not p.is_absolute() and str(p) == name, 'Unsafe member: ' + name
|
||||
assert '\\' not in name and ':' not in name and not any(x in ('.', '..', '') for x in name.split('/')), 'Unsafe member: ' + name
|
||||
|
||||
|
||||
def check_oracles(files, fixture, prefix='fixtures/'):
|
||||
revision = fixture['fixture_revision']
|
||||
assert revision in ('process-removal-v1-literal', V2)
|
||||
assert fixture['selection'] == fixture['removed'] == ['a', 'c', 'approved']
|
||||
assert fixture['changed'] == ['assessment']
|
||||
assert fixture['caller_definitions'] == ['caller', 'outer']
|
||||
assert fixture['remaining_implicit_order'] == ['e1', 'e2']
|
||||
assert set(fixture['files']) == set(PHASES.values()), 'Wrong fixture inventory'
|
||||
for name, expected in fixture['files'].items():
|
||||
assert pin(files[prefix + name]) == expected, 'Fixture pin mismatch: ' + name
|
||||
baseline = files[prefix + 'baseline.arcadia']
|
||||
assert pin(baseline) == {'bytes': 2609, 'sha256': '9965f4024584d64ac81442724aeb77b018b0cfae94e50bd412c5dcbbac49f6bf'}
|
||||
actual = files[prefix + 'expected-removed.arcadia']
|
||||
if revision != V2:
|
||||
# Preserve the original incorrect oracle as history, never acceptance.
|
||||
assert pin(actual) == {'bytes': 2186, 'sha256': '117b550f0c10753f53febb3ca6beb2c8088688ffa707f21eac654573e4a3d29c'}
|
||||
return
|
||||
text = baseline.decode('utf-8')
|
||||
assert '\r' not in text
|
||||
pattern = r'(?m)^ (?:oa_occurrence (?:a|c) |sequence_link approved )[^\n]*'
|
||||
# Independent fixture-specific lexical rules; never execute archived code.
|
||||
def trivia(line):
|
||||
return re.sub(r'\S', '', re.sub(r'"(?:\\.|[^"\\])*"', '', line))
|
||||
rows = re.findall(pattern, text)
|
||||
assert [trivia(row) for row in rows] == [' ' * 22, ' ' * 20, ' ' * 16]
|
||||
expected = re.sub(pattern, lambda m: '\n\n' + trivia(m.group()) + '\n\n', text)
|
||||
assert expected.count('steps [@a,@e1,@c,@e2]') == 1
|
||||
expected = expected.replace('steps [@a,@e1,@c,@e2]', 'steps [@e1,@e2]')
|
||||
assert actual == expected.encode('utf-8'), 'V2 differs from independently specified delta'
|
||||
assert pin(actual) == {'bytes': 2258, 'sha256': 'b4a2b07ad4c796f555c61945ca42b1c221b454b35488c60fefc86f96f7aa71ba'}
|
||||
|
||||
|
||||
def check_source(files, actual, phase, prefix='fixtures/'):
|
||||
assert phase in PHASES, 'Unknown source phase'
|
||||
assert actual == files[prefix + PHASES[phase]], 'Exact full source differs from ' + phase
|
||||
|
||||
|
||||
def verify(root):
|
||||
manifest = json.loads((root / 'manifest.json').read_text(encoding='utf-8'))
|
||||
assert manifest['kind'] == KIND, 'Wrong package kind'
|
||||
assert pin((root / 'native-evidence.zip').read_bytes()) == manifest['archive'], 'Archive pin mismatch'
|
||||
with zipfile.ZipFile(root / 'native-evidence.zip') as archive:
|
||||
names = archive.namelist()
|
||||
assert len(names) == len(set(names)) and set(names) == set(manifest['members']), 'Archive member inventory mismatch'
|
||||
for name in names:
|
||||
safe_name(name)
|
||||
assert ((archive.getinfo(name).external_attr >> 16) & 0o170000) != 0o120000, 'Symlink member'
|
||||
files = {name: archive.read(name) for name in names}
|
||||
for name, data in files.items():
|
||||
assert pin(data) == manifest['members'][name], 'Member pin mismatch: ' + name
|
||||
assert PurePosixPath(name).suffix.lower() not in ('.exe', '.dll'), 'Unexpected archived binary'
|
||||
for name in manifest['excluded_binary_bytes']:
|
||||
safe_name(name)
|
||||
assert name not in files and PurePosixPath(name).suffix.lower() in ('.exe', '.dll'), 'Invalid omitted binary entry'
|
||||
assert (root / 'verify.py').read_bytes() == files['helpers/native-evidence-verify.py'], 'External verifier differs from pinned helper'
|
||||
|
||||
def js(name):
|
||||
return json.loads(files[name].decode('utf-8-sig'))
|
||||
|
||||
def present_pin(name):
|
||||
return pin(files[name]) if name in files else manifest['excluded_binary_bytes'].get(name)
|
||||
|
||||
def build_binding(prefix, source):
|
||||
receipt = js(prefix + 'result.json')
|
||||
proof = js(prefix + 'source-proof.json')
|
||||
inputs = js(prefix + 'inputs.json')
|
||||
assert receipt['exitCode'] == 0 and receipt['inputsUnchanged'] is True, 'Build not successful/unchanged'
|
||||
assert proof['status'].startswith('PASS') and proof['result'] == pin(files[prefix + 'result.json']), 'Build proof result binding'
|
||||
assert proof['inputMap'] == receipt['inputs'] == pin(files[prefix + 'inputs.json']), 'Build input-map binding'
|
||||
assert proof['inputCount'] == receipt['inputCount'] == len(inputs), 'Build input count'
|
||||
assert proof['commit'] == receipt['head'] == source and re.fullmatch('[0-9a-f]{40}', source), 'Build source identity'
|
||||
return receipt
|
||||
|
||||
fixture = js('fixtures/fixture-manifest.json')
|
||||
check_oracles(files, fixture)
|
||||
for name in files:
|
||||
if name.startswith('oracle-revisions/') and name.endswith('/fixture-manifest.json'):
|
||||
check_oracles(files, js(name), name.removesuffix('fixture-manifest.json'))
|
||||
derivation = 'oracle-revisions/oracle-v2/derive-source-oracle-v2.ps1'
|
||||
assert pin(files[derivation]) == js('oracle-revisions/oracle-v2/fixture-manifest.json')['derivation']
|
||||
config = js('review-config.json')
|
||||
final_source = config['final_source']
|
||||
assert re.fullmatch('[0-9a-f]{40}', final_source)
|
||||
assert config['runs'] == manifest['runs'] and config['preflights'] == manifest['preflights']
|
||||
for entries, prefix, pattern in [(config['runs'], 'runs', r'run-[0-9]+'), (config['preflights'], 'preflight', r'fixture-preflight-[0-9]+')]:
|
||||
assert entries and len({x['name'] for x in entries}) == len(entries)
|
||||
assert all(re.fullmatch(pattern, x['name']) and type(x['accepted']) is bool for x in entries)
|
||||
assert {name.split('/')[1] for name in files if name.startswith(prefix + '/')} == {x['name'] for x in entries}, 'Undeclared archived attempts'
|
||||
|
||||
gates = config['gates']
|
||||
assert len(gates) == len(set(gates)) and {'process-removal-tests-1', 'process-removal-tests-2'} <= set(gates)
|
||||
assert all(re.fullmatch(r'process-removal-(?:tests|format|clippy|build)-[0-9]+', g) for g in gates)
|
||||
assert {n.split('/')[1] for n in files if n.startswith('gates/')} == set(gates)
|
||||
gate_reports = []
|
||||
for gate in gates:
|
||||
prefix = 'gates/' + gate + '/'
|
||||
result, proof, inputs = (js(prefix + f) for f in ('result.json', 'source-proof.json', 'inputs.json'))
|
||||
assert result['inputsUnchanged'] is True and 'finish' in result
|
||||
assert proof['status'].startswith('PASS') and proof['result'] == pin(files[prefix + 'result.json'])
|
||||
assert proof['inputMap'] == result['inputs'] == pin(files[prefix + 'inputs.json'])
|
||||
assert proof['inputCount'] == result['inputCount'] == len(inputs)
|
||||
assert proof['commit'] == result['head'] and re.fullmatch('[0-9a-f]{40}', result['head'])
|
||||
for stream in ('stdout', 'stderr'):
|
||||
assert result[stream] == pin(files[prefix + stream + '.log'])
|
||||
gate_reports.append({'gate': gate, 'source': result['head'], 'exitCode': result['exitCode']})
|
||||
# The original failed gates must remain failed historical facts.
|
||||
for gate, source in [('process-removal-tests-1', 'fe637dcd277b17653139ffa234e601b29775c565'),
|
||||
('process-removal-tests-2', 'f20497b29bf637619bc35ffe41274fc0f66c387b')]:
|
||||
receipt = js('gates/' + gate + '/result.json')
|
||||
assert receipt['head'] == source and receipt['exitCode'] == 101
|
||||
|
||||
reports, preflights = [], []
|
||||
accepted_cli_bindings = set()
|
||||
for entry in config['preflights']:
|
||||
prefix = 'preflight/' + entry['name'] + '/'
|
||||
problems = []
|
||||
try:
|
||||
current = js(prefix + 'result.json')
|
||||
started = js(prefix + 'started.json')
|
||||
fixture_name = prefix + 'evidence/fixture-manifest.json'
|
||||
current_fixture = js(fixture_name)
|
||||
check_oracles(files, current_fixture, prefix + 'fixtures/')
|
||||
revision = current_fixture.get('fixture_revision', 'removal-v1-source-derived')
|
||||
if 'fixture_revision' in current:
|
||||
assert current['fixture_revision'] == revision and current['fixture_manifest'] == pin(files[fixture_name]), 'Preflight fixture revision binding'
|
||||
assert current['fixture_comparator'] == pin(files[prefix + 'evidence/verify-captured.py']), 'Preflight comparator pin'
|
||||
assert started['checks'] == [] and all(current[key] == started[key] for key in started if key != 'checks'), 'Preflight started/result changed identity'
|
||||
assert current['status'].startswith('PASS') and current['cliUnchanged'] is True, 'Preflight not passed'
|
||||
receipt = build_binding(prefix + 'evidence/', current['source'])
|
||||
assert current['receipt'] == pin(files[prefix + 'evidence/result.json']) and current['proof'] == pin(files[prefix + 'evidence/source-proof.json']), 'Preflight proof pins'
|
||||
cli = [a for a in receipt['artifacts'] if a['cargo']['target']['name'] == 'arcadia' and not a['cargo']['profile']['test']]
|
||||
assert len(cli) == 1 and cli[0]['readback'] == current['cli'] == present_pin(prefix + 'bin/arcadia.exe'), 'CLI artifact binding'
|
||||
assert len(current['checks']) == len(PHASES) and {x['fixture'] for x in current['checks']} == set(PHASES.values()), 'Incomplete fixture preflight'
|
||||
for check in current['checks']:
|
||||
name = check['fixture']
|
||||
assert check['exitCode'] == 0 and check['json_report'] is True and check['fixtureUnchanged'] is True, 'Fixture check failed: ' + name
|
||||
assert check['stdout'] == pin(files[prefix + name + '.stdout.json']) and check['stderr'] == pin(files[prefix + name + '.stderr.log']), 'CLI output pins'
|
||||
assert pin(files[prefix + 'fixtures/' + name]) == current_fixture['files'][name], 'Preflight fixture differs'
|
||||
raw = js(prefix + name + '.stdout.json')
|
||||
assert raw['operation'] == 'check' and raw['exit_code'] == 0 and raw['written'] is False and not any(x['severity'] == 'error' for x in raw['diagnostics']), 'Raw CLI report rejects fixture'
|
||||
for runtime in current['runtime']:
|
||||
assert present_pin(prefix + 'bin/' + PureWindowsPath(runtime['source']).name) == {k: runtime[k] for k in ('bytes', 'sha256')}, 'CLI runtime pin'
|
||||
except (AssertionError, KeyError, ValueError, TypeError) as error:
|
||||
problems.append(str(error))
|
||||
preflights.append({'preflight': entry['name'], 'reviewer_accepted': entry['accepted'], 'checks_pass': not problems, 'problems': problems})
|
||||
if entry['accepted']:
|
||||
assert current_fixture['fixture_revision'] == V2, 'Historical v1 cannot be accepted'
|
||||
assert current['source'] == final_source, 'Accepted preflight source differs'
|
||||
assert not problems, json.dumps(preflights[-1])
|
||||
accepted_cli_bindings.add((current['source'], pin(files[fixture_name])['sha256']))
|
||||
|
||||
for entry in config['runs']:
|
||||
prefix = 'runs/' + entry['name'] + '/'
|
||||
problems, captures, sources = [], 0, []
|
||||
# Report the actual discovered count even when an earlier historical
|
||||
# source failure prevents the remaining per-run checks from passing.
|
||||
for name in files:
|
||||
if name.startswith(prefix) and name.endswith('.json'):
|
||||
try:
|
||||
record = js(name)
|
||||
captures += int(isinstance(record, dict) and 'images' in record)
|
||||
except (ValueError, UnicodeError):
|
||||
pass
|
||||
try:
|
||||
prepared, started, finished = (js(prefix + name + '.json') for name in ('prepared', 'started', 'finished'))
|
||||
fixture_name = prefix + 'evidence/fixture-manifest.json'
|
||||
run_fixture = js(fixture_name)
|
||||
check_oracles(files, run_fixture, prefix + 'evidence/')
|
||||
revision = run_fixture.get('fixture_revision', 'removal-v1-source-derived')
|
||||
if 'fixture_revision' in prepared:
|
||||
assert prepared['fixture_revision'] == revision and prepared['fixture_manifest'] == pin(files[fixture_name]), 'Run fixture revision binding'
|
||||
assert started['prepared'] == pin(files[prefix + 'prepared.json']), 'Started/prepared binding'
|
||||
for rel, expected in prepared['immutable'].items():
|
||||
safe_name(rel)
|
||||
assert present_pin(prefix + rel) == expected, 'Immutable pin: ' + rel
|
||||
assert prepared['helper'] == pin(files[prefix + 'evidence/native-harness.py']), 'Native helper binding'
|
||||
for helper in ('capture.js', 'verify-captured.py'):
|
||||
rel = 'evidence/' + helper
|
||||
assert prepared['immutable'].get(rel) == pin(files[prefix + rel]), 'Captured observation/source helper is not immutable: ' + helper
|
||||
for name in [*run_fixture['files'], 'fixture-manifest.json']:
|
||||
assert prepared['immutable'].get('evidence/' + name) == pin(files[prefix + 'evidence/' + name]), 'Run oracle is not immutable: ' + name
|
||||
receipt = build_binding(prefix + 'evidence/', prepared['source'])
|
||||
gui = [a for a in receipt['artifacts'] if a['cargo']['target']['name'] == 'arcadia-studio' and not a['cargo']['profile']['test']]
|
||||
assert len(gui) == 1 and gui[0]['readback'] == prepared['artifact'] == present_pin(prefix + 'bin/arcadia-studio.exe'), 'GUI artifact binding'
|
||||
for runtime in prepared['runtime_sources']:
|
||||
assert present_pin(prefix + 'bin/' + PureWindowsPath(runtime['path']).name) == {k: runtime[k] for k in ('bytes', 'sha256')}, 'GUI runtime pin'
|
||||
assert started['command'] == prepared['command'] and started['cwd'] == prepared['cwd'], 'Launch command/cwd changed'
|
||||
identity = started['identity']
|
||||
assert identity['pid'] == started['pid'] and identity['created_filetime'] > 0 and identity['running'] is True, 'Missing initial live process identity'
|
||||
assert PureWindowsPath(identity['image']) == PureWindowsPath(prepared['command'][0]), 'Wrong process image'
|
||||
assert finished['exitCode'] == 0 and finished['immutableUnchanged'] is True, 'Normal exit/immutability not established'
|
||||
for stream in ('stdout', 'stderr'):
|
||||
assert finished[stream] == pin(files[prefix + 'app.' + stream + '.log']), 'Final log binding'
|
||||
assert finished['working'] == pin(files[prefix + 'working.arcadia']), 'Final source binding'
|
||||
assert prepared.get('initial_pin', started['workingBefore']) == started['workingBefore'], 'Initial source pin changed'
|
||||
origin = entry.get('reopen_from')
|
||||
if prefix + 'reopen-origin.json' in files:
|
||||
old = js(prefix + 'reopen-origin.json')
|
||||
declared = {'run': old['from_run'], 'file': old['from_file']}
|
||||
assert origin is None or origin == declared, 'Contradictory reopen origins'
|
||||
origin = declared
|
||||
assert old['from_pin'] == old['copied_pin'] == started['workingBefore'], 'Reopen-origin pins disagree'
|
||||
if origin:
|
||||
assert origin['run'] != entry['name'] and origin['run'] in {x['name'] for x in config['runs']}, 'Unknown/self reopen origin'
|
||||
safe_name(origin['file'])
|
||||
assert pin(files['runs/' + origin['run'] + '/' + origin['file']]) == started['workingBefore'], 'Reopen source is not the exact captured copy'
|
||||
else:
|
||||
assert started['workingBefore'] == run_fixture['files']['baseline.arcadia'], 'Nonbaseline start needs explicit reopen_from'
|
||||
phases = entry.get('source_phases', {})
|
||||
actual_sources = [name for name in files if name.startswith(prefix) and name.endswith('.arcadia') and '/evidence/' not in name and name != prefix + 'baseline.arcadia']
|
||||
assert set(phases) <= {name[len(prefix):] for name in actual_sources}, 'Phase config references missing source'
|
||||
for name in actual_sources:
|
||||
rel = name[len(prefix):]
|
||||
sidecar = name[:-8] + '.json'
|
||||
phase = phases.get(rel)
|
||||
if sidecar in files and 'phase' in js(sidecar):
|
||||
saved = js(sidecar)
|
||||
assert phase is None or phase == saved['phase'], 'Contradictory snapshot phase'
|
||||
phase = saved['phase']
|
||||
assert saved['pin'] == pin(files[name]) and saved['exitCode'] == 0, 'Snapshot check/pin failed'
|
||||
evaluated = js(name[:-8] + '.evaluator.stdout.json')
|
||||
assert evaluated['status'] == 'PASS' and evaluated['phase'] == phase and evaluated['sha256'] == pin(files[name])['sha256'], 'Raw source evaluator report mismatch'
|
||||
assert not files[name[:-8] + '.evaluator.stderr.log'], 'Source evaluator stderr is nonempty'
|
||||
check_source(files, files[name], phase, prefix + 'evidence/')
|
||||
sources.append({'file': rel, 'phase': phase})
|
||||
start_time = datetime.datetime.fromisoformat(started['started'])
|
||||
finish_time = datetime.datetime.fromisoformat(finished['finished'])
|
||||
assert start_time <= finish_time, 'Reversed process lifetime'
|
||||
for name in files:
|
||||
if not name.startswith(prefix) or not name.endswith('.json'):
|
||||
continue
|
||||
record = js(name)
|
||||
if not isinstance(record, dict) or 'images' not in record:
|
||||
continue
|
||||
assert record['images'], 'Empty capture record'
|
||||
assert record['window']['app'].casefold() == ('process:' + identity['image']).casefold(), 'Capture uses a different executable surface'
|
||||
observed = datetime.datetime.fromisoformat(record['time'])
|
||||
assert start_time <= observed <= finish_time, 'Capture is outside process lifetime'
|
||||
for image in record['images']:
|
||||
safe_name(image['file'])
|
||||
assert prefix + image['file'] in files and image['width'] > 0 and image['height'] > 0, 'Missing/invalid capture image'
|
||||
assert captures > 0, 'No captured native observations'
|
||||
if entry['accepted']:
|
||||
assert run_fixture['fixture_revision'] == V2, 'Historical v1 cannot be accepted'
|
||||
assert prepared['source'] == final_source, 'Accepted native source differs'
|
||||
assert (prepared['source'], pin(files[fixture_name])['sha256']) in accepted_cli_bindings, 'No accepted current-source/current-oracle CLI fixture preflight'
|
||||
except (AssertionError, KeyError, ValueError, TypeError) as error:
|
||||
problems.append(str(error))
|
||||
reports.append({'run': entry['name'], 'reviewer_accepted': entry['accepted'], 'checks_pass': not problems, 'problems': problems, 'capture_records': captures, 'source_checks': sources})
|
||||
if entry['accepted']:
|
||||
assert not problems, json.dumps(reports[-1], indent=2)
|
||||
return {
|
||||
'status': 'PASS offline archive, exact source and receipt consistency checks',
|
||||
'gates': gate_reports,
|
||||
'runs': reports,
|
||||
'preflights': preflights,
|
||||
'accepted_run_count': sum(entry['accepted'] for entry in config['runs']),
|
||||
'limitations': [
|
||||
'Human visual, keyboard, selection and interaction claims are not inferred from screenshots.',
|
||||
'Omitted executable/DLL bytes cannot be rehashed offline.',
|
||||
'Build proof/input map consistency checked; Git blobs are not independently available offline.',
|
||||
'accepted=false failures are retained and reported, never silently promoted.',
|
||||
'No native history-depth introspection, usability acceptance, release or publication assertion.',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('directory', type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(verify(args.directory.resolve()), indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"audited_at": "2026-09-25T04:10:10.690609+00:00",
|
||||
"scope": "Independent exhaustive byte-level original/member mapping, recursive run/preflight/gate inventories, omitted local executable/DLL pins, historical preservation and report review; no GUI/Cargo/Git/Gitea/source mutation",
|
||||
"archive": {
|
||||
"bytes": 9190152,
|
||||
"sha256": "02ef32920cb76e12da8b2e41cdecd0d5eea5526371df6425462195c30b564250"
|
||||
},
|
||||
"manifest": {
|
||||
"bytes": 77836,
|
||||
"sha256": "e4b02304f6f7d4113e4799b6c96d6a5a6d663cde0b32a472d7b31d8a19ca8a75"
|
||||
},
|
||||
"members": 461,
|
||||
"mapped_original_files": 460,
|
||||
"normalized_config": 1,
|
||||
"member_groups": {
|
||||
"fixtures": 3,
|
||||
"gates": 70,
|
||||
"helpers": 13,
|
||||
"oracle-revisions": 5,
|
||||
"preflight": 15,
|
||||
"preparation": 7,
|
||||
"review-config.json": 1,
|
||||
"review-notes": 8,
|
||||
"runs": 339
|
||||
},
|
||||
"attempts": {
|
||||
"run-1": {
|
||||
"archived_files": 156,
|
||||
"excluded_binaries": 2,
|
||||
"capture_records": 43
|
||||
},
|
||||
"run-2": {
|
||||
"archived_files": 52,
|
||||
"excluded_binaries": 2,
|
||||
"capture_records": 10
|
||||
},
|
||||
"run-3": {
|
||||
"archived_files": 131,
|
||||
"excluded_binaries": 2,
|
||||
"capture_records": 46
|
||||
},
|
||||
"fixture-preflight-1": {
|
||||
"archived_files": 15,
|
||||
"excluded_binaries": 2,
|
||||
"capture_records": 0
|
||||
}
|
||||
},
|
||||
"omitted_binaries_rehashed": 8,
|
||||
"historical_preservation": {
|
||||
"old_archive_pin_matches_prior_audit": true,
|
||||
"old_members": 327,
|
||||
"identical_members": 326,
|
||||
"changed_members": [
|
||||
"review-config.json"
|
||||
],
|
||||
"missing_members": [],
|
||||
"explanation": "Only normalized top-level review-config.json extends accepted runs with run-3; original per-run config, bytes, old package and failures are retained."
|
||||
},
|
||||
"errors": [],
|
||||
"standalone_verifier": {
|
||||
"command": [
|
||||
"C:\\Users\\User\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe",
|
||||
"-B",
|
||||
"C:\\ar-scenario-9e8r6t\\harness\\process-group-removal-evidence-20260925\\compact-native\\verify.py",
|
||||
"C:\\ar-scenario-9e8r6t\\harness\\process-group-removal-evidence-20260925\\compact-native"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"stderr": "",
|
||||
"result": {
|
||||
"status": "PASS offline archive, exact source and receipt consistency checks",
|
||||
"gates": [
|
||||
{
|
||||
"gate": "process-removal-tests-1",
|
||||
"source": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"exitCode": 101
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-tests-2",
|
||||
"source": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"exitCode": 101
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-tests-3",
|
||||
"source": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-tests-4",
|
||||
"source": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-format-1",
|
||||
"source": "fe637dcd277b17653139ffa234e601b29775c565",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-format-2",
|
||||
"source": "f20497b29bf637619bc35ffe41274fc0f66c387b",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-format-3",
|
||||
"source": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-clippy-1",
|
||||
"source": "d93c0a801249f361d95721fb0fc239a95159af5c",
|
||||
"exitCode": 101
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-clippy-2",
|
||||
"source": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"gate": "process-removal-build-1",
|
||||
"source": "572e96ba03522cfb822b13fe4abb50e857a5de48",
|
||||
"exitCode": 0
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"run": "run-1",
|
||||
"reviewer_accepted": true,
|
||||
"checks_pass": true,
|
||||
"problems": [],
|
||||
"capture_records": 43,
|
||||
"source_checks": [
|
||||
{
|
||||
"file": "snapshots/cancelled-baseline.arcadia",
|
||||
"phase": "baseline"
|
||||
},
|
||||
{
|
||||
"file": "snapshots/redo.arcadia",
|
||||
"phase": "removed"
|
||||
},
|
||||
{
|
||||
"file": "snapshots/removed.arcadia",
|
||||
"phase": "removed"
|
||||
},
|
||||
{
|
||||
"file": "snapshots/undo.arcadia",
|
||||
"phase": "baseline"
|
||||
},
|
||||
{
|
||||
"file": "working.arcadia",
|
||||
"phase": "removed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"run": "run-2",
|
||||
"reviewer_accepted": true,
|
||||
"checks_pass": true,
|
||||
"problems": [],
|
||||
"capture_records": 10,
|
||||
"source_checks": [
|
||||
{
|
||||
"file": "snapshots/reopened.arcadia",
|
||||
"phase": "removed"
|
||||
},
|
||||
{
|
||||
"file": "working.arcadia",
|
||||
"phase": "removed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"run": "run-3",
|
||||
"reviewer_accepted": true,
|
||||
"checks_pass": true,
|
||||
"problems": [],
|
||||
"capture_records": 46,
|
||||
"source_checks": [
|
||||
{
|
||||
"file": "snapshots/compact-cancelled.arcadia",
|
||||
"phase": "baseline"
|
||||
},
|
||||
{
|
||||
"file": "snapshots/compact-removed.arcadia",
|
||||
"phase": "removed"
|
||||
},
|
||||
{
|
||||
"file": "snapshots/compact-undo.arcadia",
|
||||
"phase": "baseline"
|
||||
},
|
||||
{
|
||||
"file": "working.arcadia",
|
||||
"phase": "baseline"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"preflights": [
|
||||
{
|
||||
"preflight": "fixture-preflight-1",
|
||||
"reviewer_accepted": true,
|
||||
"checks_pass": true,
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"accepted_run_count": 3,
|
||||
"limitations": [
|
||||
"Human visual, keyboard, selection and interaction claims are not inferred from screenshots.",
|
||||
"Omitted executable/DLL bytes cannot be rehashed offline.",
|
||||
"Build proof/input map consistency checked; Git blobs are not independently available offline.",
|
||||
"accepted=false failures are retained and reported, never silently promoted.",
|
||||
"No native history-depth introspection, usability acceptance, release or publication assertion."
|
||||
]
|
||||
}
|
||||
},
|
||||
"report_review": {
|
||||
"file": "harness/process-removal-compact-20260925.md",
|
||||
"material_overclaims": [],
|
||||
"limits_preserved": [
|
||||
"One OA derived fixture at 1102x732; compact removal review only",
|
||||
"Parent action account distinct from capture/source receipts",
|
||||
"Context Add rather than untested native Shift-click",
|
||||
"General canvas clipping/small labels/abbreviated palette remain",
|
||||
"No keyboard-only/full accessibility/SA-native/full-suite/Linux acceptance",
|
||||
"Redo/reopen evidence comes from original runs1/2",
|
||||
"Static source-budget investigation found no candidate; no actual Process source-write-failure claim",
|
||||
"Local-only, no integration/issue-closure/release claim"
|
||||
],
|
||||
"review_basis": "Direct image review and source/lifecycle checks in compact-review.md; report compared to that review, compact-actions.md and separate static investigation. No new native execution or formal byte-monotonicity proof."
|
||||
},
|
||||
"limits": [
|
||||
"Offline verifier cannot resolve Git blobs or rehash omitted binaries; this audit additionally rehashed locally available omitted binaries.",
|
||||
"Archive consistency does not establish new gesture/usability claims.",
|
||||
"Gate source capsules inputs.zip are intentionally omitted here; receipt pins and original checks package preserve their separate provenance."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Independent compact Process review — 2026-09-25 UTC
|
||||
|
||||
Reviewer: `compact_process_review`, bounded evidence review only. No GUI, Cargo,
|
||||
Git, Gitea or production-source mutation. Parent action account is
|
||||
`compact-actions.md`; capture JSON records states, not an instrumented input log.
|
||||
|
||||
**Finding:** no correctness or usability blocker identified for this fixture's
|
||||
OA derived Process aggregate-removal review at 1102×732. This is acceptance of
|
||||
the bounded compact review evidence, not general compact authoring/accessibility.
|
||||
|
||||
## Direct visual inspection
|
||||
|
||||
Viewed actual images 16, 28, 29 and every image 34–47 with `view_image`.
|
||||
|
||||
- 16 establishes the visible compact window. 34 shows 3 selected, both same-name
|
||||
Review gate cards outlined and the authored link selected; the inspector
|
||||
distinguishes primary `@approved`. Selection gestures come from the parent's
|
||||
report, not filenames or screenshot inference.
|
||||
- 35 shows a contained dialog with readable title, 3 removed / 1 updated,
|
||||
separate affected-definition counts, search, page controls and fixed Cancel /
|
||||
explicit Remove controls. The explanation distinguishes local contributions
|
||||
from preserved referenced definitions and warns that complete caller graphs
|
||||
are not computed. Same-name selected cards are distinguished by `@a` / `@c`.
|
||||
- 36 shows the first page scrolled to the explicit-before / implicit-after
|
||||
summaries while both action buttons remain fully visible. The partial line at
|
||||
the scroll viewport's upper edge is normal scrolling, not inaccessible content.
|
||||
- 37 shows page 2 with full `@e1` then `@e2` order entries and both `@caller` and
|
||||
transitive `@outer` names/aliases. Previous is enabled and Next visibly disabled.
|
||||
Search and action buttons remain in place, with no review-specific clipping.
|
||||
- 38 shows the search caret. 39 shows `@outer`, one match and its full affected
|
||||
caller entry. 40 shows `@outer-missing`, zero matches and recovery guidance;
|
||||
the three-selection title/counts remain, clearly separating filtering from the
|
||||
pending operation. Confirmation still represents the entire selected group.
|
||||
- 41 shows cancellation feedback, original graph, selection count 3 and retained
|
||||
primary alias; 42 shows saved status. 43 is a fresh unfiltered review. 44–45
|
||||
show removal feedback, cleared selection, Process owner in the inspector and
|
||||
surviving exchange cards joined by implicit order, then saved status. 46–47
|
||||
show the restored four-card/authored-link graph, Undo feedback then saved state.
|
||||
Undo does not restore the earlier selection; no such guarantee is asserted.
|
||||
|
||||
## Source and lifecycle checks
|
||||
|
||||
Independently compared complete byte arrays: `compact-cancelled.arcadia`,
|
||||
`compact-undo.arcadia`, and final `working.arcadia` all equal pinned oracle-v2
|
||||
`baseline.arcadia` (2609 bytes, SHA256
|
||||
`9965f4024584d64ac81442724aeb77b018b0cfae94e50bd412c5dcbbac49f6bf`).
|
||||
`compact-removed.arcadia` equals oracle-v2 `expected-removed.arcadia` (2258 bytes,
|
||||
SHA256 `b4a2b07ad4c796f555c61945ca42b1c221b454b35488c60fefc86f96f7aa71ba`).
|
||||
All three snapshot sidecars record evaluator exit 0. The source delta removes
|
||||
`@a`, `@c`, `@approved`, updates Assessment steps to `[@e1,@e2]`, and preserves
|
||||
referenced OA work/exchanges, caller references, SA sentinels and remaining bytes.
|
||||
|
||||
Rehashed all 12 prepared immutable entries, including the local executable,
|
||||
DLL, source-proof/input-map/build receipts, exact oracle and comparator copies:
|
||||
all byte lengths and SHA256 pins match. The executable's pin matches its build
|
||||
receipt for `572e96ba03522cfb822b13fe4abb50e857a5de48`; the captured source-proof
|
||||
receipt reports exact source/input agreement. This review did not rerun Git blob
|
||||
verification. The started receipt correctly pins prepared.json and identifies
|
||||
PID 6252 and the isolated executable. All 46 capture timestamps lie between
|
||||
03:58:20.104404 and 04:05:21.991314 UTC; snapshot timestamps also lie within that
|
||||
interval. Finished receipt reports exit 0, unchanged immutable inputs and final
|
||||
baseline pin. Actual stdout/stderr files are empty. Parent reports normal Alt+F4.
|
||||
|
||||
## Limits and non-blocking observations
|
||||
|
||||
At 100% the compact canvas crops the graph (28); Fit exposes it at 59% (29), with
|
||||
small labels. The narrow palette uses abbreviations, and the inspector/explorer
|
||||
require scrolling for more content. These are general compact UI observations,
|
||||
not modal clipping or blockers for the demonstrated removal sequence. Broader
|
||||
compact authoring remains unevaluated.
|
||||
|
||||
Evidence covers this OA derived-layout fixture, mouse/context-add selection,
|
||||
header review, scrolling/paging/search/empty result, Cancel, explicit Remove,
|
||||
Save and one parent-reported Ctrl+Z. No native Shift-click, keyboard-only modal
|
||||
navigation, screen-reader usability, full accessibility, SA native workflows,
|
||||
source-budget failure or arbitrary smaller windows are accepted here. Captured
|
||||
native accessibility exposes only the top-level window/title-bar tree. This run
|
||||
does not add Redo or fresh-process reopen evidence; original runs 1–2 remain
|
||||
separate and unchanged. Screenshots cannot establish exact keystrokes, timing or
|
||||
all intermediate states. No new full-suite, Linux, integration or release claim.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Final bounded Process removal source review
|
||||
|
||||
Reviewed local commit `fe637dcd277b17653139ffa234e601b29775c565` on
|
||||
2026-09-25 UTC. `git status --porcelain` was empty before this ignored local
|
||||
review note. No production edits, Cargo, UI or Gitea operations were performed;
|
||||
the parent's frozen-input verification gate was running independently.
|
||||
|
||||
**Result:** no new actionable production finding in the requested final scope.
|
||||
The previous P1, P2 wording and same-batch gesture findings are addressed in
|
||||
source; the two requested planner coverage gaps now have independent literal
|
||||
test cases. Passing runtime/native gates remain the parent's separate evidence.
|
||||
|
||||
## Prior finding disposition
|
||||
|
||||
| Prior item | Final source assessment |
|
||||
| --- | --- |
|
||||
| P1 interpretation-only caller closure omitted explicit-to-explicit effects | Addressed. `Plan.affected_uses` remains independent of interpretation deltas, seeded from actual removed/changed definition topology. The OA/SA repeated/direct/transitive caller test is at `behavior/topology/group_removal/tests.rs:680`. |
|
||||
| P2 “No remaining contributions” denied surviving unlisted contributions | Addressed. `app/process_removal/view.rs:89–93` now says “Empty order” / “One item in this order,” with no false global contribution count. Empty order explicitly permits incomplete contextual flow. |
|
||||
| P2 candidate queued Delete admitted after a later private group press | Addressed in source with synchronous shared input generation and an active gesture lease; production-tree discriminator now exists at `app/process_removal/tests.rs:536`. Runtime result not independently observed by this reviewer. |
|
||||
| Coverage: selected occurrence + covered selected link + uncovered selected link | Added at `behavior/topology/group_removal/tests.rs:707`, both layers. Literal removed IDs, changed IDs, exact two Delete commands, reverse selection order and independent final Project/source history oracle. |
|
||||
| Coverage: global attachment/trace/placement/diagram impact | Added at `behavior/topology/group_removal/tests.rs:738`, both layers. Literal removed and changed sets include two placements, cascade-owned evidence, trace and surviving constraint/diagrams; counterpart, unrelated view, retained trace/artifact/placement remain exact. Full expected Project/source history oracle is independently constructed. |
|
||||
|
||||
## Input lifetime correction
|
||||
|
||||
`behavior/topology/removal_input.rs` supplies a shared `Arc<AtomicU64>` lifetime:
|
||||
even generations admit idle capture; an acquired group gesture owns an odd
|
||||
generation. A lease advances the generation on Drop, including ordinary
|
||||
release, cancellation and widget-state destruction. Checked arithmetic leaves
|
||||
overflow terminal/unavailable rather than revalidating old packets. Compare-
|
||||
exchange prevents a stale lease from resetting an unrelated generation.
|
||||
|
||||
The production topology view supplies the State's lifetime to the Graph.
|
||||
`direct_selection.rs` revokes idle captures synchronously on canvas presses and
|
||||
Unfocused/Resized boundaries. A selected-card GroupPress holds the lease. The
|
||||
shared viewport forwards synthetic Unfocused when camera ownership begins, so
|
||||
the same native batch can revoke an earlier Delete even though App has not yet
|
||||
consumed its queued Begin message.
|
||||
|
||||
`app/process_removal.rs:43–58` captures/requires an idle generation;
|
||||
`:90` compares it before any source/canvas boundary; `:185` compares it again at
|
||||
confirmation. This closes the specific prior race without substituting an App
|
||||
message-order check for synchronous widget state. Full typed selection/context
|
||||
admission remains present alongside the generation check.
|
||||
|
||||
The new mounted test at `app/process_removal/tests.rs:536` uses a real queued
|
||||
Delete, cursor move and selected-card press in one production input batch. Its
|
||||
assertions require no modal, unchanged canvas epoch, exact selection and shared
|
||||
Document snapshot, disabled fresh capture during the live gesture, and a fresh
|
||||
review after cancellation. The test at `:606` exercises Delete followed by
|
||||
middle/background-left camera pan start. Direct lifecycle tests at
|
||||
`behavior/topology/direct_selection_tests.rs:458` cover release, Escape,
|
||||
unfocus and Input destruction, requiring idle recovery with a different
|
||||
generation. The primitive lease test also checks concurrent acquisition refusal
|
||||
and explicit idle revocation.
|
||||
|
||||
These are meaningful discriminators for the reported paths. They are not a
|
||||
claim that the generation tracks every internal viewport gesture: existing
|
||||
viewport keyboard/capture rules still own camera input. No additional concrete
|
||||
production bypass was found in that composition.
|
||||
|
||||
## Review presentation correction
|
||||
|
||||
Identical local/expanded order dimensions are coalesced only when BOTH before
|
||||
and after vectors match (`app/process_removal/view.rs:62–80`). A divergence in
|
||||
either snapshot still produces separately labeled dimensions, preserving the
|
||||
owned-unlisted discriminator. Contextual-analysis changes remain independent.
|
||||
Affected caller definitions are deduplicated for the header count; rows retain
|
||||
definition-to-caller relationships, without claiming reference-instance counts.
|
||||
The text continues to disclose possible reused entry/exit changes and the lack
|
||||
of complete expanded-graph analysis.
|
||||
|
||||
## Precise verification boundary
|
||||
|
||||
This review establishes source consistency, test-oracle suitability and old
|
||||
finding disposition for the identified commit only. I did not execute any new
|
||||
or existing test, inspect the parent's gate output, run the native UI, verify
|
||||
compact layout/focus with real input, test packaged launch/save/reopen, or
|
||||
confirm remote publication/integration. In particular, the mounted race tests'
|
||||
presence and correct assertions do not by themselves establish their passing
|
||||
result. Parent gate/native evidence must remain attached to the tested commit;
|
||||
later changes invalidate only the affected portion of this source review.
|
||||
|
||||
## Addendum: qualified-definition menu restoration after runtime gate
|
||||
|
||||
The parent subsequently reported 185 passing and two failing tests for
|
||||
`fe637dcd277b17653139ffa234e601b29775c565`: an existing qualified-definition menu
|
||||
regression and a literal source-oracle whitespace mismatch. Those are parent-
|
||||
reported runtime results; I did not run or independently inspect their logs.
|
||||
|
||||
I reviewed the uncommitted `crates/oa-desktop/src/model_menu.rs` correction
|
||||
relative to that commit. Inspected file SHA256:
|
||||
`8BC2C74EA83EAEC8BD342AF5600FBF8E9963BED7290DC094E8B4FC7ABFC0B70D`.
|
||||
At inspection this was the only tracked modification. This addendum binds to
|
||||
that file content, not a claim that all current files equal the prior HEAD.
|
||||
|
||||
**No actionable finding in the correction.** The qualified navigation entry is
|
||||
now constructed before the aggregate-selection early return, so it remains
|
||||
available for a clicked selected or unselected preview while preserving group
|
||||
removal. Its message uses the captured request's Process context and exact
|
||||
qualified endpoint; it does not read the primary selection or strip reference
|
||||
paths. Preview-link navigation retains the prior source-endpoint convention.
|
||||
|
||||
The group branch still returns before singleton mutation entries are built.
|
||||
Outside the group branch, a qualified target still returns before those entries.
|
||||
The group Remove message continues to capture the entire current selection;
|
||||
adding navigation does not bypass the planner's qualified-member refusal or
|
||||
turn deletion into a clicked-object action. Selection-changing menu entries
|
||||
remain explicit.
|
||||
|
||||
Existing menu token/context/exact-selection validity checks apply unchanged.
|
||||
App menu invocation routes TopologyCanvas navigation before ordinary clicked-
|
||||
object selection. `app/process_canvas.rs:125` resolves the complete endpoint,
|
||||
remembers that exact invocation and opens its defining sequence; it retains
|
||||
pending-edit admission. No singleton destructive bypass was introduced by the
|
||||
entry relocation.
|
||||
|
||||
The previously passing source-only review did not catch the menu regression;
|
||||
the runtime gate supplied necessary additional evidence. The correction still
|
||||
requires its relevant regression rerun. The independent source-oracle whitespace
|
||||
repair is outside this addendum's inspected scope and is not treated as a
|
||||
production defect or as verified here. No Cargo/UI/Gitea or production edits
|
||||
were performed for this addendum.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user