Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8e6c1bda | ||
|
|
2ca84676ee | ||
|
|
336720559b | ||
|
|
636093aae6 | ||
|
|
c0908f101a | ||
|
|
24b1eab07d | ||
|
|
47f55199b4 | ||
|
|
41a23fbbe7 | ||
|
|
1a372f1b68 | ||
|
|
bb6e8a44ac | ||
|
|
1a669130fb | ||
|
|
715036e132 | ||
|
|
a9cd364eac |
@@ -12,12 +12,6 @@
|
||||
*.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.
|
||||
@@ -95,12 +89,3 @@ 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
|
||||
|
||||
@@ -210,7 +210,6 @@ impl SemanticPlan {
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
@@ -485,6 +484,57 @@ pub struct PresentationContext {
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
impl PresentationContext {
|
||||
fn header_visible(self, header: Rect, outgoing: bool) -> bool {
|
||||
if !outgoing {
|
||||
return self.viewport.contains(header);
|
||||
}
|
||||
// Leaving a performer must not require fitting the entire work card
|
||||
// inside the camera. Retain a 24px grab region so the result stays
|
||||
// directly reachable; geometry/model admission is independent.
|
||||
let width = (header.position.x + header.size.width)
|
||||
.min(self.viewport.position.x + self.viewport.size.width)
|
||||
- header.position.x.max(self.viewport.position.x);
|
||||
let height = (header.position.y + header.size.height)
|
||||
.min(self.viewport.position.y + self.viewport.size.height)
|
||||
- header.position.y.max(self.viewport.position.y);
|
||||
width * self.zoom >= 24. && height * self.zoom >= 24.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_header_retains_a_grab_region_without_requiring_full_camera_containment() {
|
||||
let context = PresentationContext {
|
||||
viewport: Rect {
|
||||
position: Position { x: 0., y: 0. },
|
||||
size: Size {
|
||||
width: 800.,
|
||||
height: 400.,
|
||||
},
|
||||
},
|
||||
zoom: 0.75,
|
||||
target: None,
|
||||
};
|
||||
let mut header = Rect {
|
||||
position: Position { x: -100., y: 30. },
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 62.,
|
||||
},
|
||||
};
|
||||
assert!(context.header_visible(header, true));
|
||||
assert!(!context.header_visible(header, false));
|
||||
header.position.x = -192.; // exactly 32 world pixels = 24 logical pixels
|
||||
assert!(context.header_visible(header, true));
|
||||
header.position.x -= 1.;
|
||||
assert!(!context.header_visible(header, true));
|
||||
header.position.x = 0.;
|
||||
header.position.y = 368.;
|
||||
assert!(context.header_visible(header, true));
|
||||
header.position.y += 1.;
|
||||
assert!(!context.header_visible(header, true));
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
@@ -496,8 +546,8 @@ pub struct PlacementPlan {
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// `requested` preserves the frozen pointer offset unless the caller proposes
|
||||
/// bounded outward clearance for an outgoing drop. Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
pub fn place(
|
||||
@@ -577,7 +627,7 @@ pub fn place(
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
if !presentation.header_visible(header, plan.destination == Destination::OutsidePerformers) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
|
||||
@@ -188,7 +188,7 @@ pub fn place(
|
||||
};
|
||||
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
|
||||
scenes?;
|
||||
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
|
||||
geometry::check_headers(&view, &plan.selected, presentation, false)?;
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
|
||||
@@ -398,7 +398,12 @@ pub fn place(
|
||||
&expanded,
|
||||
)?;
|
||||
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
|
||||
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
|
||||
geometry::check_headers(
|
||||
&view,
|
||||
scope.selected(),
|
||||
presentation,
|
||||
plan.destination == Destination::OutsidePerformers,
|
||||
)?;
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
|
||||
@@ -170,7 +170,8 @@ pub(super) fn check_rigid(
|
||||
pub(super) fn check_headers(
|
||||
view: &ProjectedView,
|
||||
selected: &BTreeMap<Id, Id>,
|
||||
viewport: Rect,
|
||||
presentation: PresentationContext,
|
||||
outgoing: bool,
|
||||
) -> Result<(), Refusal> {
|
||||
for (id, element) in selected {
|
||||
let node = view
|
||||
@@ -185,7 +186,7 @@ pub(super) fn check_headers(
|
||||
height: node.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !viewport.contains(header) {
|
||||
if !presentation.header_visible(header, outgoing) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
|
||||
@@ -60,7 +60,6 @@ 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,18 +19,12 @@ 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;
|
||||
@@ -221,14 +215,11 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, f.sender,
|
||||
)));
|
||||
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"
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(context),
|
||||
"same-owner selection retains gesture context"
|
||||
);
|
||||
let context = fresh;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: created,
|
||||
@@ -446,10 +437,6 @@ 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,
|
||||
@@ -536,14 +523,12 @@ 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,
|
||||
@@ -660,14 +645,12 @@ 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(),
|
||||
@@ -1365,128 +1348,8 @@ 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, _)
|
||||
@@ -1655,16 +1518,6 @@ 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.
|
||||
@@ -1752,20 +1605,9 @@ 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> {
|
||||
@@ -2316,10 +2158,6 @@ 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();
|
||||
@@ -2828,9 +2666,6 @@ 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) => {
|
||||
@@ -2860,21 +2695,9 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
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(_)
|
||||
))
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2917,10 +2740,6 @@ 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;
|
||||
@@ -3789,9 +3608,9 @@ impl App {
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = if self.topology_context().is_some() {
|
||||
"Use Remove selected above the canvas to review the complete Process selection."
|
||||
"Group removal needs an aggregate review. Select one contribution to remove it individually."
|
||||
} else {
|
||||
"Use Delete from model above the canvas to review the complete selection, or Hide selected to remove placements."
|
||||
"Group model-deletion review is not available yet. Select one element or use Hide selected."
|
||||
}.into();
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
mod clearance;
|
||||
|
||||
pub const FINISH_EDIT: &str =
|
||||
"Finish or cancel the current edit before selecting or moving elements.";
|
||||
|
||||
@@ -56,6 +58,7 @@ pub struct Target {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frozen {
|
||||
pub targets: Vec<Target>,
|
||||
pub moving: Vec<Rectangle>,
|
||||
cards: Vec<Rectangle>,
|
||||
controls: Vec<Rectangle>,
|
||||
edges: Vec<(Point, Point)>,
|
||||
@@ -82,14 +85,16 @@ impl Frozen {
|
||||
) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
let mut moving = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if affected.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
Point::new(n.position.x, n.position.y),
|
||||
iced::Size::new(n.size.width, n.size.height),
|
||||
);
|
||||
if affected.contains_key(&n.id) {
|
||||
moving.push(region);
|
||||
continue;
|
||||
}
|
||||
let Some(e) = project.elements.get(&n.element) else {
|
||||
cards.push(region);
|
||||
continue;
|
||||
@@ -172,6 +177,7 @@ impl Frozen {
|
||||
}
|
||||
Self {
|
||||
targets,
|
||||
moving,
|
||||
cards,
|
||||
controls,
|
||||
edges,
|
||||
@@ -407,7 +413,6 @@ impl Candidate {
|
||||
Self::Performers(p) => &p.accepted,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &view_options::ViewIndex {
|
||||
match self {
|
||||
Self::Singleton(p) => p.index(),
|
||||
@@ -525,6 +530,8 @@ pub(crate) struct Feedback {
|
||||
pub inspection_targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
/// Pointer-following outline only; it never authorizes a model change.
|
||||
pub ghost_delta: Option<Vector>,
|
||||
}
|
||||
impl Feedback {
|
||||
pub fn inspection_identity(&self) -> String {
|
||||
@@ -1055,7 +1062,26 @@ impl App {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
|
||||
};
|
||||
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let exact = candidate.place(requested, presentation);
|
||||
let placed = if destination == plan::Destination::OutsidePerformers
|
||||
&& matches!(&exact, Err(plan::Refusal::LayoutRequired(_)))
|
||||
{
|
||||
let affected = match &active.prepared {
|
||||
Prepared::Singleton(scope) => Some(&scope.placements),
|
||||
Prepared::Assignment(scope) => Some(scope.affected()),
|
||||
_ => None,
|
||||
};
|
||||
affected.and_then(|affected| {
|
||||
clearance::requests(
|
||||
(self.project(), self.document.view_index()),
|
||||
(candidate.project(), candidate.index()), stamp.context.diagram,
|
||||
affected, *anchor, requested,
|
||||
(stamp.world(press.start), stamp.world(pointer.unwrap())),
|
||||
).into_iter().find_map(|position| candidate.place(position, presentation).ok())
|
||||
}).map_or(exact, Ok)
|
||||
} else {
|
||||
exact
|
||||
}.map_err(|r| refusal(&r))?;
|
||||
let label = candidate.label();
|
||||
if release {
|
||||
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
|
||||
@@ -1082,6 +1108,14 @@ impl App {
|
||||
} else {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
let frozen = active.frozen.clone();
|
||||
// A rejected intermediate position is not a return gesture. Keep the
|
||||
// last admitted scene and show current motion separately; release
|
||||
// always recomputes admission and never commits this retained view.
|
||||
if refused && in_bounds && preview.is_none() {
|
||||
preview = self.allocation_drag.preview.take();
|
||||
}
|
||||
let ghost_delta = (refused && in_bounds)
|
||||
.then(|| (pointer.unwrap() - press.start) * (1. / stamp.zoom));
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
@@ -1092,6 +1126,7 @@ impl App {
|
||||
inspection_targets,
|
||||
text,
|
||||
refused,
|
||||
ghost_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Bounded outgoing clearance proposals. The ordinary placement and semantic
|
||||
//! validators still decide whether any proposal may be previewed or committed.
|
||||
use iced::Point;
|
||||
use oa_core::{Id, NodePlacement, Position, Project, projection, view_options::ViewIndex};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const GRID: f32 = 10.;
|
||||
const GAP: f32 = 10.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bounds {
|
||||
left: f32,
|
||||
top: f32,
|
||||
right: f32,
|
||||
bottom: f32,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
fn node(node: &projection::ProjectedNode) -> Option<Self> {
|
||||
let result = Self {
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
right: node.position.x + node.size.width,
|
||||
bottom: node.position.y + node.size.height,
|
||||
};
|
||||
([result.left, result.top, result.right, result.bottom]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
&& result.right > result.left
|
||||
&& result.bottom > result.top)
|
||||
.then_some(result)
|
||||
}
|
||||
|
||||
fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
left: self.left.min(other.left),
|
||||
top: self.top.min(other.top),
|
||||
right: self.right.max(other.right),
|
||||
bottom: self.bottom.max(other.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(self, other: Self) -> bool {
|
||||
self.left < other.right
|
||||
&& other.left < self.right
|
||||
&& self.top < other.bottom
|
||||
&& other.top < self.bottom
|
||||
}
|
||||
}
|
||||
|
||||
/// Called only after an OutsidePerformers candidate fails ordinary placement.
|
||||
/// Coordinates use the frozen world transform. The entire affected footprint
|
||||
/// receives one anchor translation; this never moves or packs other cards.
|
||||
pub(super) fn requests(
|
||||
accepted: (&Project, &ViewIndex),
|
||||
candidate: (&Project, &ViewIndex),
|
||||
diagram: Id,
|
||||
affected: &BTreeMap<Id, NodePlacement>,
|
||||
anchor: Id,
|
||||
requested: Position,
|
||||
motion: (Point, Point),
|
||||
) -> Vec<Position> {
|
||||
let (accepted, accepted_index) = accepted;
|
||||
let (candidate, candidate_index) = candidate;
|
||||
let (start, pointer) = motion;
|
||||
if ![
|
||||
requested.x,
|
||||
requested.y,
|
||||
start.x,
|
||||
start.y,
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let (Some(original), Some(base), Some(anchor)) = (
|
||||
accepted.diagrams.get(&diagram),
|
||||
candidate.diagrams.get(&diagram),
|
||||
affected.get(&anchor),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let snapped = Position {
|
||||
x: (requested.x / GRID).round() * GRID,
|
||||
y: (requested.y / GRID).round() * GRID,
|
||||
};
|
||||
let delta = Position {
|
||||
x: snapped.x - anchor.position.x,
|
||||
y: snapped.y - anchor.position.y,
|
||||
};
|
||||
let before = projection::project_diagram_with_index(accepted, original, accepted_index);
|
||||
let before_nodes: BTreeMap<_, _> = before.nodes.iter().map(|node| (node.id, node)).collect();
|
||||
let mut sources = BTreeSet::new();
|
||||
for node in before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| affected.contains_key(&node.id))
|
||||
{
|
||||
let mut parent = node.visual_parent;
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = parent {
|
||||
if !seen.insert(id) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(node) = before_nodes.get(&id) else {
|
||||
break;
|
||||
};
|
||||
if !affected.contains_key(&id)
|
||||
&& accepted
|
||||
.elements
|
||||
.get(&node.element)
|
||||
.is_some_and(|element| element.kind().is_performer())
|
||||
{
|
||||
sources.insert(id);
|
||||
}
|
||||
parent = node.visual_parent;
|
||||
}
|
||||
}
|
||||
if sources.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Projection after responsibility removal supplies the former performer's
|
||||
// remaining enclosure, including other allocated work. Projecting the moved
|
||||
// block also includes the full visible descendant enclosure of every root.
|
||||
let mut moved = base.clone();
|
||||
for (id, captured) in affected {
|
||||
let Some(node) = moved.nodes.get_mut(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
node.position = Position {
|
||||
x: captured.position.x + delta.x,
|
||||
y: captured.position.y + delta.y,
|
||||
};
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let after = projection::project_diagram_with_index(candidate, &moved, candidate_index);
|
||||
let mut footprint: Option<Bounds> = None;
|
||||
for node in after
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| affected.contains_key(&node.id))
|
||||
{
|
||||
let Some(bounds) = Bounds::node(node) else {
|
||||
return Vec::new();
|
||||
};
|
||||
footprint = Some(footprint.map_or(bounds, |previous| previous.union(bounds)));
|
||||
}
|
||||
let Some(footprint) = footprint else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut overlaps = Vec::new();
|
||||
for node in after.nodes.iter().filter(|node| sources.contains(&node.id)) {
|
||||
let (Some(old), Some(current)) = (Bounds::node(before_nodes[&node.id]), Bounds::node(node))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if footprint.overlaps(current) {
|
||||
overlaps.push((old, current));
|
||||
}
|
||||
}
|
||||
if overlaps.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Each entry is a half-plane actually crossed by the pointer. At a corner,
|
||||
// prefer the boundary reached first on the press-to-pointer segment. The
|
||||
// direction index is a stable tie-break, independent of placement UUIDs.
|
||||
let mut crossings = [None::<f32>; 4];
|
||||
for (old, _) in &overlaps {
|
||||
for (direction, initial, final_, boundary, outward) in [
|
||||
(
|
||||
0,
|
||||
start.x,
|
||||
pointer.x,
|
||||
old.left,
|
||||
pointer.x < old.left && start.x >= old.left,
|
||||
),
|
||||
(
|
||||
1,
|
||||
start.x,
|
||||
pointer.x,
|
||||
old.right,
|
||||
pointer.x >= old.right && start.x < old.right,
|
||||
),
|
||||
(
|
||||
2,
|
||||
start.y,
|
||||
pointer.y,
|
||||
old.top,
|
||||
pointer.y < old.top && start.y >= old.top,
|
||||
),
|
||||
(
|
||||
3,
|
||||
start.y,
|
||||
pointer.y,
|
||||
old.bottom,
|
||||
pointer.y >= old.bottom && start.y < old.bottom,
|
||||
),
|
||||
] {
|
||||
if outward {
|
||||
let fraction = (boundary - initial) / (final_ - initial);
|
||||
if fraction.is_finite() && (0. ..=1.).contains(&fraction) {
|
||||
crossings[direction] =
|
||||
Some(crossings[direction].map_or(fraction, |old| old.min(fraction)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut directions: Vec<_> = crossings
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(direction, fraction)| fraction.map(|fraction| (direction, fraction)))
|
||||
.collect();
|
||||
directions.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
let mut result = Vec::new();
|
||||
for (direction, _) in directions {
|
||||
let mut corrected = snapped;
|
||||
match direction {
|
||||
0 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.left)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
corrected.x = ((snapped.x + edge - GAP - footprint.right) / GRID).floor() * GRID;
|
||||
}
|
||||
1 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.right)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
corrected.x = ((snapped.x + edge + GAP - footprint.left) / GRID).ceil() * GRID;
|
||||
}
|
||||
2 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.top)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
corrected.y = ((snapped.y + edge - GAP - footprint.bottom) / GRID).floor() * GRID;
|
||||
}
|
||||
3 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.bottom)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
corrected.y = ((snapped.y + edge + GAP - footprint.top) / GRID).ceil() * GRID;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if corrected.x.is_finite() && corrected.y.is_finite() && !result.contains(&corrected) {
|
||||
result.push(corrected);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::allocation_drag as plan;
|
||||
use oa_core::{Kind, Layer, ProjectionKind, RelationKind, Size};
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
performer: Id,
|
||||
work: Id,
|
||||
node: Id,
|
||||
}
|
||||
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let (performer, work) = match layer {
|
||||
Layer::Oa => (Kind::Entity, Kind::Activity),
|
||||
Layer::Sa => (Kind::System, Kind::Function),
|
||||
};
|
||||
let performer = project
|
||||
.add_element(performer, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let work = project.add_element(work, layer, "Work", None).unwrap();
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let performer = project.place(diagram, performer, xy(100., 100.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&performer)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 400.,
|
||||
height: 250.,
|
||||
};
|
||||
let node = project.place(diagram, work, xy(160., 180.));
|
||||
let owner = project.diagrams[&diagram].nodes[&performer].element;
|
||||
project.relate(RelationKind::Allocation, "Responsibility", owner, work);
|
||||
Fixture {
|
||||
project,
|
||||
diagram,
|
||||
performer,
|
||||
work,
|
||||
node,
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic(f: &Fixture) -> (plan::Scope, plan::SemanticPlan) {
|
||||
let scope = plan::capture(&f.project, f.diagram, f.work, f.node).unwrap();
|
||||
let semantic = plan::plan_semantics(
|
||||
&f.project,
|
||||
&scope,
|
||||
plan::Destination::OutsidePerformers,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
(scope, semantic)
|
||||
}
|
||||
|
||||
fn presentation() -> plan::PresentationContext {
|
||||
plan::PresentationContext {
|
||||
viewport: plan::Rect {
|
||||
position: xy(-2000., -2000.),
|
||||
size: Size {
|
||||
width: 5000.,
|
||||
height: 5000.,
|
||||
},
|
||||
},
|
||||
zoom: 1.,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_outgoing_edge_clears_the_source_and_passes_ordinary_placement() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
for (start, pointer, requested, expected) in [
|
||||
(
|
||||
Point::new(170., 210.),
|
||||
Point::new(99., 210.),
|
||||
xy(89., 180.),
|
||||
xy(-140., 180.),
|
||||
),
|
||||
(
|
||||
Point::new(340., 210.),
|
||||
Point::new(501., 210.),
|
||||
xy(321., 180.),
|
||||
xy(510., 180.),
|
||||
),
|
||||
(
|
||||
Point::new(200., 190.),
|
||||
Point::new(200., 99.),
|
||||
xy(160., 89.),
|
||||
xy(160., -10.),
|
||||
),
|
||||
(
|
||||
Point::new(200., 260.),
|
||||
Point::new(200., 351.),
|
||||
xy(160., 271.),
|
||||
xy(160., 360.),
|
||||
),
|
||||
] {
|
||||
assert!(matches!(
|
||||
plan::place(&semantic, requested, presentation()),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
requested,
|
||||
(start, pointer),
|
||||
);
|
||||
assert_eq!(proposed, vec![expected]);
|
||||
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
assert!(
|
||||
plan::finish(&semantic, &f.project, &placed)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_shrink_uses_candidate_enclosure_not_frozen_target() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.performer)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
let (scope, semantic) = semantic(&f);
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(229., 180.),
|
||||
(Point::new(340., 210.), Point::new(409., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(340., 180.)]);
|
||||
plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_order_follows_boundary_crossing_and_is_deterministic() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
// Right crossed at 160/161 of the segment; bottom at 90/91.
|
||||
let args = (
|
||||
xy(321., 271.),
|
||||
Point::new(340., 260.),
|
||||
Point::new(501., 351.),
|
||||
);
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
args.0,
|
||||
(args.1, args.2),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(320., 360.), xy(510., 270.)]);
|
||||
assert_eq!(
|
||||
proposed,
|
||||
requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
args.0,
|
||||
(args.1, args.2)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_crossing_no_overlap_no_source_and_nonfinite_requests_are_empty() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
for (requested, pointer) in [
|
||||
(xy(170., 180.), Point::new(210., 210.)),
|
||||
(xy(700., 180.), Point::new(740., 210.)),
|
||||
(xy(f32::NAN, 180.), Point::new(99., 210.)),
|
||||
(xy(89., 180.), Point::new(f32::INFINITY, 210.)),
|
||||
] {
|
||||
assert!(
|
||||
requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
requested,
|
||||
(Point::new(200., 210.), pointer)
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
requests(
|
||||
(semantic.project(), semantic.index()),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(200., 210.), Point::new(99., 210.))
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_footprint_controls_clearance_without_mutating_projects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let kind = f.project.elements[&f.work].kind();
|
||||
let child = f
|
||||
.project
|
||||
.add_element(kind, layer, "Child", Some(f.work))
|
||||
.unwrap();
|
||||
let child_node = f.project.place(f.diagram, child, xy(240., 270.));
|
||||
// First-child allocation transfer is normally performed by commands;
|
||||
// this fixture expresses the resulting leaf allocation directly.
|
||||
f.project.relations.clear();
|
||||
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
||||
f.project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Leaf responsibility",
|
||||
owner,
|
||||
child,
|
||||
);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
assert!(scope.placements.contains_key(&child_node));
|
||||
let before = f.project.clone();
|
||||
let candidate = semantic.project().clone();
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(&candidate, semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(170., 210.), Point::new(99., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(-240., 180.)]);
|
||||
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
let root = &placed.diagram.nodes[&f.node];
|
||||
let child = &placed.diagram.nodes[&child_node];
|
||||
assert_eq!(child.position.x - root.position.x, 80.);
|
||||
assert_eq!(child.position.y - root.position.y, 90.);
|
||||
assert_eq!(f.project, before);
|
||||
assert_eq!(semantic.project(), &candidate);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_selected_roots_keep_one_rigid_group_translation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let kind = f.project.elements[&f.work].kind();
|
||||
let second = f
|
||||
.project
|
||||
.add_element(kind, layer, "Second work", None)
|
||||
.unwrap();
|
||||
let second_node = f.project.place(f.diagram, second, xy(240., 300.));
|
||||
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
||||
f.project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Second responsibility",
|
||||
owner,
|
||||
second,
|
||||
);
|
||||
let selected = BTreeMap::from([(f.node, f.work), (second_node, second)]);
|
||||
let scope =
|
||||
plan::assignment::capture(&f.project, f.diagram, &selected, f.node).unwrap();
|
||||
let semantic = plan::assignment::plan_semantics(
|
||||
&scope,
|
||||
plan::Destination::OutsidePerformers,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
scope.affected(),
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(170., 210.), Point::new(99., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(-220., 180.)]);
|
||||
let placed = plan::assignment::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
let root = &placed.diagram.nodes[&f.node];
|
||||
let second = &placed.diagram.nodes[&second_node];
|
||||
assert_eq!(second.position.x - root.position.x, 80.);
|
||||
assert_eq!(second.position.y - root.position.y, 120.);
|
||||
assert!(
|
||||
plan::assignment::finish(&semantic, &f.project, &placed)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,117 @@ async fn mount() -> (Fixture, Mounted) {
|
||||
mounted.settle(&mut f.app).await;
|
||||
(f, mounted)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_tree_outgoing_boundary_motion_preview_release_and_cancel() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = iced::Size::new(1440., 900.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 0.75;
|
||||
f.app.pan = Vector::new(20., 15.);
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let original = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = at(&mut mounted, &f, xy(660., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(from),
|
||||
)
|
||||
.await;
|
||||
// Separate input/update/layout turns expose the held boundary transition
|
||||
// that a one-batch distant drop cannot exercise.
|
||||
for x in [620., 560., 510., 499., 490.] {
|
||||
let to = at(&mut mounted, &f, xy(x, 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: to })],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.project(), &original);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(
|
||||
!f.app.allocation_drag.feedback.as_ref().unwrap().refused,
|
||||
"{layer:?} x={x}: {}",
|
||||
f.app.status
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let _ = mounted.pixels(&f.app);
|
||||
}
|
||||
let expected = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.placement
|
||||
.diagram
|
||||
.clone();
|
||||
let to = at(&mut mounted, &f, xy(490., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(&f.app.project().diagrams[&f.diagram], &expected);
|
||||
assert!(
|
||||
!f.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work)
|
||||
);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let from = at(&mut mounted, &f, xy(660., 225.));
|
||||
let to = at(&mut mounted, &f, xy(490., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
@@ -481,6 +592,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
||||
if pan.y == -100. {
|
||||
// A genuine clipping refusal in the same held gesture, then
|
||||
// recovery at the original exact point: no camera rebasing.
|
||||
let retained = f.app.allocation_drag.preview.clone().unwrap();
|
||||
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||
mounted
|
||||
.batch(
|
||||
@@ -492,7 +604,19 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
f.app.allocation_drag.preview.as_ref().unwrap(),
|
||||
&retained
|
||||
));
|
||||
assert!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.ghost_delta
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
|
||||
@@ -63,7 +63,6 @@ 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(),
|
||||
@@ -1240,6 +1239,7 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
|
||||
inspection_targets: vec![],
|
||||
text: "A card blocks this drop".into(),
|
||||
refused: true,
|
||||
ghost_delta: None,
|
||||
};
|
||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||
feedback.text = "Release inside the captured canvas".into();
|
||||
@@ -1247,3 +1247,361 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
|
||||
feedback.refused = false;
|
||||
assert_eq!(feedback.destination_identity(), "Outside performers");
|
||||
}
|
||||
|
||||
fn outgoing_fixture(layer: Layer) -> Fixture {
|
||||
let mut f = fixture(layer);
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
let mut project = f.app.project().clone();
|
||||
let d = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let performer = d.nodes.values_mut().find(|n| n.element == f.a).unwrap();
|
||||
performer.position = xy(500., 100.);
|
||||
performer.size = Size {
|
||||
width: 300.,
|
||||
height: 300.,
|
||||
};
|
||||
d.nodes.get_mut(&f.node).unwrap().position = xy(540., 190.);
|
||||
replace(&mut f, project);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_boundary_drop_clears_source_without_changing_other_placements() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(490., 225.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().unwrap();
|
||||
let expected_diagram = preview.placement.diagram.clone();
|
||||
let work = &expected_diagram.nodes[&f.node];
|
||||
assert!(work.position.x + work.size.width <= 490.);
|
||||
assert_eq!(work.position.y, 190.);
|
||||
for (id, node) in &before.diagrams[&f.diagram].nodes {
|
||||
if *id != f.node {
|
||||
assert_eq!(&expected_diagram.nodes[id], node);
|
||||
}
|
||||
}
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(&f.app.project().diagrams[&f.diagram], &expected_diagram);
|
||||
assert!(
|
||||
!f.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work)
|
||||
);
|
||||
let after = f.app.project().clone();
|
||||
let saved = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &after);
|
||||
assert_eq!(f.app.document.bundle(), &saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_clearance_collision_refuses_release_without_source_or_history_mutation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
let obstacle = project
|
||||
.add_element(
|
||||
project.elements[&f.work].kind(),
|
||||
layer,
|
||||
"Unrelated work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let obstacle_node = project.place(f.diagram, obstacle, xy(240., 190.));
|
||||
replace(&mut f, project);
|
||||
// A refused release must preserve an existing redo branch, too.
|
||||
let _ = f.app.update(Message::Command(Command::Rename(
|
||||
f.work,
|
||||
"Temporary".into(),
|
||||
)));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let snapshot = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(490., 225.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = &active.press;
|
||||
let frozen = active.frozen.as_ref().unwrap();
|
||||
let owner = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
assert_eq!(p.start, from);
|
||||
assert!(contains(owner.region, p.stamp.world(from)));
|
||||
assert!(p.stamp.world(to).x < owner.region.x);
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(to), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None),
|
||||
"The pointer must reach empty canvas, not be occluded by the obstacle"
|
||||
);
|
||||
let candidate = active
|
||||
.cache
|
||||
.iter()
|
||||
.find(|(destination, _)| *destination == plan::Destination::OutsidePerformers)
|
||||
.unwrap()
|
||||
.1
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let affected = match &active.prepared {
|
||||
Prepared::Singleton(scope) => &scope.placements,
|
||||
Prepared::Assignment(scope) => scope.affected(),
|
||||
_ => panic!("expected assignment gesture"),
|
||||
};
|
||||
let requested = xy(370., 190.);
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: p.stamp.viewport(),
|
||||
zoom: p.stamp.zoom,
|
||||
target: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
candidate.place(requested, presentation),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let proposed = clearance::requests(
|
||||
(f.app.project(), f.app.document.view_index()),
|
||||
(candidate.project(), candidate.index()),
|
||||
f.diagram,
|
||||
affected,
|
||||
f.node,
|
||||
requested,
|
||||
(p.stamp.world(from), p.stamp.world(to)),
|
||||
);
|
||||
assert_eq!(
|
||||
proposed.len(),
|
||||
1,
|
||||
"a genuine left crossing must propose clearance"
|
||||
);
|
||||
assert!(proposed[0].x < requested.x);
|
||||
let work = &before.diagrams[&f.diagram].nodes[&f.node];
|
||||
let obstacle = &before.diagrams[&f.diagram].nodes[&obstacle_node];
|
||||
assert!(obstacle.position.x + obstacle.size.width < 490.);
|
||||
assert!(proposed[0].x + work.size.width <= owner.region.x - 10.);
|
||||
assert!(proposed[0].x < obstacle.position.x + obstacle.size.width);
|
||||
assert!(obstacle.position.x < proposed[0].x + work.size.width);
|
||||
assert_eq!(proposed[0].y, obstacle.position.y);
|
||||
assert!(matches!(
|
||||
candidate.place(proposed[0], presentation),
|
||||
Err(plan::Refusal::LayoutRequired(message)) if message.contains("overlapping cards")
|
||||
));
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused, "{}", feedback.text);
|
||||
assert!(feedback.ghost_delta.is_some());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
let _ = f.app.update(release);
|
||||
assert!(
|
||||
f.app.status.contains("overlapping cards"),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_clearance_preserves_source_enclosure_and_allocated_sibling() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
let sibling = project
|
||||
.add_element(
|
||||
project.elements[&f.work].kind(),
|
||||
layer,
|
||||
"Remaining work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sibling_node = project.place(f.diagram, sibling, xy(540., 450.));
|
||||
let allocation = project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Keep responsibility",
|
||||
f.a,
|
||||
sibling,
|
||||
);
|
||||
replace(&mut f, project);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let view = projection::project_view(&before, f.diagram);
|
||||
let owner = view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
||||
let bottom = owner.position.y + owner.size.height;
|
||||
let stored_owner = &before.diagrams[&f.diagram].nodes[&owner.id];
|
||||
assert!(
|
||||
bottom > stored_owner.position.y + stored_owner.size.height,
|
||||
"The retained sibling must enlarge the projected source enclosure"
|
||||
);
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(660., bottom + 1.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = &active.press;
|
||||
let frozen = active.frozen.as_ref().unwrap();
|
||||
let target = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
assert_eq!(p.start, from);
|
||||
assert!(contains(target.region, p.stamp.world(from)));
|
||||
assert!(p.stamp.world(to).y >= target.region.y + target.region.height);
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(to), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None)
|
||||
);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
let requested = xy(540., 190. + to.y - from.y);
|
||||
assert!(matches!(
|
||||
preview.candidate.place(
|
||||
requested,
|
||||
plan::PresentationContext {
|
||||
viewport: p.stamp.viewport(),
|
||||
zoom: p.stamp.zoom,
|
||||
target: None,
|
||||
}
|
||||
),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let moved = &preview.placement.diagram.nodes[&f.node];
|
||||
assert_eq!(moved.position.x, 540.);
|
||||
assert!(moved.position.y >= bottom + 10.);
|
||||
assert!(moved.position.y > requested.y);
|
||||
assert_eq!(
|
||||
preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == owner.id)
|
||||
.unwrap(),
|
||||
owner
|
||||
);
|
||||
let remaining = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == sibling_node)
|
||||
.unwrap();
|
||||
assert_eq!(remaining.visual_parent, Some(owner.id));
|
||||
assert_eq!(
|
||||
remaining.position,
|
||||
before.diagrams[&f.diagram].nodes[&sibling_node].position
|
||||
);
|
||||
assert_eq!(
|
||||
&preview.project().relations[&allocation],
|
||||
&before.relations[&allocation]
|
||||
);
|
||||
for (id, node) in &before.diagrams[&f.diagram].nodes {
|
||||
if *id != f.node {
|
||||
assert_eq!(&preview.placement.diagram.nodes[id], node);
|
||||
}
|
||||
}
|
||||
let mut expected = preview.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), sibling),
|
||||
Some(f.a)
|
||||
);
|
||||
assert_eq!(
|
||||
&f.app.project().relations[&allocation],
|
||||
&before.relations[&allocation]
|
||||
);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
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 refused_motion_retains_last_scene_but_release_never_commits_it() {
|
||||
let mut f = outgoing_fixture(Layer::Sa);
|
||||
let mut project = f.app.project().clone();
|
||||
let obstacle = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Unrelated work", None)
|
||||
.unwrap();
|
||||
project.place(f.diagram, obstacle, xy(1250., 120.));
|
||||
replace(&mut f, project);
|
||||
let original = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let p = admit_press(&mut f);
|
||||
let clear = screen(xy(450., 225.));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(clear),
|
||||
)));
|
||||
let last = f.app.allocation_drag.preview.clone().expect(&f.app.status);
|
||||
// Another work card blocks this location: the prior legal outgoing scene is display
|
||||
// state only, and must not become a fallback command on release.
|
||||
let blocked = screen(xy(1260., 130.));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(blocked),
|
||||
)));
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.ghost_delta
|
||||
.is_some()
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
f.app.allocation_drag.preview.as_ref().unwrap(),
|
||||
&last
|
||||
));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(blocked),
|
||||
)));
|
||||
assert_eq!(f.app.project(), &original);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ 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,7 +221,6 @@ 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,
|
||||
@@ -300,7 +299,6 @@ 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 unchanged while moving to another Scenario owner.
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
let captured = app.scenario_context().unwrap();
|
||||
assert_eq!(captured.scenario, owners[0]);
|
||||
assert_eq!(
|
||||
@@ -197,28 +197,9 @@ 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 real captured snapshot rejects the changed Scenario destination"
|
||||
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the independent owner guard must reject an otherwise current token"
|
||||
);
|
||||
// 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,9 +15,6 @@ 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 {
|
||||
@@ -212,9 +209,6 @@ 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 ownership ahead of cancellation and captured reorder Apply.
|
||||
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
|
||||
//!
|
||||
//! 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,34 +24,9 @@ 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).or_else(|| {
|
||||
app.scenario_reorder.session.as_ref().map(|session| {
|
||||
crate::behavior::scenario::reorder_view::message(
|
||||
session,
|
||||
super::scenario_reorder::Intent::Cancel,
|
||||
)
|
||||
})
|
||||
}),
|
||||
modal.then_some(Message::Escape),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -59,9 +34,6 @@ 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> {
|
||||
@@ -115,43 +87,6 @@ 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 {
|
||||
@@ -164,11 +99,6 @@ 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,
|
||||
@@ -190,13 +120,6 @@ 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,7 +100,6 @@ 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> {
|
||||
@@ -110,9 +109,6 @@ 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()?;
|
||||
@@ -123,14 +119,6 @@ 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
|
||||
@@ -164,10 +152,6 @@ 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;
|
||||
};
|
||||
@@ -235,10 +219,6 @@ 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;
|
||||
@@ -304,10 +284,6 @@ 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;
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,940 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//! 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()
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,231 +0,0 @@
|
||||
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,33 +1,9 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use behavior::scenario::{
|
||||
selection,
|
||||
spacing::{Event, Refusal, controller::Finish},
|
||||
};
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
|
||||
#[cfg(test)]
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
@@ -40,54 +16,22 @@ 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
|
||||
|| !state.spacing_snapshot().is_some_and(|captured| {
|
||||
self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(captured, selection::context(stamp.context))
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => !matches,
|
||||
} {
|
||||
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() || matches!(event, Event::Begin { .. }) {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
@@ -95,38 +39,18 @@ impl App {
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing_with_snapshot(self.project(), event, snapshot);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
self.behavior.scenario = state;
|
||||
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);
|
||||
if result.is_err() {
|
||||
self.status = "The drag could not start in this view. Try again.".into();
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -134,38 +58,28 @@ impl App {
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => 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();
|
||||
}) => {
|
||||
// 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();
|
||||
}
|
||||
self.status = if group {
|
||||
format!(
|
||||
"Spacing updated for {} selected objects. Scenario order is unchanged.",
|
||||
captured.targets().len()
|
||||
)
|
||||
} else {
|
||||
"Scenario spacing updated.".into()
|
||||
};
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
},
|
||||
Err(error) => self.status = refusal_reason(&error),
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.scenario_reorder_reconcile();
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
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};
|
||||
|
||||
pub(super) fn setup() -> (App, Id, Id) {
|
||||
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,21 +88,14 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture()
|
||||
"the current gesture remains usable"
|
||||
);
|
||||
}
|
||||
pub(in crate::app) fn packet_for_app(app: &App, target: Target) -> Event {
|
||||
let mut event = packet(
|
||||
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let 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,
|
||||
@@ -115,10 +108,10 @@ pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
(event, press_screen, scale)
|
||||
}
|
||||
pub(super) fn send(app: &mut App, event: Event) {
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
}
|
||||
pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Move {
|
||||
@@ -128,7 +121,7 @@ pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
},
|
||||
);
|
||||
}
|
||||
pub(super) fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Finish {
|
||||
|
||||
@@ -116,7 +116,6 @@ 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()
|
||||
|
||||
@@ -10,6 +10,11 @@ async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
std::slice::from_ref(&input),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
statuses,
|
||||
[iced::event::Status::Captured],
|
||||
"the mounted picker must capture Escape before App subscriptions"
|
||||
);
|
||||
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
|
||||
for message in messages.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
@@ -41,17 +46,11 @@ async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reope
|
||||
send(&mut app, Message::BehaviorDiagram(choice));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
|
||||
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
|
||||
let (_, canvas, _) = mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
|
||||
.unwrap();
|
||||
// The existing picker is immediately above the measured canvas, separated
|
||||
// by 8px. Its 12px text and 8px padding give a 31.6px row.
|
||||
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
|
||||
let option = iced::Point::new(picker.x, canvas.y + 8.);
|
||||
// Locate the stable picker slot itself: selection controls may sit between
|
||||
// it and the canvas. The single menu option opens below this slot.
|
||||
let bounds = mounted.geometry(&app).bounds("state-missing-picker");
|
||||
let picker = bounds.center();
|
||||
let option = iced::Point::new(picker.x, bounds.y + bounds.height * 1.5);
|
||||
let before = app.document.snapshot();
|
||||
let original = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
|
||||
@@ -1243,7 +1243,6 @@ 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>,
|
||||
@@ -1263,8 +1262,6 @@ struct SequenceSlot {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct SequenceInput {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group_press: Option<ScenarioGroupPress>,
|
||||
context: Option<scenario::Context>,
|
||||
tool: scenario::CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
@@ -1272,13 +1269,6 @@ 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 {
|
||||
@@ -1319,48 +1309,11 @@ 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)
|
||||
@@ -1387,7 +1340,6 @@ impl Scene {
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
selection: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
@@ -2030,24 +1982,7 @@ 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)| {
|
||||
@@ -2072,14 +2007,10 @@ 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
|
||||
@@ -2106,9 +2037,6 @@ 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, .. }
|
||||
@@ -2144,22 +2072,9 @@ 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(
|
||||
@@ -2174,10 +2089,7 @@ 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()
|
||||
|| state.group_press.is_some() =>
|
||||
{
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
@@ -2191,18 +2103,6 @@ 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;
|
||||
@@ -2212,83 +2112,6 @@ 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(
|
||||
@@ -2391,27 +2214,6 @@ 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 {
|
||||
@@ -2563,7 +2365,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if self.is_selected(guide.id) {
|
||||
.with_color(if guide.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2589,7 +2391,7 @@ impl canvas::Program<Message> for Scene {
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if self.is_selected(activation.call) {
|
||||
.with_color(if activation.call == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2606,7 +2408,7 @@ impl canvas::Program<Message> for Scene {
|
||||
b.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
);
|
||||
let color = if self.is_selected(b.id) {
|
||||
let color = if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2628,7 +2430,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let color = if self.is_selected(l.id) {
|
||||
let color = if l.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2684,7 +2486,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 self.is_selected(b.id) {
|
||||
color: if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
@@ -2714,19 +2516,6 @@ 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
|
||||
@@ -2753,8 +2542,6 @@ 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,13 +6,6 @@ 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,
|
||||
@@ -150,7 +143,6 @@ 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),
|
||||
@@ -194,7 +186,6 @@ 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, _)
|
||||
@@ -1262,21 +1253,26 @@ pub(crate) fn reorder(
|
||||
occurrence: Id,
|
||||
slot: Slot,
|
||||
) -> Result<Command, String> {
|
||||
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 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 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)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,824 +0,0 @@
|
||||
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());
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,720 +0,0 @@
|
||||
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,7 +218,6 @@ 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));
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,354 +0,0 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! 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; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -1,323 +0,0 @@
|
||||
//! 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(),
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,268 +0,0 @@
|
||||
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,17 +5,13 @@
|
||||
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};
|
||||
|
||||
@@ -34,7 +30,6 @@ impl Target {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refusal {
|
||||
InvalidSelection(&'static str),
|
||||
InvalidSavedView(Id),
|
||||
TargetNotRendered(Target),
|
||||
InvalidDelta,
|
||||
@@ -50,7 +45,6 @@ pub(super) struct Drag {
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
origin: f32,
|
||||
other_origins: Vec<(Target, f32)>,
|
||||
original_geometry: ResolvedScenarioPlan,
|
||||
}
|
||||
|
||||
@@ -64,135 +58,17 @@ 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> {
|
||||
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 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 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)
|
||||
&& e.layer() == project.elements[&owner].layer()
|
||||
}) =>
|
||||
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
|
||||
{
|
||||
// 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()
|
||||
// Unassigned lifelines still have drawn headers. `scene.lanes`
|
||||
// excludes them only from semantic message-endpoint hit testing.
|
||||
geometry.scene.render_lanes.get(&id).copied()
|
||||
}
|
||||
Target::Message(id)
|
||||
if element.is_some_and(|e| {
|
||||
@@ -203,16 +79,17 @@ fn captured_origin(
|
||||
..
|
||||
})
|
||||
)
|
||||
}) && local_message(project, owner, 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()
|
||||
// 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)
|
||||
.and_then(|line| line.points.first())
|
||||
.map(|point| point.y);
|
||||
if lines.next().is_some() { None } else { first }
|
||||
.map(|point| point.y)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -220,38 +97,11 @@ fn captured_origin(
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -267,50 +117,28 @@ impl Drag {
|
||||
if original_delta == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
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() {
|
||||
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
|
||||
if position == self.origin {
|
||||
return Ok(None);
|
||||
}
|
||||
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 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
let project = execute_batch(&self.original, std::slice::from_ref(&command))
|
||||
.map_err(|error| Refusal::Command(error.to_string()))?;
|
||||
|
||||
@@ -5,7 +5,6 @@ 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)]
|
||||
@@ -17,7 +16,6 @@ mod timing_tests;
|
||||
pub struct Stamp {
|
||||
pub(crate) context: Context,
|
||||
pub(crate) camera_epoch: u64,
|
||||
pub(crate) selection_generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -74,14 +72,11 @@ 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>,
|
||||
@@ -103,21 +98,7 @@ pub(crate) enum Finish {
|
||||
|
||||
impl Session {
|
||||
fn configure(&self, scene: &mut Scene) {
|
||||
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.selected = self.drag.target.id();
|
||||
scene.scale = self.scale;
|
||||
scene.camera_pan = self.pan;
|
||||
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
|
||||
@@ -136,18 +117,8 @@ impl Session {
|
||||
Target::Lane(_) => screen.x - self.press_screen.x,
|
||||
Target::Message(_) => screen.y - self.press_screen.y,
|
||||
};
|
||||
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.
|
||||
self.ever_crossed |= distance.abs() >= 3.;
|
||||
if !self.ever_crossed {
|
||||
return;
|
||||
}
|
||||
match self.drag.prepare(distance / self.scale) {
|
||||
@@ -168,9 +139,6 @@ 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()
|
||||
}
|
||||
@@ -181,55 +149,28 @@ impl State {
|
||||
camera_epoch: u64,
|
||||
) -> Option<Paint<'_>> {
|
||||
let session = self.spacing.as_ref()?;
|
||||
if session.stamp.context != context || session.stamp.camera_epoch != camera_epoch {
|
||||
if session.stamp
|
||||
!= (Stamp {
|
||||
context,
|
||||
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().or_else(|| {
|
||||
session
|
||||
.ever_crossed
|
||||
.then_some(session.group_refusal.as_ref())
|
||||
.flatten()
|
||||
}),
|
||||
refusal: session.refusal.as_ref(),
|
||||
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,
|
||||
@@ -275,34 +216,7 @@ impl State {
|
||||
let Representation::Saved { diagram } = context.representation else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
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 mut drag = capture(project, diagram, target)?;
|
||||
let camera = self.presentation.camera(drag.original_geometry.bounds);
|
||||
if self.presentation.viewport != Some(canvas_bounds.size())
|
||||
|| camera.scale != scale
|
||||
@@ -332,19 +246,7 @@ impl State {
|
||||
}
|
||||
// Configure baseline without changing the persistent camera or selection.
|
||||
let scene = &mut drag.original_geometry.scene;
|
||||
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.selected = target.id();
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
@@ -360,9 +262,7 @@ impl State {
|
||||
drag,
|
||||
preview: None,
|
||||
refusal: None,
|
||||
group_refusal,
|
||||
ever_crossed: false,
|
||||
snapshot,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -382,9 +282,6 @@ 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,7 +95,6 @@ pub(crate) fn packet(
|
||||
stamp: Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
selection_generation: 0,
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
target,
|
||||
@@ -136,55 +135,6 @@ 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] {
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
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,7 +75,6 @@ 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
|
||||
@@ -91,8 +90,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
},
|
||||
ScenarioPresentation::capabilities,
|
||||
);
|
||||
let coordinate_input =
|
||||
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = if let Some(paint) = &preview {
|
||||
display_notice_parts(
|
||||
p,
|
||||
@@ -125,7 +123,6 @@ 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));
|
||||
@@ -141,21 +138,15 @@ 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(|| {
|
||||
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();
|
||||
.then_some(spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
});
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
@@ -167,27 +158,8 @@ 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))
|
||||
@@ -224,46 +196,7 @@ 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()
|
||||
};
|
||||
// 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,
|
||||
));
|
||||
}
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
@@ -329,12 +262,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let entries = [
|
||||
(
|
||||
"S",
|
||||
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 { .. }) {
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
"Select / move messages and lifelines"
|
||||
} else {
|
||||
"Select / reorder occurrences"
|
||||
@@ -415,15 +343,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
}),
|
||||
);
|
||||
let mut details = column![
|
||||
text(if selected_count > 1 {
|
||||
format!(
|
||||
"{selected_count} selected; properties: {}",
|
||||
p.named(app.selected)
|
||||
)
|
||||
} else {
|
||||
format!("Selected: {}", p.named(app.selected))
|
||||
})
|
||||
.size(12),
|
||||
text(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)),
|
||||
@@ -447,7 +367,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, snapshot.as_ref()).into(),
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
@@ -765,9 +685,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.into();
|
||||
}
|
||||
}
|
||||
let editor = if app.scenario_reorder.session.is_some() {
|
||||
None
|
||||
} else if pending {
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
@@ -806,13 +724,9 @@ fn display_notice_parts<'a>(
|
||||
for diagnostic in diagnostics.iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"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())
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
@@ -837,7 +751,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. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").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));
|
||||
}
|
||||
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));
|
||||
@@ -994,24 +908,7 @@ 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, .. },
|
||||
..
|
||||
@@ -1020,10 +917,10 @@ fn sequence_rows<'a>(
|
||||
for branch in branches {
|
||||
out = out.push(sequence_action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
choose(*branch),
|
||||
Message::Select(*branch),
|
||||
enabled,
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
|
||||
out = sequence_rows(p, context, *branch, out, enabled);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
@@ -1032,17 +929,17 @@ fn sequence_rows<'a>(
|
||||
row![
|
||||
sequence_action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
choose(*id),
|
||||
Message::Select(*id),
|
||||
enabled
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((order_enabled && i > 0).then_some(Message::Command(
|
||||
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
||||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||||
))),
|
||||
button("↓").padding(8).on_press_maybe(
|
||||
(order_enabled && i + 1 < steps.len())
|
||||
(enabled && i + 1 < steps.len())
|
||||
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
||||
)
|
||||
]
|
||||
@@ -1063,7 +960,7 @@ fn sequence_rows<'a>(
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out, enabled, snapshot);
|
||||
out = sequence_rows(p, context, *id, out, enabled);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -388,16 +388,13 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
|
||||
CanvasEvent::Viewport(c, Size::new(900., 600.))
|
||||
});
|
||||
open_details(&mut app);
|
||||
let mut begin = packet(
|
||||
let 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!()
|
||||
};
|
||||
@@ -446,180 +443,3 @@ 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,204 +436,3 @@ 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,13 +234,6 @@ 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,7 +29,6 @@ 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,
|
||||
@@ -45,8 +44,6 @@ 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>,
|
||||
@@ -880,21 +877,6 @@ 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()
|
||||
{
|
||||
@@ -903,7 +885,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 {
|
||||
"Use Shift+Delete to review model deletion or Hide selected for placements."
|
||||
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
|
||||
}.into())
|
||||
};
|
||||
return Some(Action::publish(Event::Selection(event)).and_capture());
|
||||
@@ -1529,8 +1511,9 @@ fn kind_for_tool(tool: Tool) -> Kind {
|
||||
}
|
||||
}
|
||||
|
||||
impl StateCanvas<'_> {
|
||||
fn update_input(
|
||||
impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -2019,51 +2002,6 @@ impl 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,
|
||||
@@ -2659,7 +2597,6 @@ mod tests {
|
||||
include!("state_multiselect_canvas_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
machine: Id,
|
||||
@@ -2721,7 +2658,6 @@ mod tests {
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
machine,
|
||||
@@ -2922,7 +2858,6 @@ 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,7 +26,6 @@ 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),
|
||||
@@ -105,9 +104,7 @@ impl Event {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::ModelRemoval(_) | Self::Choose(_) | Self::CreateView(_) => {
|
||||
None
|
||||
}
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
|
||||
@@ -328,10 +328,7 @@ 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(_)))));
|
||||
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()));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
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,7 +43,6 @@ 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),
|
||||
}
|
||||
}
|
||||
@@ -170,7 +169,6 @@ 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,
|
||||
@@ -189,7 +187,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.into();
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
container(missing_controls).id("state-missing-picker"),
|
||||
selection_controls(app),
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
@@ -225,22 +223,15 @@ 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",
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 12)
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
@@ -5,9 +5,7 @@ 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)]
|
||||
@@ -82,7 +80,6 @@ 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>,
|
||||
@@ -131,10 +128,6 @@ 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,
|
||||
@@ -1111,7 +1104,6 @@ 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,
|
||||
@@ -1409,7 +1401,6 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
removal_input: removal_input::Lifetime::default(),
|
||||
interaction: None,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -109,12 +108,6 @@ 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, .. })
|
||||
@@ -212,46 +205,21 @@ impl Graph<'_> {
|
||||
return None;
|
||||
}
|
||||
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
|
||||
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,
|
||||
}))
|
||||
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 *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(),
|
||||
));
|
||||
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()));
|
||||
}
|
||||
let pointer = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
@@ -260,9 +228,6 @@ 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,
|
||||
@@ -345,11 +310,7 @@ 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_reviews_delete_and_refuses_drag_and_connect_without_singleton_bypass() {
|
||||
fn whole_mixed_set_refuses_drag_delete_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_reviews_delete_and_refuses_drag_and_connect_without_singleton
|
||||
};
|
||||
assert!(matches!(
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
|
||||
Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if snapshot.targets().len() == 2)
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||
assert!(matches!(
|
||||
@@ -401,99 +401,3 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,909 +0,0 @@
|
||||
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,7 +185,6 @@ 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| {
|
||||
@@ -575,15 +574,6 @@ 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()))),
|
||||
));
|
||||
|
||||
@@ -42,8 +42,8 @@ mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
mod grid;
|
||||
pub(crate) mod group_gesture;
|
||||
pub(crate) mod group_removal;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
@@ -53,7 +53,6 @@ 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,
|
||||
@@ -73,8 +72,7 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_lease: Option<crate::removal_input::Lease>,
|
||||
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
grid: grid::Grid,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
@@ -610,8 +608,9 @@ impl DiagramCanvas<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
impl DiagramCanvas<'_> {
|
||||
fn update_input(
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -1246,67 +1245,6 @@ impl 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,
|
||||
@@ -1315,23 +1253,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let background = state
|
||||
.grid
|
||||
.draw(renderer, bounds.size(), self.zoom, self.pan);
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
f.fill_rectangle(
|
||||
Point::ORIGIN,
|
||||
bounds.size(),
|
||||
Color::from_rgb8(246, 248, 251),
|
||||
);
|
||||
let step = (24. * self.zoom).max(14.);
|
||||
let ox = self.pan.x.rem_euclid(step);
|
||||
let oy = self.pan.y.rem_euclid(step);
|
||||
for x in 0..(bounds.width / step) as i32 + 1 {
|
||||
for y in 0..(bounds.height / step) as i32 + 1 {
|
||||
f.fill(
|
||||
&Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65),
|
||||
style::LINE,
|
||||
);
|
||||
}
|
||||
}
|
||||
f.translate(self.pan);
|
||||
f.scale(self.zoom);
|
||||
let scene = self.scene(state);
|
||||
@@ -1734,7 +1659,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
vec![f.into_geometry(), overlay.into_geometry()]
|
||||
vec![background, f.into_geometry(), overlay.into_geometry()]
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
@@ -2392,7 +2317,6 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2471,7 +2395,6 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2601,7 +2524,6 @@ 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,
|
||||
@@ -2778,7 +2700,6 @@ 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,
|
||||
@@ -2858,7 +2779,6 @@ 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,
|
||||
@@ -2959,7 +2879,6 @@ 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,
|
||||
@@ -3091,7 +3010,6 @@ 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,
|
||||
@@ -3149,7 +3067,6 @@ 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,
|
||||
@@ -3227,7 +3144,6 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3264,7 +3180,6 @@ 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,7 +98,6 @@ 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,7 +34,6 @@ 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,16 +206,6 @@ 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,50 @@
|
||||
//! Screen-space background geometry, independent of the model and pointer.
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Grid {
|
||||
geometry: canvas::Cache,
|
||||
camera: Cell<Option<(f32, Vector)>>,
|
||||
#[cfg(test)]
|
||||
builds: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
pub(super) fn draw(
|
||||
&self,
|
||||
renderer: &Renderer,
|
||||
size: Size,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
) -> canvas::Geometry {
|
||||
// Cache::draw handles exact size changes. Raw camera equality is
|
||||
// deliberately conservative: do not quantize fractional antialiasing.
|
||||
if self.camera.replace(Some((zoom, pan))) != Some((zoom, pan)) {
|
||||
self.geometry.clear();
|
||||
}
|
||||
self.geometry.draw(renderer, size, |frame| {
|
||||
#[cfg(test)]
|
||||
self.builds.set(self.builds.get() + 1);
|
||||
paint(frame, size, zoom, pan);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(frame: &mut Frame, size: Size, zoom: f32, pan: Vector) {
|
||||
frame.fill_rectangle(Point::ORIGIN, size, Color::from_rgb8(246, 248, 251));
|
||||
let step = (24. * zoom).max(14.);
|
||||
let ox = pan.x.rem_euclid(step);
|
||||
let oy = pan.y.rem_euclid(step);
|
||||
for x in 0..(size.width / step) as i32 + 1 {
|
||||
for y in 0..(size.height / step) as i32 + 1 {
|
||||
frame.fill(
|
||||
&Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65),
|
||||
style::LINE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::geometry::Renderer as _,
|
||||
renderer::{Headless, Renderer as _},
|
||||
};
|
||||
|
||||
async fn renderer() -> Renderer {
|
||||
<Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
|
||||
.await
|
||||
.expect("grid tests require the supported WGPU headless renderer")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_reuses_unchanged_inputs_and_rebuilds_for_each_camera_or_size_change() {
|
||||
let renderer = renderer().await;
|
||||
let grid = Grid::default();
|
||||
let original = Size::new(320., 240.);
|
||||
let threshold = 14. / 24.;
|
||||
// Each row changes exactly one dependency from its predecessor. Returning
|
||||
// to old dimensions must invalidate too; this cache holds one viewport.
|
||||
let cases = [
|
||||
("initial", original, 1., Vector::ZERO),
|
||||
("positive x pan", original, 1., Vector::new(24., 0.)),
|
||||
("negative x pan", original, 1., Vector::new(-24., 0.)),
|
||||
("fractional x pan", original, 1., Vector::new(-24.25, 0.)),
|
||||
("positive y pan", original, 1., Vector::new(-24.25, 18.)),
|
||||
("negative y pan", original, 1., Vector::new(-24.25, -18.)),
|
||||
("fractional y pan", original, 1., Vector::new(-24.25, -18.5)),
|
||||
("return to zero pan", original, 1., Vector::ZERO),
|
||||
("zoom at zero pan", original, 1.25, Vector::ZERO),
|
||||
(
|
||||
"below spacing threshold",
|
||||
original,
|
||||
threshold - 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
("at spacing threshold", original, threshold, Vector::ZERO),
|
||||
(
|
||||
"above spacing threshold",
|
||||
original,
|
||||
threshold + 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
(
|
||||
"width only",
|
||||
Size::new(321., 240.),
|
||||
threshold + 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
("return width", original, threshold + 0.001, Vector::ZERO),
|
||||
(
|
||||
"height only",
|
||||
Size::new(320., 241.),
|
||||
threshold + 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
("return height", original, threshold + 0.001, Vector::ZERO),
|
||||
(
|
||||
"fractional width",
|
||||
Size::new(320.25, 240.),
|
||||
threshold + 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
(
|
||||
"fractional height",
|
||||
Size::new(320.25, 240.5),
|
||||
threshold + 0.001,
|
||||
Vector::ZERO,
|
||||
),
|
||||
];
|
||||
for (index, (label, size, zoom, pan)) in cases.into_iter().enumerate() {
|
||||
let _ = grid.draw(&renderer, size, zoom, pan);
|
||||
assert_eq!(grid.builds.get(), index + 1, "{label} must rebuild");
|
||||
for _ in 0..3 {
|
||||
let _ = grid.draw(&renderer, size, zoom, pan);
|
||||
assert_eq!(grid.builds.get(), index + 1, "{label} must reuse");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Independent reference copied from the former DiagramCanvas::draw background
|
||||
// pass. Do not call grid::paint here: a shared implementation would hide changes
|
||||
// in spacing, phase, radius, color, or coverage from the parity comparison.
|
||||
fn legacy_background(frame: &mut Frame, size: Size, zoom: f32, pan: Vector) {
|
||||
frame.fill_rectangle(Point::ORIGIN, size, Color::from_rgb8(246, 248, 251));
|
||||
let spacing = (24. * zoom).max(14.);
|
||||
let offset_x = pan.x.rem_euclid(spacing);
|
||||
let offset_y = pan.y.rem_euclid(spacing);
|
||||
for column in 0..(size.width / spacing) as i32 + 1 {
|
||||
for row in 0..(size.height / spacing) as i32 + 1 {
|
||||
frame.fill(
|
||||
&Path::circle(
|
||||
Point::new(
|
||||
offset_x + column as f32 * spacing,
|
||||
offset_y + row as f32 * spacing,
|
||||
),
|
||||
0.65,
|
||||
),
|
||||
Color::from_rgb(0.86, 0.89, 0.91),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn foreground(frame: &mut Frame, zoom: f32, pan: Vector, changed: bool) {
|
||||
frame.translate(pan);
|
||||
frame.scale(zoom);
|
||||
// Crossing paths pass behind opaque cards. The long paths also cross the
|
||||
// screen-space warning, making geometry order observable.
|
||||
for (start, end, color) in [
|
||||
(Point::new(0., 20.), Point::new(300., 210.), style::TEAL),
|
||||
(Point::new(0., 210.), Point::new(300., 20.), style::BOUNDARY),
|
||||
] {
|
||||
frame.stroke(
|
||||
&Path::line(start, end),
|
||||
Stroke::default().with_color(color).with_width(3.),
|
||||
);
|
||||
}
|
||||
let shift = if changed { 19.5 } else { 0. };
|
||||
frame.fill_rectangle(
|
||||
Point::new(12., 92.),
|
||||
Size::new(270., 35.),
|
||||
Color::from_rgba(0.1, 0.6, 0.5, 0.25),
|
||||
);
|
||||
for (position, size, title) in [
|
||||
(Point::new(32. + shift, 58.), Size::new(116., 72.), "Sensor"),
|
||||
(Point::new(172., 139.), Size::new(118., 66.), "Controller"),
|
||||
] {
|
||||
frame.fill_rectangle(position, size, Color::WHITE);
|
||||
frame.stroke(
|
||||
&Path::rectangle(position, size),
|
||||
Stroke::default().with_color(style::BOUNDARY).with_width(1.),
|
||||
);
|
||||
frame.fill_text(Text {
|
||||
content: title.into(),
|
||||
position: position + Vector::new(8., 12.),
|
||||
color: style::INK,
|
||||
size: 13.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// Selection paint and handles remain live foreground data, including when
|
||||
// the underlying background cache is reused after an edit.
|
||||
frame.stroke(
|
||||
&Path::rectangle(Point::new(29. + shift, 55.), Size::new(122., 78.)),
|
||||
Stroke::default().with_color(style::TEAL).with_width(2.),
|
||||
);
|
||||
frame.fill_rectangle(Point::new(26. + shift, 52.), Size::new(6., 6.), style::TEAL);
|
||||
frame.fill_text(Text {
|
||||
content: if changed {
|
||||
"Edited connection"
|
||||
} else {
|
||||
"Connection"
|
||||
}
|
||||
.into(),
|
||||
position: Point::new(116., 113.),
|
||||
color: style::INK,
|
||||
size: 11.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn warning(frame: &mut Frame, size: Size) {
|
||||
frame.fill_rectangle(
|
||||
Point::new(8., 8.),
|
||||
Size::new((size.width - 16.).max(0.), 30.),
|
||||
Color::WHITE,
|
||||
);
|
||||
frame.fill_text(Text {
|
||||
content: "1 connection route needs attention".into(),
|
||||
position: Point::new(16., 15.),
|
||||
color: style::MUTED,
|
||||
size: 12.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn legacy_geometry(
|
||||
renderer: &Renderer,
|
||||
size: Size,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
changed: bool,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut combined = Frame::new(renderer, size);
|
||||
legacy_background(&mut combined, size, zoom, pan);
|
||||
foreground(&mut combined, zoom, pan, changed);
|
||||
let mut overlay = Frame::new(renderer, size);
|
||||
warning(&mut overlay, size);
|
||||
vec![combined.into_geometry(), overlay.into_geometry()]
|
||||
}
|
||||
|
||||
fn cached_geometry(
|
||||
grid: &Grid,
|
||||
renderer: &Renderer,
|
||||
size: Size,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
changed: bool,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let background = grid.draw(renderer, size, zoom, pan);
|
||||
let mut content = Frame::new(renderer, size);
|
||||
foreground(&mut content, zoom, pan, changed);
|
||||
let mut overlay = Frame::new(renderer, size);
|
||||
warning(&mut overlay, size);
|
||||
vec![background, content.into_geometry(), overlay.into_geometry()]
|
||||
}
|
||||
|
||||
fn pixels(renderer: &mut Renderer, size: Size, geometry: Vec<canvas::Geometry>) -> Vec<u8> {
|
||||
// Keep physical screenshot dimensions and renderer viewport consistent even
|
||||
// for fractional canvas bounds; the geometry itself keeps its exact size.
|
||||
let physical = Size::new(size.width.ceil() as u32, size.height.ceil() as u32);
|
||||
renderer.reset(Rectangle::with_size(Size::new(
|
||||
physical.width as f32,
|
||||
physical.height as f32,
|
||||
)));
|
||||
for layer in geometry {
|
||||
renderer.draw_geometry(layer);
|
||||
}
|
||||
let pixels = Headless::screenshot(renderer, physical, 1., style::BG);
|
||||
assert_eq!(
|
||||
pixels.len(),
|
||||
physical.width as usize * physical.height as usize * 4
|
||||
);
|
||||
pixels
|
||||
}
|
||||
|
||||
fn assert_same_pixels(expected: &[u8], actual: &[u8], label: &str) {
|
||||
assert_eq!(expected.len(), actual.len(), "{label}: dimensions");
|
||||
let differing = expected
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(actual.as_chunks::<4>().0)
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
assert_eq!(differing, 0, "{label}: changed RGBA pixels");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_background_preserves_pixels_and_live_foreground_across_camera_and_size_changes() {
|
||||
let mut renderer = renderer().await;
|
||||
let grid = Grid::default();
|
||||
let original = Size::new(320., 240.);
|
||||
let threshold = 14. / 24.;
|
||||
for (label, size, zoom, pan) in [
|
||||
("origin", original, 1., Vector::ZERO),
|
||||
("positive pan", original, 1., Vector::new(17., 11.)),
|
||||
("negative pan", original, 1., Vector::new(-17., -11.)),
|
||||
("fractional pan", original, 1., Vector::new(-17.25, 11.5)),
|
||||
("zoom zero pan", original, 1.25, Vector::ZERO),
|
||||
("below threshold", original, threshold - 0.001, Vector::ZERO),
|
||||
("at threshold", original, threshold, Vector::ZERO),
|
||||
("above threshold", original, threshold + 0.001, Vector::ZERO),
|
||||
("width resize", Size::new(327., 240.), 1., Vector::ZERO),
|
||||
("height resize", Size::new(327., 247.), 1., Vector::ZERO),
|
||||
("return size", original, 1., Vector::ZERO),
|
||||
(
|
||||
"fractional bounds",
|
||||
Size::new(319.25, 239.5),
|
||||
1.,
|
||||
Vector::new(-0.5, 0.25),
|
||||
),
|
||||
] {
|
||||
let old = legacy_geometry(&renderer, size, zoom, pan, false);
|
||||
let expected = pixels(&mut renderer, size, old);
|
||||
let layers = cached_geometry(&grid, &renderer, size, zoom, pan, false);
|
||||
let actual = pixels(&mut renderer, size, layers);
|
||||
assert_same_pixels(&expected, &actual, label);
|
||||
let builds = grid.builds.get();
|
||||
let reused = cached_geometry(&grid, &renderer, size, zoom, pan, false);
|
||||
let reused = pixels(&mut renderer, size, reused);
|
||||
assert_same_pixels(&expected, &reused, label);
|
||||
assert_eq!(grid.builds.get(), builds, "{label}: screenshot reuse");
|
||||
|
||||
let edited_old = legacy_geometry(&renderer, size, zoom, pan, true);
|
||||
let edited_expected = pixels(&mut renderer, size, edited_old);
|
||||
assert_ne!(
|
||||
expected, edited_expected,
|
||||
"{label}: edit must visibly change foreground"
|
||||
);
|
||||
let edited = cached_geometry(&grid, &renderer, size, zoom, pan, true);
|
||||
let edited = pixels(&mut renderer, size, edited);
|
||||
assert_same_pixels(&edited_expected, &edited, label);
|
||||
assert_eq!(
|
||||
grid.builds.get(),
|
||||
builds,
|
||||
"{label}: foreground edit must reuse grid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_canvas_keeps_background_cached_while_selection_and_revision_change() {
|
||||
use super::super::{Context, DiagramCanvas, State};
|
||||
let mut renderer = renderer().await;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Sensor", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Actor, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let node = project.place(diagram, a, Position { x: 20., y: 50. });
|
||||
project.place(diagram, b, Position { x: 100., y: 160. });
|
||||
project.relate(RelationKind::CommunicationMean, "Link", a, b);
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: &project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: a,
|
||||
navigation_focus: None,
|
||||
zoom: 0.75,
|
||||
pan: Vector::ZERO,
|
||||
context: Context {
|
||||
document: project.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let state = State::default();
|
||||
let size = Size::new(360., 280.);
|
||||
let bounds = Rectangle::with_size(size);
|
||||
let draw = |canvas: &DiagramCanvas<'_>, renderer: &Renderer, bounds| {
|
||||
canvas::Program::draw(
|
||||
canvas,
|
||||
&state,
|
||||
renderer,
|
||||
&crate::style::theme(),
|
||||
bounds,
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
};
|
||||
let layers = draw(&canvas, &renderer, bounds);
|
||||
assert_eq!(layers.len(), 3, "background, scene, warning overlay");
|
||||
let original = pixels(&mut renderer, size, layers);
|
||||
assert_eq!(state.grid.builds.get(), 1);
|
||||
canvas.selected = b;
|
||||
let layers = draw(&canvas, &renderer, bounds);
|
||||
let selected = pixels(&mut renderer, size, layers);
|
||||
assert_ne!(original, selected, "selection must remain live");
|
||||
assert_eq!(state.grid.builds.get(), 1);
|
||||
let mut changed = project.clone();
|
||||
changed
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 30.;
|
||||
canvas.project = &changed;
|
||||
canvas.context.revision += 1;
|
||||
let layers = draw(&canvas, &renderer, bounds);
|
||||
let moved = pixels(&mut renderer, size, layers);
|
||||
assert_ne!(selected, moved, "new model revision must remain live");
|
||||
assert_eq!(state.grid.builds.get(), 1);
|
||||
canvas.pan.x = -0.25;
|
||||
let _ = draw(&canvas, &renderer, bounds);
|
||||
assert_eq!(state.grid.builds.get(), 2);
|
||||
let _ = draw(
|
||||
&canvas,
|
||||
&renderer,
|
||||
Rectangle::with_size(Size::new(361., 280.)),
|
||||
);
|
||||
assert_eq!(state.grid.builds.get(), 3);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,302 +0,0 @@
|
||||
//! 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,7 +53,6 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -178,25 +178,10 @@ 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 plain Delete to hide placements or Shift+Delete to review deletion from the model.".into())
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
@@ -71,7 +70,6 @@ impl Fixture {
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
Self {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
a,
|
||||
@@ -107,7 +105,6 @@ impl Fixture {
|
||||
_ => self.diagram,
|
||||
};
|
||||
DiagramCanvas {
|
||||
removal_input: &self.removal_input,
|
||||
selection: Some(selection),
|
||||
project: &self.project,
|
||||
view_index: None,
|
||||
|
||||
@@ -61,7 +61,6 @@ 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,13 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas<'a>(
|
||||
project: &'a Project,
|
||||
diagram: Id,
|
||||
zoom: f32,
|
||||
removal_input: &'a crate::removal_input::Lifetime,
|
||||
) -> DiagramCanvas<'a> {
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input,
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
@@ -64,8 +58,7 @@ fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, zoom, &removal_input);
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
@@ -98,8 +91,7 @@ fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let mut canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
@@ -155,8 +147,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
|
||||
@@ -177,7 +177,6 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -243,6 +243,21 @@ impl canvas::Program<Message> for WorkFeedback<'_> {
|
||||
let Some(frozen) = &feedback.frozen else {
|
||||
return vec![];
|
||||
};
|
||||
if let Some(delta) = feedback.ghost_delta {
|
||||
for region in &frozen.moving {
|
||||
let top = Point::new(
|
||||
(region.x + delta.x) * self.0.zoom + self.0.pan.x,
|
||||
(region.y + delta.y) * self.0.zoom + self.0.pan.y,
|
||||
);
|
||||
frame.stroke(
|
||||
&Path::rectangle(
|
||||
top,
|
||||
iced::Size::new(region.width * self.0.zoom, region.height * self.0.zoom),
|
||||
),
|
||||
Stroke::default().with_color(color).with_width(2.),
|
||||
);
|
||||
}
|
||||
}
|
||||
for index in &feedback.targets {
|
||||
let target = &frozen.targets[*index];
|
||||
let region = target.region;
|
||||
|
||||
@@ -184,10 +184,6 @@ impl Selection {
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ 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,7 +19,6 @@ 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 {
|
||||
@@ -35,7 +34,6 @@ impl Request {
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
scenario: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +55,6 @@ 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> {
|
||||
@@ -70,24 +67,9 @@ 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,
|
||||
@@ -97,7 +79,6 @@ impl Opened {
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
scenario_selection,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
@@ -118,15 +99,6 @@ 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
|
||||
@@ -137,19 +109,9 @@ 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)
|
||||
{
|
||||
@@ -197,17 +159,6 @@ 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
|
||||
@@ -221,56 +172,49 @@ 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| Message::Menu(Event::Open(actions_request(app, anchor))),
|
||||
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))
|
||||
},
|
||||
)
|
||||
.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;
|
||||
@@ -317,78 +261,6 @@ 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()
|
||||
{
|
||||
@@ -442,50 +314,22 @@ 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 || request.process.is_some() || request.canvas.is_some())
|
||||
&& selected
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
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(
|
||||
@@ -499,13 +343,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
entries.push(Entry::title(
|
||||
"Drag contributions within one column to reorder",
|
||||
));
|
||||
if let Some(removal) = crate::app::process_removal::begin(app) {
|
||||
entries.push(item(
|
||||
"Remove selected contributions / links…",
|
||||
removal,
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Hide unavailable · cards are model contributions",
|
||||
));
|
||||
} else {
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
@@ -513,11 +353,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
}
|
||||
if request.process.is_none()
|
||||
&& let Some(removal) = crate::app::saved_removal::begin(app)
|
||||
{
|
||||
entries.push(item("Delete selected from model…", removal, true));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
@@ -526,13 +364,34 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if qualified_definition.is_some() {
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,10 +798,6 @@ 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::*;
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! 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,27 +326,6 @@ 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![
|
||||
@@ -489,7 +468,6 @@ 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(),
|
||||
@@ -682,25 +660,17 @@ 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",
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Edit only {}",
|
||||
short(app.project().named(app.selected), 12)
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
+13
-1
@@ -2702,7 +2702,19 @@ performer change with diagram placement. The preview MUST identify the
|
||||
destination and preserve readable work and destination identity. Destination
|
||||
identity is shown outside the canvas, independently of explanatory or refusal
|
||||
text. The outlined target's hit region remains fixed throughout the gesture;
|
||||
feedback MUST NOT obscure the work or change its pointer-relative placement.
|
||||
feedback MUST NOT obscure the work. Preserve pointer-relative placement when
|
||||
the requested position is valid. When an outgoing drop would leave work
|
||||
overlapping its former performer, the editor MAY propose a minimal grid-aligned
|
||||
translation through a crossed performer boundary, with one grid unit of
|
||||
clearance. It MUST preview that exact position, translate the complete affected
|
||||
work group rigidly, and recheck all semantic and collision constraints on release.
|
||||
It MUST NOT move unrelated placements or search arbitrary free space. If no
|
||||
proposal is valid, a pointer-following outline MAY accompany the last valid
|
||||
preview; that retained preview MUST NOT authorize a refused release.
|
||||
Outgoing work headers MAY be partly outside the viewport provided each retains
|
||||
at least a 24-by-24 logical-pixel grab region. Releasing the pointer outside the
|
||||
viewport still cancels. Other assignment and Arrange gestures retain their
|
||||
complete-header visibility requirement.
|
||||
A clean click selects without applying unrelated drafts. A refused work press
|
||||
with an unapplied property draft MUST preserve its text, caret and focused
|
||||
editor. An accepted drag is one atomic model/source/history transaction:
|
||||
|
||||
+13
-43
@@ -2128,7 +2128,19 @@ performer change with diagram placement. The preview MUST identify the
|
||||
destination and preserve readable work and destination identity. Destination
|
||||
identity is shown outside the canvas, independently of explanatory or refusal
|
||||
text. The outlined target's hit region remains fixed throughout the gesture;
|
||||
feedback MUST NOT obscure the work or change its pointer-relative placement.
|
||||
feedback MUST NOT obscure the work. Preserve pointer-relative placement when
|
||||
the requested position is valid. When an outgoing drop would leave work
|
||||
overlapping its former performer, the editor MAY propose a minimal grid-aligned
|
||||
translation through a crossed performer boundary, with one grid unit of
|
||||
clearance. It MUST preview that exact position, translate the complete affected
|
||||
work group rigidly, and recheck all semantic and collision constraints on release.
|
||||
It MUST NOT move unrelated placements or search arbitrary free space. If no
|
||||
proposal is valid, a pointer-following outline MAY accompany the last valid
|
||||
preview; that retained preview MUST NOT authorize a refused release.
|
||||
Outgoing work headers MAY be partly outside the viewport provided each retains
|
||||
at least a 24-by-24 logical-pixel grab region. Releasing the pointer outside the
|
||||
viewport still cancels. Other assignment and Arrange gestures retain their
|
||||
complete-header visibility requirement.
|
||||
A clean click selects without applying unrelated drafts. A refused work press
|
||||
with an unapplied property draft MUST preserve its text, caret and focused
|
||||
editor. An accepted drag is one atomic model/source/history transaction:
|
||||
@@ -2279,28 +2291,6 @@ 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
|
||||
@@ -2314,26 +2304,6 @@ 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
|
||||
|
||||
+7
-1
@@ -25,7 +25,13 @@ the current code includes that one-line correction. The original compile failed
|
||||
before running tests. The first combined compile at `897d9f04` subsequently
|
||||
found a missing `text_editor` import in the Allocation Text Apply regression;
|
||||
the current source includes that one-line correction too. Both failures remain
|
||||
recorded in the source handoff. Corrected combined execution remains pending.
|
||||
recorded in the source handoff. The subsequent full test run at `8ddfadf4`
|
||||
failed the mounted State picker fixture: added selection controls invalidated
|
||||
its canvas-relative clicks. Source successor `a9cd364eacbbffa0693bab05b5a2b65f9d105535`
|
||||
measures an identified fixed-height picker slot and checks Escape capture,
|
||||
retaining the exact preservation/reopen/Place/Undo assertions. The source
|
||||
handoff links the preserved failed and interrupted attempts. Corrected combined
|
||||
Windows execution and package acceptance remain pending.
|
||||
|
||||
Live build, final-source and publication results belong in
|
||||
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Outgoing allocation dragging
|
||||
|
||||
Scope: [issue 40](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/40),
|
||||
based on published Windows source `715036e132677dd2bc2fb37ed316d93dc9987aec`.
|
||||
This contribution is not integrated release acceptance. User requirements remain
|
||||
in `PROJECT_GOAL.md`; this note records a revision to derived interaction policy.
|
||||
|
||||
## Reproduction and cause
|
||||
|
||||
The released executable SHA256
|
||||
`2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399`
|
||||
opened a copied Allocation/Process companion fixture on Windows. At 1440x900,
|
||||
72% Fit, the OA assign view's X-receive was dragged from window (486,546) to
|
||||
(365,546), just beyond A's left edge. It remained assigned at its original
|
||||
position, reporting enlarged card overlap. A control drag to (625,572) removed
|
||||
assignment; Undo restored the original scene. These are actual native end-state
|
||||
observations; intermediate jitter was diagnosed from source, not frame capture.
|
||||
|
||||
Frozen targets resolve from the pointer, while placement retains its grab
|
||||
offset. The pointer therefore leaves its performer before the full work card
|
||||
does. Former parent/child intersection becomes unrelated overlap after removing
|
||||
allocation. Refused motion previously cleared the preview and redrew the saved
|
||||
scene. Additionally, whole-header viewport containment rejects a useful outgoing
|
||||
position in the fitted reproduction even when its model geometry is valid.
|
||||
|
||||
## Selected policy
|
||||
|
||||
Try exact snapped pointer placement first. Only an outgoing assignment candidate
|
||||
with a placement refusal may propose bounded source clearance: move the entire
|
||||
captured work footprint through a crossed frozen source boundary, clearing the
|
||||
post-removal source enclosure by one 10-world-unit grid cell. Consider at most
|
||||
four directions, ordered by press-to-pointer boundary-crossing fraction and a
|
||||
fixed direction tie-break. Existing placement, collision, semantic, exact-command
|
||||
and source/history admission remain authoritative. No neighbor is moved, no
|
||||
general packing/search occurs, and no semantic refusal is overridden.
|
||||
|
||||
The accepted/candidate semantic indexes are reused during fallback projections.
|
||||
Outgoing headers may be partly clipped but each must retain a 24x24 logical-pixel
|
||||
grab region. Other assignment and Arrange visibility rules remain unchanged.
|
||||
The exact corrected candidate is previewed; release recomputes it. Blocked
|
||||
in-bounds motion retains its last admitted scene and adds an orange pointer
|
||||
outline. This display state confers no permission to commit on refused release.
|
||||
Cancel, outside release and gesture invalidation retire it normally.
|
||||
|
||||
Rejected alternatives: retaining a moving outline alone leaves the boundary
|
||||
dead band; allowing unrelated overlap makes unassigned work look allocated;
|
||||
general auto-layout changes unrelated placement and makes drag intent harder to
|
||||
predict. The previous unconditional pointer-offset rule is narrowed in
|
||||
`docs/language.rst` and its HTML copy. Real neighboring-card collisions and
|
||||
insufficient visible grab area can still refuse a drop.
|
||||
|
||||
## Verification status
|
||||
|
||||
Independent source diagnosis and repair critique found no semantic/stale/cancel
|
||||
bypass. Review exposed the remaining whole-header camera restriction and an
|
||||
obsolete retained-preview assertion; both were revised. Added checks cover
|
||||
OA/SA boundary clearance, corners, source shrinkage, rigid descendants/groups,
|
||||
real App frame-by-frame input, exact source/history, cancellation, blocked release
|
||||
and outgoing grab-area boundaries. At clean source
|
||||
`1a669130fb6d2b65279aab3847e966a1a282c191`, the Windows allocation-drag
|
||||
suite passes 121 tests (none failed or ignored; 1,424 filtered out), strict
|
||||
desktop all-target Clippy passes, and the normal desktop development build
|
||||
passes. [Raw results and reproduction commands](experiments/drag-out-20260925/README.md)
|
||||
preserve the source, platform, failed attempts and exact scope. These checks do
|
||||
not prove native usability. Computer Use was stopped with the physical Escape
|
||||
key before the changed executable could be inspected. Fitted-view dragging,
|
||||
clipped-card identification/regrabbing, native Undo/Redo and Save/reopen remain
|
||||
unverified. The issue remains open; no integration or release acceptance follows
|
||||
from the targeted gates.
|
||||
|
||||
Test-only successor `24b1eab07d306d8cb091bb46f7daf9f027a8f1c0` additionally
|
||||
checks an actual fallback blocked by unrelated work, including preservation of
|
||||
an existing redo branch, and an outgoing move while an allocated sibling keeps
|
||||
the source enclosure expanded. Both run for OA and SA. All 123 allocation-drag
|
||||
tests and strict desktop Clippy pass at that successor. Fresh independent review
|
||||
confirms those tests exercise their claimed invariants, with no material source
|
||||
finding in this scope. The same linked evidence includes the new logs and review;
|
||||
production code and native-acceptance limits remain unchanged.
|
||||
|
||||
The first build attempt failed before tests because the sparse checkout omitted
|
||||
`build/windows_manifest.rs`; restoring the tracked build directory corrected the
|
||||
checkout. The local drive initially had about 22 MB free. Git sparse checkout and
|
||||
Cargo's package-scoped cleanup of inactive desktop artifacts recovered capacity.
|
||||
Automatic review rejected recursive deletion of the extracted preview; it was
|
||||
left intact. No published package or unique evidence was deleted.
|
||||
The second attempt also exposed omitted tracked harness fixtures and a `Self`
|
||||
typo in a standalone test helper; both were corrected before the passing run.
|
||||
That failed compiler process was terminated after its errors were captured.
|
||||
@@ -0,0 +1 @@
|
||||
*.log -text
|
||||
@@ -0,0 +1,91 @@
|
||||
# Small architecture diagram CPU investigation
|
||||
|
||||
Question: does core projection/routing explain [issue 44's low FPS](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/44)
|
||||
with roughly eight architecture cards? This experiment narrows that question;
|
||||
it neither reproduces native frame rate nor establishes a complete cause.
|
||||
|
||||
## Workload and source
|
||||
|
||||
The screenshot uses names from `examples/release1`. Its exact model, projection,
|
||||
camera and authored geometry are unavailable. The probe loads the unchanged
|
||||
`examples/release1/model.json`, measures its five saved views, and constructs a
|
||||
clearly labeled eight-card SA Overview approximation using those semantic
|
||||
elements. Approximate coordinates and stable placement IDs are in the probe;
|
||||
it has 8 projected nodes, 6 ports and 13 projected edges. It is not a recovered
|
||||
copy of the user's diagram. All data changes occur in memory.
|
||||
|
||||
Source: `24b1eab07d306d8cb091bb46f7daf9f027a8f1c0`; core, manifest and lockfile
|
||||
are unchanged from the drag contribution's `1a669130` production source.
|
||||
Windows 10.0.26200, Rust 1.98.1, x86_64-pc-windows-gnullvm, LLVM-MinGW
|
||||
20260908 UCRT, reported CPU Intel Core i7-10870H with 4 exposed cores/threads.
|
||||
The probe was optimized with `-O` and linked to the existing **unoptimized
|
||||
development-profile core library** used by the completed desktop test build.
|
||||
This distinction matters: optimizing the driver does not optimize that library.
|
||||
The desktop test process had completed before measurement; no Cargo compilation
|
||||
was started until after measurement. Other operating-system workloads were not
|
||||
controlled, so outliers are retained rather than interpreted as stable capacity.
|
||||
|
||||
## Method and results
|
||||
|
||||
Each phase gets 3 warmups and 40 timed samples, with returned values passed to
|
||||
`black_box`. Reported p50/p95 select sorted index `ceil((n-1)*p)` as implemented
|
||||
in the source. Returned output destruction is outside each timed phase. Diagram
|
||||
movement uses a deterministic small delta sequence and the existing descendant
|
||||
and authored-route translation APIs. Index creation, projection, routing, and
|
||||
combined movement preparation are measured separately. The probe does not model
|
||||
allocation transaction admission or an Iced frame.
|
||||
|
||||
For the eight-card approximation, `performance-01.log` records:
|
||||
|
||||
| Phase | p50 ms | p95 ms | max ms |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Reused-index projection | 0.3885 | 0.7550 | 1.2965 |
|
||||
| Fresh-index projection | 1.7135 | 2.7022 | 4.2204 |
|
||||
| Settled routing | 2.4525 | 4.4617 | 4.7735 |
|
||||
| Moved routing | 3.1907 | 4.4781 | 4.5672 |
|
||||
| Movement preparation + projection + routing | 4.2640 | 6.8259 | 8.8052 |
|
||||
|
||||
This workload does not expose severe core latency sufficient by itself to
|
||||
explain very low FPS. It does not rule out different authored routes, allocation
|
||||
admission, hardware/rendering, or the user's exact scene. Repeated fresh-index
|
||||
projection has a measurable cost, but no change is justified solely by this
|
||||
small sample. No FPS or performance-fix claim follows.
|
||||
|
||||
## Source observations and next discriminating measurement
|
||||
|
||||
Independent read-only hot-path review found that `DiagramCanvas::scene` already
|
||||
caches projection and routing by semantic context and transient geometry.
|
||||
Ordinary node movement changes preview geometry and routes the full view;
|
||||
route gestures use `route_edge_preview`. Ordinary hover need not reroute.
|
||||
|
||||
`diagram/port_labels.rs` requests redraw on every mouse event, including when
|
||||
no tooltip is visible. `DiagramCanvas::draw` rebuilds geometry and each dot-grid
|
||||
circle on every draw. At a 1900x660 canvas this yields about 2,240 circles at
|
||||
zoom 1 and 6,528 at minimum spacing. Scene caching does not cache that geometry.
|
||||
`views.rs` also performs an unindexed `project_view` during app view rebuilding;
|
||||
legacy node-motion redraw does not itself require that full UI rebuild.
|
||||
|
||||
Next, measure actual canvas draw/frame costs independently for idle hover, pan,
|
||||
ordinary node drag and allocation drag, with grid generation separated from
|
||||
node/edge/text rendering and frame presentation. Compare the same scene/camera
|
||||
and renderer before changing production code. Native automation was stopped
|
||||
with Escape; no GPU/frame measurement was attempted in this experiment. Live
|
||||
ownership and subsequent results belong to issue 44.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Build `oa-core` at the identified source with the stated Windows toolchain, then
|
||||
compile this standalone probe against that exact library and matching dependency
|
||||
directory. For a fresh standard Cargo development build:
|
||||
|
||||
```powershell
|
||||
cargo build --locked -p oa-core
|
||||
rustc --edition=2024 -O harness/experiments/architecture-perf-20260925/architecture_perf_probe.rs --extern oa_core=target/debug/liboa_core.rlib -L dependency=target/debug/deps -C linker=clang.exe -o architecture_perf_probe.exe
|
||||
./architecture_perf_probe.exe examples/release1/model.json 40
|
||||
```
|
||||
|
||||
The actual run linked `target/debug/deps/liboa_core-2f3c5174cb6398fd.rlib`
|
||||
and used the same dependency directory. Set the linker/runtime PATH as required
|
||||
by the Windows toolchain. Compiler flags, core profile, machine load and fixture
|
||||
all affect timings; preserve them when comparing candidates. This standalone
|
||||
probe is experimental evidence, not a production dependency or a timing test.
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Local-only CPU diagnosis. Does not measure native FPS, canvas redraw, or GPU.
|
||||
//! Usage: architecture_perf_probe.exe PATH_TO_RELEASE1_MODEL_JSON [SAMPLES=40]
|
||||
use oa_core::{persistence, projection, routing, view_options::ViewIndex, *};
|
||||
use std::{collections::BTreeMap, hint::black_box, time::Instant};
|
||||
|
||||
fn measure<T>(label: &str, samples: usize, mut f: impl FnMut(usize) -> T) {
|
||||
for i in 0..3 {
|
||||
black_box(f(i));
|
||||
}
|
||||
let mut ms = Vec::with_capacity(samples);
|
||||
for i in 0..samples {
|
||||
let start = Instant::now();
|
||||
let output = black_box(f(i));
|
||||
ms.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
drop(output); // Exclude destruction consistently from each timed phase.
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let percentile = |p: f64| ms[((samples - 1) as f64 * p).ceil() as usize];
|
||||
println!(
|
||||
"{label}: n={samples} p50_ms={:.4} p95_ms={:.4} max_ms={:.4}",
|
||||
percentile(0.5),
|
||||
percentile(0.95),
|
||||
ms[samples - 1]
|
||||
);
|
||||
}
|
||||
|
||||
fn probe(p: &Project, d: &Diagram, samples: usize) {
|
||||
let index = ViewIndex::new(p);
|
||||
let view = projection::project_diagram_with_index(p, d, &index);
|
||||
println!(
|
||||
"\nview={} projection={:?} nodes={} ports={} edges={}",
|
||||
d.name,
|
||||
d.projection,
|
||||
view.nodes.len(),
|
||||
view.ports.len(),
|
||||
view.edges.len()
|
||||
);
|
||||
measure("index_new", samples, |_| ViewIndex::new(black_box(p)));
|
||||
measure("projection_reused_index", samples, |_| {
|
||||
projection::project_diagram_with_index(black_box(p), black_box(d), black_box(&index))
|
||||
});
|
||||
measure("projection_fresh_index", samples, |_| {
|
||||
projection::project_diagram(black_box(p), black_box(d))
|
||||
});
|
||||
measure("routing_settled", samples, |_| {
|
||||
routing::route_diagram(black_box(p), black_box(d), black_box(&view))
|
||||
});
|
||||
// Deterministic slight motion of the first root card. Includes the same
|
||||
// descendant/route translation as legacy single-card canvas drag. This is
|
||||
// presentation-only, not the allocation transaction pipeline.
|
||||
let Some(root) = view.nodes.iter().find(|n| n.visual_parent.is_none()) else {
|
||||
return;
|
||||
};
|
||||
println!("moving_root={} element={}", root.id, p.named(root.element));
|
||||
let mut moved = projection::descendant_nodes(p, d.id, root.id);
|
||||
moved.push(root.id);
|
||||
let make_moved = |i: usize| {
|
||||
let delta = Position {
|
||||
x: (i % 21) as f32 * 3.0,
|
||||
y: (i % 11) as f32 * 2.0,
|
||||
};
|
||||
let mut preview = d.clone();
|
||||
let deltas = moved
|
||||
.iter()
|
||||
.map(|id| (*id, delta))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for route in projection::translated_routes(p, d, &deltas) {
|
||||
if let Some(stored) = preview.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*stored = route;
|
||||
} else {
|
||||
preview.routes.push(route);
|
||||
}
|
||||
}
|
||||
for id in &moved {
|
||||
if let Some(n) = preview.nodes.get_mut(id) {
|
||||
n.position.x += delta.x;
|
||||
n.position.y += delta.y;
|
||||
}
|
||||
}
|
||||
preview
|
||||
};
|
||||
let previews = (0..samples.max(3)).map(make_moved).collect::<Vec<_>>();
|
||||
let projected = previews
|
||||
.iter()
|
||||
.map(|d| projection::project_diagram_with_index(p, d, &index))
|
||||
.collect::<Vec<_>>();
|
||||
measure("moved_projection", samples, |i| {
|
||||
projection::project_diagram_with_index(p, black_box(&previews[i]), &index)
|
||||
});
|
||||
measure("moved_routing", samples, |i| {
|
||||
routing::route_diagram(p, black_box(&previews[i]), black_box(&projected[i]))
|
||||
});
|
||||
measure("moved_prepare_projection_routing", samples, |i| {
|
||||
let preview = make_moved(i);
|
||||
let view = projection::project_diagram_with_index(p, &preview, &index);
|
||||
routing::route_diagram(p, &preview, &view)
|
||||
});
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
let path = args
|
||||
.get(1)
|
||||
.ok_or("usage: architecture_perf_probe PATH [SAMPLES]")?;
|
||||
let samples = args
|
||||
.get(2)
|
||||
.map(|s| s.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or(40)
|
||||
.max(1);
|
||||
let p = persistence::decode(&std::fs::read(path)?)?;
|
||||
println!(
|
||||
"CPU-only probe; samples={samples}; fixture={path}; elements={} relations={}",
|
||||
p.elements.len(),
|
||||
p.relations.len()
|
||||
);
|
||||
for d in p.diagrams.values() {
|
||||
probe(&p, d, samples);
|
||||
}
|
||||
// Approximation of the reported eight-card screenshot, not an exact repro:
|
||||
// fixture semantics, Overview projection, synthetic positions/card sizes.
|
||||
let mut approx = p.clone();
|
||||
let id: Id = "f0440000-0000-4000-8000-000000000001".parse()?;
|
||||
approx.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: "fps44_approx_overview".into(),
|
||||
name: "APPROXIMATION eight-card SA Overview".into(),
|
||||
description: "Synthetic positions, not user geometry".into(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
for (ordinal, (alias, x, y)) in [
|
||||
("sa_assessment_procedure", 30., 240.),
|
||||
("sa_triage", 510., 240.),
|
||||
("sa_control", 990., 240.),
|
||||
("sa_technician", 530., 440.),
|
||||
("sa_assess", 1490., 240.),
|
||||
("sa_repair", 1030., 440.),
|
||||
("sa_confirm", 1530., 440.),
|
||||
("sa_reset", 30., 440.),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let element = approx
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == alias)
|
||||
.ok_or_else(|| format!("missing alias {alias}"))?
|
||||
.id;
|
||||
let generated = approx.place(id, element, Position { x, y });
|
||||
let diagram = approx.diagrams.get_mut(&id).unwrap();
|
||||
let mut node = diagram.nodes.remove(&generated).unwrap();
|
||||
node.id = format!("f0440000-0000-4000-8000-{:012}", ordinal + 2).parse()?;
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
probe(&approx, &approx.diagrams[&id], samples);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"source": "24b1eab07d306d8cb091bb46f7daf9f027a8f1c0",
|
||||
"core_profile": "dev unoptimized",
|
||||
"driver_optimization": "rustc -O",
|
||||
"samples_per_phase": 40,
|
||||
"warmups_per_phase": 3,
|
||||
"rlib_sha256": "E10C81BF28A33CD0FB6C4C26884CFFA480EF05328E78BCDB138DD4B635B2531C",
|
||||
"fixture_sha256": "EF0EF4006C3E09D5DB6E1B621256B3F7D6642F3A7B09CC34607CFBCCA830C727",
|
||||
"driver_exe_sha256": "4C66E9CDE4F08D6DFD2A0DC3DBF1A80B97E340F3F22CA001A76301F06F47C2E2",
|
||||
"native_fps_measured": false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
CPU-only probe; samples=40; fixture=.local/drag-out-01a0d6fd/examples/release1/model.json; elements=94 relations=62
|
||||
|
||||
view=1 · OA responsibility and repair traffic projection=Allocation nodes=6 ports=0 edges=3
|
||||
index_new: n=40 p50_ms=1.0106 p95_ms=2.0583 max_ms=2.0910
|
||||
projection_reused_index: n=40 p50_ms=0.5702 p95_ms=1.2320 max_ms=1.3490
|
||||
projection_fresh_index: n=40 p50_ms=1.5505 p95_ms=3.6681 max_ms=5.1533
|
||||
routing_settled: n=40 p50_ms=0.1944 p95_ms=0.3391 max_ms=0.4246
|
||||
moving_root=3191e05c-1c7f-5f0e-beb0-5f3bded54c48 element=Control room
|
||||
moved_projection: n=40 p50_ms=0.4848 p95_ms=1.2745 max_ms=1.5797
|
||||
moved_routing: n=40 p50_ms=0.1994 p95_ms=0.7971 max_ms=1.1414
|
||||
moved_prepare_projection_routing: n=40 p50_ms=0.9250 p95_ms=2.6847 max_ms=10.4908
|
||||
|
||||
view=5 · SA pressure supervision projection=StateMachine { machine: 132b3f28-3f88-5327-9868-8c8b645ed412 } nodes=5 ports=0 edges=3
|
||||
index_new: n=40 p50_ms=0.9602 p95_ms=2.1394 max_ms=2.4512
|
||||
projection_reused_index: n=40 p50_ms=0.1697 p95_ms=0.7629 max_ms=1.1780
|
||||
projection_fresh_index: n=40 p50_ms=1.1189 p95_ms=2.6324 max_ms=3.3912
|
||||
routing_settled: n=40 p50_ms=0.7063 p95_ms=1.0812 max_ms=1.1149
|
||||
moving_root=a676fbb9-0833-59a8-98cf-a10d8f4b7434 element=Pressure supervision
|
||||
moved_projection: n=40 p50_ms=0.1565 p95_ms=0.3445 max_ms=0.3790
|
||||
moved_routing: n=40 p50_ms=0.6113 p95_ms=1.3199 max_ms=1.3733
|
||||
moved_prepare_projection_routing: n=40 p50_ms=0.8259 p95_ms=1.7640 max_ms=2.1752
|
||||
|
||||
view=2 · SA service boundary and functions projection=Allocation nodes=6 ports=6 edges=3
|
||||
index_new: n=40 p50_ms=0.7820 p95_ms=3.4180 max_ms=6.8439
|
||||
projection_reused_index: n=40 p50_ms=0.7938 p95_ms=1.9946 max_ms=2.1411
|
||||
projection_fresh_index: n=40 p50_ms=2.0597 p95_ms=3.4554 max_ms=3.6616
|
||||
routing_settled: n=40 p50_ms=0.1478 p95_ms=0.7886 max_ms=1.0081
|
||||
moving_root=8345c76a-8d85-5c31-902e-ec1cd33ed4a3 element=Pressure coordination service
|
||||
moved_projection: n=40 p50_ms=0.6914 p95_ms=1.5286 max_ms=1.8658
|
||||
moved_routing: n=40 p50_ms=0.1862 p95_ms=0.5658 max_ms=2.8924
|
||||
moved_prepare_projection_routing: n=40 p50_ms=1.1724 p95_ms=3.1806 max_ms=4.2620
|
||||
|
||||
view=4 · SA mission and included capability projection=Capabilities nodes=4 ports=1 edges=3
|
||||
index_new: n=40 p50_ms=0.6879 p95_ms=1.3582 max_ms=1.5266
|
||||
projection_reused_index: n=40 p50_ms=0.1562 p95_ms=0.6767 max_ms=1.0102
|
||||
projection_fresh_index: n=40 p50_ms=1.2929 p95_ms=2.3543 max_ms=4.0762
|
||||
routing_settled: n=40 p50_ms=0.2553 p95_ms=1.4389 max_ms=1.6677
|
||||
moving_root=069a3fb3-077c-59d8-91da-27b0665143c7 element=Assess an alarm
|
||||
moved_projection: n=40 p50_ms=0.1243 p95_ms=0.1928 max_ms=0.2212
|
||||
moved_routing: n=40 p50_ms=0.2271 p95_ms=0.6733 max_ms=1.2408
|
||||
moved_prepare_projection_routing: n=40 p50_ms=0.5542 p95_ms=1.3344 max_ms=1.8957
|
||||
|
||||
view=3 · SA service conversation only projection=Allocation nodes=6 ports=6 edges=1
|
||||
index_new: n=40 p50_ms=0.9618 p95_ms=1.6890 max_ms=2.0995
|
||||
projection_reused_index: n=40 p50_ms=0.6661 p95_ms=2.3388 max_ms=2.6429
|
||||
projection_fresh_index: n=40 p50_ms=2.3913 p95_ms=4.1941 max_ms=4.8120
|
||||
routing_settled: n=40 p50_ms=0.0660 p95_ms=0.2156 max_ms=0.6289
|
||||
moving_root=40210589-69f7-5209-b329-10bb83cf615c element=Field technician
|
||||
moved_projection: n=40 p50_ms=0.5527 p95_ms=1.1239 max_ms=1.1362
|
||||
moved_routing: n=40 p50_ms=0.0495 p95_ms=0.1771 max_ms=0.2209
|
||||
moved_prepare_projection_routing: n=40 p50_ms=0.7963 p95_ms=4.1835 max_ms=6.7551
|
||||
|
||||
view=APPROXIMATION eight-card SA Overview projection=Overview nodes=8 ports=6 edges=13
|
||||
index_new: n=40 p50_ms=0.7487 p95_ms=1.9597 max_ms=2.2005
|
||||
projection_reused_index: n=40 p50_ms=0.3885 p95_ms=0.7550 max_ms=1.2965
|
||||
projection_fresh_index: n=40 p50_ms=1.7135 p95_ms=2.7022 max_ms=4.2204
|
||||
routing_settled: n=40 p50_ms=2.4525 p95_ms=4.4617 max_ms=4.7735
|
||||
moving_root=f0440000-0000-4000-8000-000000000002 element=Assess an alarm procedure
|
||||
moved_projection: n=40 p50_ms=0.4691 p95_ms=0.8354 max_ms=1.0408
|
||||
moved_routing: n=40 p50_ms=3.1907 p95_ms=4.4781 max_ms=4.5672
|
||||
moved_prepare_projection_routing: n=40 p50_ms=4.2640 p95_ms=6.8259 max_ms=8.8052
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user