Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu d8db66080d Publish interrupted saved-removal checkpoint and remaining verification 2026-09-24 22:41:51 -07:00
sorokin.oleg.edu fa9d89efad Close expired menus and cancel noneditable sequence gestures 2026-09-24 22:38:52 -07:00
sorokin.oleg.edu b137d45727 Simplify saved group context action admission 2026-09-24 22:29:55 -07:00
sorokin.oleg.edu 84cd5ef56c Retire deleted saved-view owners after group model removal 2026-09-24 22:27:21 -07:00
sorokin.oleg.edu 1b683ea336 Complete canvas input wrappers and verify saved-removal event batches 2026-09-24 22:18:12 -07:00
sorokin.oleg.edu 3ab63ef028 Wire captured saved-model group deletion and shared impact review 2026-09-24 22:12:36 -07:00
sorokin.oleg.edu 8688faf8c5 Record saved-view removal planner Windows verification 2026-09-24 21:31:53 -07:00
sorokin.oleg.edu 7750ecd751 Plan whole saved-view model deletion from exact visible groups 2026-09-24 21:24:43 -07:00
sorokin.oleg.edu afcea12cd9 Verify compact Process removal workflow and record failure boundary 2026-09-24 21:10:39 -07:00
sorokin.oleg.edu 7323a80e94 Record Process removal Windows and native verification 2026-09-24 20:56:06 -07:00
sorokin.oleg.edu 572e96ba03 Use standard parity and saturating generation operations 2026-09-24 20:29:46 -07:00
sorokin.oleg.edu d93c0a8012 Initialize Process literal fixture with its source workspace 2026-09-24 20:25:17 -07:00
sorokin.oleg.edu f20497b29b Preserve qualified menu navigation and correct literal source trivia oracle 2026-09-24 20:17:07 -07:00
sorokin.oleg.edu fe637dcd27 Add atomic Process group removal with explicit reuse and ordering impact 2026-09-24 20:09:26 -07:00
sorokin.oleg.edu c62622cce7 Record native keyboard entry and source-budget failure verification 2026-09-24 19:42:21 -07:00
sorokin.oleg.edu 2aaea8a3a0 Exercise real source-budget refusal in Scenario group removal 2026-09-24 19:34:15 -07:00
sorokin.oleg.edu f0e8746950 Document verified Scenario group removal and native recovery 2026-09-24 19:20:01 -07:00
sorokin.oleg.edu 43340b94a1 Box captured removal selection for compact review events 2026-09-24 18:49:23 -07:00
sorokin.oleg.edu 35be9ae133 Update group removal menu and redo regression fixtures 2026-09-24 18:43:56 -07:00
sorokin.oleg.edu 81db58c8b3 Resolve removal menu import and share visibility admission 2026-09-24 18:39:58 -07:00
sorokin.oleg.edu ed6eaaa73e Add captured Scenario group removal and aggregate impact review 2026-09-24 18:38:05 -07:00
sorokin.oleg.edu cdd5a0462b Document verified Scenario reorder App and native workflows 2026-09-24 18:22:29 -07:00
sorokin.oleg.edu 1c4099a9ef Retire reorder result after a subsequent spacing transaction 2026-09-24 17:49:05 -07:00
sorokin.oleg.edu 201097793c Fix native reorder preview clipping and result lifetime 2026-09-24 17:45:13 -07:00
sorokin.oleg.edu 14df924b98 Reuse grapheme-safe summary labels for scenario reorder 2026-09-24 17:27:17 -07:00
sorokin.oleg.edu 04485e0017 Match focus discovery operation to installed Iced trait 2026-09-24 17:22:21 -07:00
sorokin.oleg.edu 9bfa113aa5 Guard reorder shortcuts and focus against current overlays and stale tasks 2026-09-24 17:20:52 -07:00
sorokin.oleg.edu 652ffb4d1b Add explicit keyboard preview and apply for scenario reorder 2026-09-24 17:16:03 -07:00
sorokin.oleg.edu c9f6a3da40 Correct automatic-view and exact-budget reorder test fixtures 2026-09-24 17:11:10 -07:00
sorokin.oleg.edu f3ed6e97dd Isolate reorder chooser input and retain exact sequence feedback 2026-09-24 17:05:41 -07:00
sorokin.oleg.edu 61099ed474 Add explicit scenario group reorder App interaction and guarded preview 2026-09-24 17:00:22 -07:00
sorokin.oleg.edu a16e014049 Record verified Scenario destination ambiguity and reorder session evidence 2026-09-24 16:50:09 -07:00
sorokin.oleg.edu cae7a22297 Exercise exact Scenario reorder sessions and geometric destination candidates 2026-09-24 16:42:27 -07:00
sorokin.oleg.edu 5381729869 Record saved Scenario group spacing validation and native evidence 2026-09-24 16:33:09 -07:00
sorokin.oleg.edu 8d6fac4701 Distinguish saved group spacing from semantic reorder in UI guidance 2026-09-24 16:05:45 -07:00
sorokin.oleg.edu 4a27c8ef0b Make group spacing oracle coordinate types explicit 2026-09-24 16:01:52 -07:00
sorokin.oleg.edu 67534ce4a5 Correct group spacing fixture and captured-input expectations 2026-09-24 15:49:10 -07:00
sorokin.oleg.edu 3b113e5316 Move homogeneous saved Scenario selections as a group 2026-09-24 15:40:23 -07:00
sorokin.oleg.edu 3b2bb8ba78 Document verified Scenario selection adapter and native evidence 2026-09-24 14:55:30 -07:00
sorokin.oleg.edu ed55b1781d Bind Scenario selection capture directly for strict lint 2026-09-24 14:28:53 -07:00
sorokin.oleg.edu ca1f18e6b0 Retire Scenario selection input without invalidating new reveal contexts 2026-09-24 00:43:42 -07:00
sorokin.oleg.edu a0a70340a6 Preserve background pan and retire legacy Scenario selection gestures 2026-09-24 00:34:35 -07:00
sorokin.oleg.edu 879eb1d5c8 Wire exact Scenario group selection and contextual admission 2026-09-24 00:24:08 -07:00
sorokin.oleg.edu 47917dd0ef Record Scenario planner verification and adapter design boundaries 2026-09-24 00:11:15 -07:00
sorokin.oleg.edu 7d3d7b23e7 Add Scenario sibling-block planner on integrated presentation model 2026-09-23 23:56:30 -07:00
253 changed files with 38723 additions and 3156 deletions
+15
View File
@@ -12,6 +12,12 @@
*.png binary
*.jpg binary
*.zip binary
harness/scenario-selection-evidence-20260924/** -text whitespace=cr-at-eol
harness/scenario-group-spacing-evidence-20260924/** -text whitespace=cr-at-eol
harness/scenario-reorder-interaction-evidence-20260924/** -text whitespace=cr-at-eol
harness/scenario-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
harness/scenario-removal-followup-evidence-20260925/** -text whitespace=cr-at-eol
harness/fixtures/process-removal-removed-v2-20260925.arcadia -text whitespace=-blank-at-eol
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
@@ -89,3 +95,12 @@ harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-bl
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
# Preserve exact Process removal review and portable evidence bytes.
harness/process-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
# Preserve exact saved-view planner review and portable evidence bytes.
harness/saved-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
# Preserve exact saved-view application check records.
harness/saved-group-removal-app-evidence-20260925/** -text whitespace=cr-at-eol
@@ -1,785 +0,0 @@
//! Experimental observer of Command::Layout; no quality thresholds or candidate algorithm.
use oa_core::{
projection::{self, ProjectedView},
routing, *,
};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[path = "auto_layout_probe/layered.rs"]
mod layered;
type P = (f64, f64);
type Rect = (f64, f64, f64, f64);
const EPS: f64 = 0.0001;
fn p(v: Position) -> P {
(v.x as f64, v.y as f64)
}
fn sub(a: P, b: P) -> P {
(a.0 - b.0, a.1 - b.1)
}
fn dot(a: P, b: P) -> f64 {
a.0 * b.0 + a.1 * b.1
}
fn cross(a: P, b: P) -> f64 {
a.0 * b.1 - a.1 * b.0
}
fn length(a: P) -> f64 {
a.0.hypot(a.1)
}
fn rect(at: Position, size: Size) -> Rect {
(
at.x as f64,
at.y as f64,
(at.x + size.width) as f64,
(at.y + size.height) as f64,
)
}
fn overlap(a: Rect, b: Rect) -> f64 {
let w = a.2.min(b.2) - a.0.max(b.0);
let h = a.3.min(b.3) - a.1.max(b.1);
if w > EPS && h > EPS { w * h } else { 0. }
}
fn contains(a: Rect, b: Rect) -> bool {
a.0 <= b.0 + EPS && a.1 <= b.1 + EPS && a.2 + EPS >= b.2 && a.3 + EPS >= b.3
}
fn proper_cross(a: P, b: P, c: P, d: P) -> Option<P> {
let u = sub(b, a);
let v = sub(d, c);
let denominator = cross(u, v);
if denominator.abs() < 1e-9 {
return None;
}
let t = cross(sub(c, a), v) / denominator;
let s = cross(sub(c, a), u) / denominator;
// Strict interiors: touches, T junctions and intersections at bends are not proper crossings.
(t > 1e-9 && t < 1. - 1e-9 && s > 1e-9 && s < 1. - 1e-9)
.then_some((a.0 + t * u.0, a.1 + t * u.1))
}
fn shared_length(a: P, b: P, c: P, d: P) -> f64 {
let u = sub(b, a);
let len = length(u);
if len < EPS || cross(u, sub(c, a)).abs() / len > EPS || cross(u, sub(d, a)).abs() / len > EPS {
return 0.;
}
let t = dot(sub(c, a), u) / len;
let s = dot(sub(d, a), u) / len;
(t.max(s).min(len) - t.min(s).max(0.)).max(0.)
}
// Closed-segment point intersections. A positive collinear interval is not a
// point: it remains represented by the existing shared-length metric.
fn point_contact(a: P, b: P, c: P, d: P) -> Option<P> {
let u = sub(b, a);
let v = sub(d, c);
let on_segment = |at: P, start: P, end: P| {
let delta = sub(end, start);
let len = length(delta);
if len < EPS {
return length(sub(at, start)) < EPS;
}
cross(delta, sub(at, start)).abs() / len <= EPS
&& dot(sub(at, start), delta) >= -EPS * len
&& dot(sub(at, end), delta) <= EPS * len
};
if length(u) < EPS {
return on_segment(a, c, d).then_some(a);
}
if length(v) < EPS {
return on_segment(c, a, b).then_some(c);
}
let denominator = cross(u, v);
if denominator.abs() < 1e-9 {
if shared_length(a, b, c, d) > EPS {
return None;
}
return [a, b, c, d]
.into_iter()
.find(|at| on_segment(*at, a, b) && on_segment(*at, c, d));
}
let t = cross(sub(c, a), v) / denominator;
let s = cross(sub(c, a), u) / denominator;
((-1e-9..=1. + 1e-9).contains(&t) && (-1e-9..=1. + 1e-9).contains(&s))
.then_some((a.0 + t.clamp(0., 1.) * u.0, a.1 + t.clamp(0., 1.) * u.1))
}
fn point_contacts(a: &[P], b: &[P]) -> Vec<(P, bool)> {
let mut unique = BTreeMap::new();
for ab in a.windows(2) {
for cd in b.windows(2) {
if let Some(at) = point_contact(ab[0], ab[1], cd[0], cd[1]) {
let key = ((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64);
let proper = proper_cross(ab[0], ab[1], cd[0], cd[1]).is_some();
let entry = unique.entry(key).or_insert((at, false));
entry.1 |= proper;
}
}
}
unique.into_values().collect()
}
fn contact_role(points: &[P], at: P) -> &'static str {
if points
.first()
.into_iter()
.chain(points.last())
.any(|tip| length(sub(*tip, at)) < EPS)
{
"route_endpoint"
} else if points.iter().any(|vertex| length(sub(*vertex, at)) < EPS) {
"internal_vertex"
} else {
"segment_interior"
}
}
fn ancestor(view: &ProjectedView, owner: Id, mut node: Id) -> bool {
for _ in 0..view.nodes.len() {
let Some(parent) = view
.nodes
.iter()
.find(|n| n.id == node)
.and_then(|n| n.visual_parent)
else {
return false;
};
if parent == owner {
return true;
}
node = parent;
}
false
}
fn terminal_points(
edge: &projection::ProjectedEdge,
points: &[Position],
segment: usize,
) -> Vec<(Id, P)> {
let mut owners = Vec::new();
if segment == 0 {
owners.push((edge.source, p(points[0])));
}
if segment + 2 == points.len() {
owners.push((edge.target, p(*points.last().unwrap())));
}
owners
}
fn inside_length(a: P, b: P, r: Rect) -> f64 {
let delta = sub(b, a);
let mut lo = 0_f64;
let mut hi = 1_f64;
for (origin, direction, min, max) in [
(a.0, delta.0, r.0 + EPS, r.2 - EPS),
(a.1, delta.1, r.1 + EPS, r.3 - EPS),
] {
if min >= max {
return 0.;
}
if direction.abs() < 1e-9 {
if origin <= min || origin >= max {
return 0.;
}
} else {
let t = (min - origin) / direction;
let u = (max - origin) / direction;
lo = lo.max(t.min(u));
hi = hi.min(t.max(u));
}
}
(hi - lo).max(0.) * length(delta)
}
fn header_height(project: &Project, diagram: Id, node: &projection::ProjectedNode) -> f64 {
let h = if project.diagrams[&diagram]
.projection
.state_machine()
.is_some()
{
projection::state_header_height(project, node.element)
} else {
64.
};
h.min(node.size.height) as f64
}
fn metrics(project: &Project, diagram: Id) -> Value {
let view = projection::project_view(project, diagram);
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
let edges: Vec<_> = routed.edges.values().collect();
let definitions: BTreeMap<_, _> = view.edges.iter().map(|e| (e.id, e)).collect();
let mut crossings = Vec::new();
let mut shared = 0.;
let mut excluded_shared = 0.;
let mut excluded_stub_crossings = 0;
let mut contacts = Vec::new();
let mut common_terminal_contacts = 0;
for (i, a) in edges.iter().enumerate() {
for b in edges.iter().skip(i + 1) {
let ap: Vec<_> = a.points.iter().copied().map(p).collect();
let bp: Vec<_> = b.points.iter().copied().map(p).collect();
let ad = definitions[&a.id];
let bd = definitions[&b.id];
for (at, proper) in point_contacts(&ap, &bp) {
let common_terminal = [(ad.source, ap.first()), (ad.target, ap.last())]
.iter()
.any(|(owner, tip)| {
tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
&& [(bd.source, bp.first()), (bd.target, bp.last())]
.iter()
.any(|(other, tip)| {
owner == other
&& tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
})
});
common_terminal_contacts += usize::from(common_terminal);
contacts.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],
"proper_crossing":proper,"roles":[contact_role(&ap,at),contact_role(&bp,at)],
"common_physical_terminal":common_terminal}));
}
let mut unique = BTreeSet::new();
for (ai, ab) in a.points.windows(2).enumerate() {
for (bi, cd) in b.points.windows(2).enumerate() {
let mut common_tips = Vec::new();
for (owner, tip) in terminal_points(definitions[&a.id], &a.points, ai) {
if terminal_points(definitions[&b.id], &b.points, bi)
.iter()
.any(|(other, at)| *other == owner && length(sub(*at, tip)) < EPS)
{
common_tips.push(tip);
}
}
let crossing = proper_cross(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
let overlap = shared_length(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
// Raw totals stay primary. Only physically common tips get
// bounded PORT_STUB exemptions, never a whole compacted segment.
shared += overlap;
excluded_shared +=
overlap.min(common_tips.len() as f64 * routing::PORT_STUB as f64);
if let Some(at) = crossing
&& unique.insert(((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64))
{
let near_tip = common_tips
.iter()
.any(|tip| length(sub(at, *tip)) <= routing::PORT_STUB as f64 + EPS);
excluded_stub_crossings += usize::from(near_tip);
crossings.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],"within_common_terminal_stub":near_tip}));
}
}
}
}
}
let mut node_collisions = Vec::new();
let mut label_nodes = Vec::new();
let mut label_labels = Vec::new();
let mut excluded_ancestor_labels = 0;
let mut penetrations = Vec::new();
let mut label_strokes = Vec::new();
for e in &edges {
let def = definitions[&e.id];
for n in &view.nodes {
if n.id == def.source || n.id == def.target {
continue;
}
let mut obstacle = rect(n.position, n.size);
let ancestor_header =
ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target);
if ancestor_header {
obstacle.3 = obstacle.1 + header_height(project, diagram, n);
}
let length: f64 = e
.points
.windows(2)
.map(|w| inside_length(p(w[0]), p(w[1]), obstacle))
.sum();
if length > EPS {
penetrations.push(json!({"edge":e.id,"node":n.id,"length":length,"ancestor_header_only":ancestor_header}));
}
}
}
for (i, a) in view.nodes.iter().enumerate() {
for b in view.nodes.iter().skip(i + 1) {
let area = overlap(rect(a.position, a.size), rect(b.position, b.size));
if area > 0. && !ancestor(&view, a.id, b.id) && !ancestor(&view, b.id, a.id) {
node_collisions.push(json!({"nodes":[a.id,b.id],"intersection_area":area}));
}
}
}
let labels: Vec<_> = edges.iter().filter(|e| !e.label.text.is_empty()).collect();
for (i, e) in labels.iter().enumerate() {
let lb = rect(e.label.bounds.position, e.label.bounds.size);
let def = definitions[&e.id];
for n in &view.nodes {
let nb = rect(n.position, n.size);
let area = overlap(lb, nb);
if area == 0. {
continue;
}
let body = (nb.0, nb.1 + header_height(project, diagram, n), nb.2, nb.3);
if contains(body, lb)
&& (ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target))
{
excluded_ancestor_labels += 1;
} else {
label_nodes.push(json!({"edge":e.id,"node":n.id,"intersection_area":area}));
}
}
for other in labels.iter().skip(i + 1) {
let area = overlap(
lb,
rect(other.label.bounds.position, other.label.bounds.size),
);
if area > 0. {
label_labels.push(json!({"edges":[e.id,other.id],"intersection_area":area}));
}
}
for other in edges.iter().filter(|other| other.id != e.id) {
let length: f64 = other
.points
.windows(2)
.map(|w| inside_length(p(w[0]), p(w[1]), lb))
.sum();
if length > EPS {
label_strokes
.push(json!({"label_edge":e.id,"stroke_edge":other.id,"length":length}));
}
}
}
let mut total_length = 0.;
let mut bends = 0;
for e in &edges {
total_length += e
.points
.windows(2)
.map(|w| length(sub(p(w[1]), p(w[0]))))
.sum::<f64>();
bends += e
.points
.windows(3)
.filter(|w| {
let a = sub(p(w[1]), p(w[0]));
let b = sub(p(w[2]), p(w[1]));
length(a) > EPS && length(b) > EPS && (cross(a, b).abs() > EPS || dot(a, b) < 0.)
})
.count();
}
let warnings: Vec<_> = edges
.iter()
.flat_map(|e| {
e.warnings
.iter()
.map(move |w| json!({"edge":e.id,"warning":format!("{w:?}")}))
})
.collect();
json!({
"nodes":view.nodes.len(),"edges":edges.len(),"ports":view.ports.len(),
"proper_crossings":crossings.len(),"crossing_details":crossings,
"point_contacts":contacts.len(),"point_contact_details":contacts,
"common_physical_terminal_point_contacts":common_terminal_contacts,
"pairwise_collinear_shared_length":shared,
"excluded_common_terminal_shared_length":excluded_shared,"excluded_common_terminal_crossings":excluded_stub_crossings,
"nonterminal_proper_crossings":crossings.len()-excluded_stub_crossings,
"nonterminal_pairwise_shared_length":shared-excluded_shared,
"unrelated_edge_node_penetrations":penetrations.len(),"edge_node_details":penetrations,
"unrelated_label_stroke_intersections":label_strokes.len(),"label_stroke_details":label_strokes,
"unrelated_node_collisions":node_collisions.len(),"node_collision_details":node_collisions,
"label_node_collisions":label_nodes.len(),"label_node_details":label_nodes,
"excluded_contained_endpoint_ancestor_label_pairs":excluded_ancestor_labels,
"label_label_collisions":label_labels.len(),"label_label_details":label_labels,
"warning_count":warnings.len(),"warnings":warnings,
"drawing_bounds":{"position":routed.bounds.position,"size":routed.bounds.size},
"drawing_area":routed.bounds.size.width as f64*routed.bounds.size.height as f64,
"route_bends":bends,"route_length":total_length,
"projected_nodes":view.nodes,"projected_ports":view.ports,
"route_endpoints":edges.iter().map(|e|json!({"id":e.id,
"source_owner":definitions[&e.id].source,"target_owner":definitions[&e.id].target,
"source":e.points.first(),"target":e.points.last()})).collect::<Vec<_>>(),
"routed_edges":edges.iter().map(|e|json!({"id":e.id,"points":e.points,"label":{
"text":e.label.text,"position":e.label.bounds.position,"size":e.label.bounds.size}})).collect::<Vec<_>>()
})
}
struct Builder {
project: Project,
diagram: Id,
next: u128,
witness: Option<Project>,
}
impl Builder {
fn new(name: &str) -> Self {
let mut project = Project::empty();
project.id = Id::from_u128(90_000);
project.alias = "layout_probe".into();
project.name = name.into();
let old = project.ensure_diagram(Layer::Sa);
let diagram = Id::from_u128(90_001);
reidentify(&mut project, old, diagram).unwrap();
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
Self {
project,
diagram,
next: 1,
witness: None,
}
}
fn stable(&mut self, old: Id) -> Id {
let id = Id::from_u128(self.next);
self.next += 1;
reidentify(&mut self.project, old, id).unwrap();
id
}
fn function(&mut self, name: &str, parent: Option<Id>, x: f32, y: f32) -> Id {
let old = self
.project
.add_element(Kind::Function, Layer::Sa, name, parent)
.unwrap();
let id = self.stable(old);
let old = self.project.place(self.diagram, id, Position { x, y });
self.stable(old);
id
}
fn exchange(&mut self, name: &str, a: Id, b: Id) -> Id {
let old = self
.project
.relate(RelationKind::FunctionalExchange, name, a, b);
let edge = self.stable(old);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = self.project.relations[&edge].body
else {
unreachable!()
};
for (old, prefix) in [(source_port, "out"), (target_port, "in")] {
let id = self.stable(old);
self.project.elements.get_mut(&id).unwrap().alias =
format!("{prefix}_{}", id.as_u128());
}
edge
}
fn node(&self, element: Id) -> Id {
self.project.diagrams[&self.diagram]
.nodes
.values()
.find(|n| n.element == element)
.unwrap()
.id
}
}
fn fixtures() -> Vec<(&'static str, Builder)> {
let mut all = Vec::new();
for (name, count) in [("flat_chain_4", 4), ("chain_7_row_wrap", 7)] {
let mut f = Builder::new(name);
let mut nodes = Vec::new();
for i in 0..count {
nodes.push(f.function(&format!("Function {i}"), None, 40. + 320. * i as f32, 80.));
}
for i in 0..count - 1 {
f.exchange(&format!("Flow {i}"), nodes[i], nodes[i + 1]);
}
all.push((name, f));
}
for (name, links) in [
("diamond", vec![(0, 1), (0, 2), (1, 3), (2, 3)]),
("crossed_bipartite", vec![(0, 3), (1, 2), (0, 2), (1, 3)]),
] {
let mut f = Builder::new(name);
let mut nodes = Vec::new();
let coordinates = if name == "diamond" {
[(40., 240.), (400., 80.), (400., 400.), (760., 240.)]
} else {
[(40., 80.), (40., 400.), (620., 80.), (620., 400.)]
};
for (i, (x, y)) in coordinates.into_iter().enumerate() {
nodes.push(f.function(&format!("Node {i}"), None, x, y));
}
for (i, (a, b)) in links.into_iter().enumerate() {
f.exchange(&format!("Flow {i}"), nodes[a], nodes[b]);
}
all.push((name, f));
}
let mut f = Builder::new("nested_cross_boundary");
let group = f.function("Parent function", None, 40., 40.);
let a = f.function("Child send", Some(group), 100., 130.);
let b = f.function("Child receive", Some(group), 100., 330.);
let c = f.function("Outside", None, 900., 210.);
f.exchange("Internal flow", a, b);
f.exchange("Cross boundary", b, c);
all.push(("nested_cross_boundary", f));
for fixed in [false, true] {
let name = if fixed {
"fixed_ports"
} else {
"authored_detour_and_label"
};
let mut f = Builder::new(name);
let a = f.function("Sender", None, 40., 80.);
let b = f.function("Receiver", None, 640., 80.);
let edge = f.exchange("Preserved information", a, b);
if fixed {
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = f.project.relations[&edge].body
else {
unreachable!()
};
for (element, port, side, offset) in [
(a, source_port, PortAnchorSide::Left, 0.3),
(b, target_port, PortAnchorSide::Right, 0.7),
] {
let node = f.node(element);
f.project = prepare_command(
&f.project,
&Command::SetPortAnchor {
diagram: f.diagram,
node,
anchor: PortAnchor { port, side, offset },
},
)
.unwrap()
.project;
}
} else {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![
Position { x: 400., y: -320. },
Position { x: 1000., y: -320. },
Position { x: 1000., y: 240. },
],
label: Some(EdgeLabel {
fraction: 0.7,
offset: Position { x: 100., y: -180. },
}),
});
}
all.push((name, f));
}
let mut f = Builder::new("two_containers_reversed_children");
let left = f.function("Left group", None, 40., 40.);
let right = f.function("Right group", None, 820., 40.);
let la = f.function("Left A", Some(left), 100., 130.);
let lb = f.function("Left B", Some(left), 100., 330.);
let ra = f.function("Right A", Some(right), 880., 130.);
let rb = f.function("Right B", Some(right), 880., 330.);
f.exchange("Upper to lower", la, rb);
f.exchange("Lower to upper", lb, ra);
let mut witness = f.project.clone();
for (element, y) in [(ra, 330.), (rb, 130.)] {
let node = f.node(element);
witness
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.y = y;
}
f.witness = Some(witness);
all.push(("two_containers_reversed_children", f));
let mut f = Builder::new("long_ordinary_labels");
let a = f.function("Input", None, 40., 100.);
let b = f.function("Transform", None, 400., 100.);
let c = f.function("Output", None, 760., 100.);
f.exchange(
"A very long first information exchange label exceeding the ordinary cutoff",
a,
b,
);
f.exchange(
"A very long second information exchange label exceeding the ordinary cutoff",
b,
c,
);
f.exchange(
"A very long bypass information exchange label exceeding the ordinary cutoff",
a,
c,
);
all.push(("long_ordinary_labels", f));
all
}
fn reset_effective_routes(project: &Project, diagram: Id) -> Project {
let view = projection::project_view(project, diagram);
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
let mut reset = project.clone();
for route in &mut reset.diagrams.get_mut(&diagram).unwrap().routes {
if routed
.edges
.get(&route.edge)
.is_some_and(|edge| !edge.custom_suspended)
{
route.waypoints.clear();
route.label = None;
}
}
reset
}
fn conservation(before: &Project, after: &Project, diagram: Id) -> Value {
let mut semantic = after.clone();
semantic.diagrams = before.diagrams.clone();
let bd = &before.diagrams[&diagram];
let ad = &after.diagrams[&diagram];
let mut stable_view = ad.clone();
stable_view.nodes = bd.nodes.clone();
stable_view.routes = bd.routes.clone();
stable_view.layout = bd.layout.clone();
let mut other_views = after.diagrams.clone();
other_views.insert(diagram, bd.clone());
let node_metadata = bd.nodes.len() == ad.nodes.len()
&& bd.nodes.iter().all(|(id, node)| {
ad.nodes.get(id).is_some_and(|new| {
let mut restored = new.clone();
restored.position = node.position;
restored.size = node.size;
restored == *node
})
});
json!({"semantic_records_exact":semantic==*before,"target_view_nongeometry_exact":stable_view==*bd,
"other_views_exact":other_views==before.diagrams,"node_identity_visibility_anchors_exact":node_metadata,
"authored_routes_exact":ad.routes==bd.routes})
}
fn roundtrip(project: &Project) -> Value {
let bytes = serde_json::to_vec(project).unwrap();
let json_result = persistence::decode(&bytes);
let source = language::format(project);
let source_result = language::parse(&source);
json!({"json_exact":json_result.as_ref().is_ok_and(|p|p==project),
"json_error":json_result.err().map(|e|e.to_string()),
"single_source_exact":source_result.as_ref().is_ok_and(|p|p==project),
"source_error":source_result.err().map(|e|format!("{e:?}"))})
}
fn observe(name: &str, f: Builder) -> Value {
let witness = f.witness;
let before = f.project;
let diagram = f.diagram;
if let Err(error) = validate_candidate(&before) {
return json!({"fixture":name,"fixture_error":error.to_string()});
}
let mut session = Session::new(before.clone());
if let Err(error) = session.execute(Command::Layout(diagram)) {
return json!({"fixture":name,"before":metrics(&before,diagram),"layout_error":error.to_string()});
}
let after = session.project().clone();
let changed = after != before;
let had_undo = session.can_undo();
session.undo();
let undo = session.project() == &before;
let one_step = !session.can_undo();
session.redo();
let redo = session.project() == &after;
let second = prepare_command(&after, &Command::Layout(diagram));
let reset = reset_effective_routes(&after, diagram);
let mut repeated = after.clone();
let mut stability = Vec::new();
for pass in 2..=10 {
match prepare_command(&repeated, &Command::Layout(diagram)) {
Ok(next) => {
stability.push(json!({"pass":pass,"equal_previous":next.project==repeated,"equal_first":next.project==after}));
repeated = next.project;
}
Err(error) => {
stability.push(json!({"pass":pass,"error":error.to_string()}));
break;
}
}
}
let witness=witness.map(|p|json!({"metrics":metrics(&p,diagram),"project":p,
"conservation":conservation(&before,&p,diagram),"validation_error":validate_candidate(&p).err().map(|e|e.to_string())}));
let candidate = match layered::candidate(&before, diagram) {
Err(error) => json!({"supported":false,"error":error.to_string()}),
Ok(candidate) => {
if let Err(error) = validate_candidate(&candidate) {
json!({"supported":true,"validation_error":error.to_string(),"project":candidate})
} else {
let again = layered::candidate(&candidate, diagram);
let duplicate = layered::candidate(&before, diagram);
let mut repeated_candidate = candidate.clone();
let mut candidate_stability = Vec::new();
for pass in 2..=10 {
match layered::candidate(&repeated_candidate, diagram) {
Ok(next) => {
candidate_stability.push(json!({"pass":pass,"equal_previous":next==repeated_candidate,"equal_first":next==candidate}));
repeated_candidate = next;
}
Err(error) => {
candidate_stability
.push(json!({"pass":pass,"error":error.to_string()}));
break;
}
}
}
json!({"supported":true,"metrics":metrics(&candidate,diagram),"project":candidate,
"conservation":conservation(&before,&candidate,diagram),"roundtrip":roundtrip(&candidate),
"ten_candidate_calls":candidate_stability,
"repeat_same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==candidate),
"idempotent":again.as_ref().is_ok_and(|p|*p==candidate),
"repeat_error":duplicate.err().map(|e|e.to_string()),"idempotence_error":again.err().map(|e|e.to_string())})
}
}
};
json!({"fixture":name,"diagram":diagram,"before":metrics(&before,diagram),"after":metrics(&after,diagram),
"before_project":before,"after_project":after,"conservation":conservation(&before,&after,diagram),
"reset_only_control":{"metrics":metrics(&reset,diagram),"project":reset,"conservation":conservation(&before,&reset,diagram),"roundtrip":roundtrip(&reset)},
"manual_planar_order_witness":witness,"ten_layout_calls":stability,"experimental_candidate":candidate,
"checks":{
"changed":changed,"history_entry_matches_change":had_undo==changed,"undo_exact":undo,"one_step_undo":one_step,"redo_exact":redo,
"second_layout_exact":second.as_ref().is_ok_and(|s|s.project==after),"second_layout_error":second.err().map(|e|e.to_string()),
"before_roundtrip":roundtrip(&before),"after_roundtrip":roundtrip(&after)}})
}
fn main() {
// Analytic checks validate the observer, not any layout's quality.
let horizontal = [(0., 0.), (10., 0.)];
assert_eq!(
point_contacts(&horizontal, &[(5., -5.), (5., 5.)]),
vec![((5., 0.), true)]
);
assert_eq!(
point_contacts(&horizontal, &[(5., 0.), (5., 5.)]),
vec![((5., 0.), false)]
);
let bend = [(5., -5.), (5., 0.), (8., 3.)];
assert_eq!(point_contacts(&horizontal, &bend), vec![((5., 0.), false)]);
assert_eq!(contact_role(&bend, (5., 0.)), "internal_vertex");
assert_eq!(contact_role(&horizontal, (5., 0.)), "segment_interior");
assert_eq!(
point_contacts(&horizontal, &[(10., 0.), (10., 5.)]),
vec![((10., 0.), false)]
);
assert_eq!(contact_role(&horizontal, (10., 0.)), "route_endpoint");
assert_eq!(
point_contacts(&horizontal, &[(10., 0.), (20., 0.)]),
vec![((10., 0.), false)]
);
assert!(point_contacts(&horizontal, &[(4., 0.), (12., 0.)]).is_empty());
assert_eq!(
proper_cross((0., 0.), (10., 0.), (5., -5.), (5., 5.)),
Some((5., 0.))
);
assert_eq!(
proper_cross((0., 0.), (10., 0.), (10., 0.), (10., 5.)),
None
);
assert!((shared_length((0., 0.), (10., 0.), (4., 0.), (12., 0.)) - 6.).abs() < EPS);
assert_eq!(shared_length((0., 0.), (10., 0.), (4., 1.), (12., 1.)), 0.);
assert_eq!(overlap((0., 0., 10., 10.), (10., 0., 20., 10.)), 0.);
assert!((inside_length((0., 5.), (10., 5.), (2., 2., 8., 8.)) - 6.).abs() < 3. * EPS);
assert_eq!(inside_length((0., 2.), (10., 2.), (2., 2., 8., 8.)), 0.);
println!(
"{}",
json!({"probe":"auto_layout_probe_v2","reference_source":"715036e132677dd2bc2fb37ed316d93dc9987aec","metric_selfchecks":"passed", "quality_thresholds":null,
"point_contact_definition":"Unexempted unique point per edge pair (EPS quantization), closed segments, proper-crossing flag ORed across segment witnesses; roles distinguish route endpoints/internal vertices/interiors. Positive collinear intervals are only in shared-length metric; a noncollinear witness at an overlap boundary is still a point contact. Common physical terminal requires equal owner and equal endpoint coordinate and is classified, never subtracted. Route endpoints emitted for exact comparison against the frozen prior run, not assumed invariant across node layout.",
"exclusions":"Raw strict-interior crossings and pairwise shared length are primary; separately bound common physical endpoint exclusions to PORT_STUB. Ancestor node containment excluded. Labels wholly inside endpoint-ancestor BODY below header exempt; headers retained. Penetration excludes endpoint nodes and endpoint-ancestor bodies. Label/stroke excludes own edge. Core approximate label bounds, no glyph/port-label metrics."})
);
for (name, f) in fixtures() {
println!("{}", observe(name, f));
}
}
@@ -1,380 +0,0 @@
//! Isolated #32 experiment, not a production layout engine.
//! Uses existing Layout sizing, three deterministic ordering passes, and one
//! core-estimated-label spacing pass. Automatic elbows remain derived. Folded/omitted
//! node topology is explicitly unsupported; filtered edges retain their metadata.
use oa_core::{Command, Id, ModelError, Position, Project, Size, projection, routing};
use std::collections::{BTreeMap, BTreeSet};
const WORK_LIMIT: usize = 200_000;
const NODE_LIMIT: usize = 256;
const EDGE_LIMIT: usize = 1024;
fn spend(work: &mut usize) -> Result<(), ModelError> {
*work += 1;
if *work > WORK_LIMIT {
return Err(ModelError("Layered probe work limit exceeded.".into()));
}
Ok(())
}
/// Return a new Project; caller owns comparison, history and serialization.
pub fn candidate(project: &Project, diagram: Id) -> Result<Project, ModelError> {
let original = project
.diagrams
.get(&diagram)
.ok_or_else(|| ModelError("Missing probe diagram.".into()))?;
let view = projection::project_diagram(project, original);
if view.nodes.len() > NODE_LIMIT || view.edges.len() > EDGE_LIMIT {
return Err(ModelError("Layered probe graph limit exceeded.".into()));
}
if view.nodes.len() != original.nodes.len() || view.nodes.iter().any(|n| n.collapsed) {
return Err(ModelError(
"Layered probe does not support folded/omitted node topology.".into(),
));
}
let routed = routing::route_diagram(project, original, &view);
let eligible: BTreeSet<_> = view
.edges
.iter()
.filter(|edge| {
projection::edge_endpoint_placements(project, original, edge.id)
== Some((edge.source, edge.target))
&& routed
.edges
.get(&edge.id)
.is_some_and(|route| !route.custom_suspended)
})
.map(|edge| edge.id)
.collect();
let mut result = oa_core::prepare_command(project, &Command::Layout(diagram))?.project;
// Discard baseline route translation: final routes are reconstructed directly
// from original geometry, avoiding two translations and preserving list order.
result.diagrams.get_mut(&diagram).unwrap().routes = original.routes.clone();
for route in &mut result.diagrams.get_mut(&diagram).unwrap().routes {
if eligible.contains(&route.edge) {
route.waypoints.clear();
route.label = None;
}
}
let baseline = projection::project_view(&result, diagram);
let labels = routing::route_diagram(&result, &result.diagrams[&diagram], &baseline);
let mut gap_x = 64.0_f32;
let mut gap_y = 32.0_f32;
for edge in labels.edges.values() {
gap_x = gap_x.max(edge.label.bounds.size.width + 24.0);
gap_y = gap_y.max(edge.label.bounds.size.height + 24.0);
if let Some(relation) = result.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, edge) {
gap_x = gap_x.max(end.label.bounds.size.width + 24.0);
gap_y = gap_y.max(end.label.bounds.size.height + 24.0);
}
}
}
if !gap_x.is_finite() || !gap_y.is_finite() {
return Err(ModelError("Nonfinite estimated label bound.".into()));
}
let parents: BTreeMap<_, _> = baseline
.nodes
.iter()
.map(|n| (n.id, n.visual_parent))
.collect();
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for node in &baseline.nodes {
children
.entry(node.visual_parent)
.or_default()
.push(node.id);
}
let names: BTreeMap<_, _> = baseline
.nodes
.iter()
.map(|n| (n.id, (result.elements[&n.element].alias.clone(), n.id)))
.collect();
for siblings in children.values_mut() {
siblings.sort_by_key(|id| names[id].clone());
}
let minima: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.size)).collect();
let headers: BTreeMap<_, _> = baseline
.nodes
.iter()
.map(|n| {
(
n.id,
if original.projection.state_machine().is_some() {
projection::state_header_height(&result, n.element)
} else {
64.0
},
)
})
.collect();
let mut positions: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.position)).collect();
let mut sizes = minima.clone();
let mut work = 0;
let mut group_order: Vec<_> = children.keys().copied().collect();
group_order.sort_by_key(|parent| {
parent.map_or(0, |id| {
baseline.nodes.iter().find(|n| n.id == id).unwrap().depth + 1
})
});
let mut groups = BTreeMap::new();
for pass in 0..3 {
for parent in &group_order {
let siblings = &children[parent];
let mut arcs = BTreeSet::new();
for edge in &baseline.edges {
spend(&mut work)?;
let a = child_under(edge.source, *parent, &parents, &mut work)?;
let b = child_under(edge.target, *parent, &parents, &mut work)?;
if edge.oriented
&& let (Some(a), Some(b)) = (a, b)
&& a != b
{
arcs.insert((a, b));
}
}
let ranks = ranks(siblings, &arcs, &mut work)?;
let mut layers: BTreeMap<usize, Vec<Id>> = BTreeMap::new();
for id in siblings {
layers.entry(ranks[id]).or_default().push(*id);
}
// Boundary neighbours participate even though they are not siblings.
// Incoming preference anchors the downstream order to its upstream
// counterpart instead of swapping both sides simultaneously.
let mut scores = BTreeMap::new();
for id in siblings {
let mut incoming = Vec::new();
for edge in &baseline.edges {
spend(&mut work)?;
let a = child_under(edge.source, *parent, &parents, &mut work)?;
let b = child_under(edge.target, *parent, &parents, &mut work)?;
if edge.oriented && b == Some(*id) && a != Some(*id) {
incoming.push(positions[&edge.source].y + sizes[&edge.source].height / 2.0);
}
}
// Do not also chase outgoing boundary neighbours: simultaneous
// swaps in two connected containers can preserve the crossing.
let values = &incoming;
let score = if values.is_empty() {
positions[id].y
} else {
values.iter().sum::<f32>() / values.len() as f32
};
scores.insert(*id, score);
}
for layer in layers.values_mut() {
// First pass starts from canonical alias order; subsequent passes
// retain a deterministic alias tie break, never hash iteration.
if pass > 0 {
layer.sort_by(|a, b| {
scores[a]
.total_cmp(&scores[b])
.then_with(|| names[a].cmp(&names[b]))
});
}
}
groups.insert(*parent, layers.into_values().collect::<Vec<_>>());
}
// Recompute from frozen minima, never add spacing to last pass's size.
sizes = minima.clone();
measure(None, &groups, &headers, &mut sizes, gap_x, gap_y, &mut work)?;
place(
None,
Position { x: 40.0, y: 40.0 },
&groups,
&headers,
&sizes,
&mut positions,
gap_x,
gap_y,
&mut work,
)?;
}
let d = result.diagrams.get_mut(&diagram).unwrap();
for (id, node) in &mut d.nodes {
node.position = positions[id];
node.size = sizes[id];
}
let deltas: BTreeMap<_, _> = original
.nodes
.iter()
.map(|(id, node)| {
(
*id,
Position {
x: positions[id].x - node.position.x,
y: positions[id].y - node.position.y,
},
)
})
.collect();
let translated: BTreeMap<_, _> = projection::translated_routes(project, original, &deltas)
.into_iter()
.map(|r| (r.edge, r))
.collect();
d.routes = original
.routes
.iter()
.map(|route| {
let mut route = translated.get(&route.edge).unwrap_or(route).clone();
if eligible.contains(&route.edge) {
route.waypoints.clear();
route.label = None;
}
route
})
.collect();
if d.nodes.values().any(|n| {
!n.position.x.is_finite()
|| !n.position.y.is_finite()
|| !n.size.width.is_finite()
|| !n.size.height.is_finite()
|| n.size.width <= 0.0
|| n.size.height <= 0.0
}) {
return Err(ModelError("Invalid layered probe geometry.".into()));
}
oa_core::validate_candidate(&result)?;
Ok(result)
}
fn child_under(
mut id: Id,
parent: Option<Id>,
parents: &BTreeMap<Id, Option<Id>>,
work: &mut usize,
) -> Result<Option<Id>, ModelError> {
for _ in 0..=parents.len() {
spend(work)?;
let Some(actual) = parents.get(&id) else {
return Ok(None);
};
if *actual == parent {
return Ok(Some(id));
}
let Some(next) = actual else { return Ok(None) };
id = *next;
}
Err(ModelError("Layered probe containment cycle.".into()))
}
fn ranks(
ids: &[Id],
arcs: &BTreeSet<(Id, Id)>,
work: &mut usize,
) -> Result<BTreeMap<Id, usize>, ModelError> {
let mut remaining: BTreeSet<_> = ids.iter().copied().collect();
let mut ranks: BTreeMap<_, usize> = BTreeMap::new();
while !remaining.is_empty() {
let mut ready = None;
for id in ids.iter().filter(|id| remaining.contains(*id)) {
let mut blocked = false;
for (a, b) in arcs {
spend(work)?;
blocked |= b == id && remaining.contains(a);
}
if !blocked {
ready = Some(*id);
break;
}
}
// Explicit cycle fallback: break the first canonical remaining vertex's
// incoming ranking constraints. Actual relation direction is unchanged.
let id = ready.unwrap_or_else(|| *ids.iter().find(|id| remaining.contains(*id)).unwrap());
let mut rank = 0;
for (a, b) in arcs {
spend(work)?;
if *b == id
&& let Some(previous) = ranks.get(a)
{
rank = rank.max(previous + 1);
}
}
ranks.insert(id, rank);
remaining.remove(&id);
}
Ok(ranks)
}
type Groups = BTreeMap<Option<Id>, Vec<Vec<Id>>>;
fn measure(
parent: Option<Id>,
groups: &Groups,
headers: &BTreeMap<Id, f32>,
sizes: &mut BTreeMap<Id, Size>,
gx: f32,
gy: f32,
work: &mut usize,
) -> Result<Size, ModelError> {
let Some(layers) = groups.get(&parent) else {
return Ok(Size {
width: 0.0,
height: 0.0,
});
};
let mut width = 0.0;
let mut height = 0.0_f32;
for (i, layer) in layers.iter().enumerate() {
let mut lw = 0.0_f32;
let mut lh = 0.0;
for (j, id) in layer.iter().enumerate() {
spend(work)?;
let content = measure(Some(*id), groups, headers, sizes, gx, gy, work)?;
let size = sizes.get_mut(id).unwrap();
if groups.contains_key(&Some(*id)) {
size.width = size.width.max(content.width + 48.0);
size.height = size.height.max(content.height + headers[id] + 24.0);
}
lw = lw.max(size.width);
lh += size.height + if j == 0 { 0.0 } else { gy };
}
width += lw + if i == 0 { 0.0 } else { gx };
height = height.max(lh);
}
Ok(Size { width, height })
}
#[allow(clippy::too_many_arguments)]
fn place(
parent: Option<Id>,
origin: Position,
groups: &Groups,
headers: &BTreeMap<Id, f32>,
sizes: &BTreeMap<Id, Size>,
positions: &mut BTreeMap<Id, Position>,
gx: f32,
gy: f32,
work: &mut usize,
) -> Result<(), ModelError> {
let Some(layers) = groups.get(&parent) else {
return Ok(());
};
let mut x = origin.x;
for layer in layers {
let mut y = origin.y;
let mut width = 0.0_f32;
for id in layer {
spend(work)?;
positions.insert(*id, Position { x, y });
place(
Some(*id),
Position {
x: x + 24.0,
y: y + headers[id],
},
groups,
headers,
sizes,
positions,
gx,
gy,
work,
)?;
y += sizes[id].height + gy;
width = width.max(sizes[id].width);
}
x += width + gx;
}
Ok(())
}
+9 -166
View File
@@ -846,19 +846,15 @@ fn route_edge(
.enumerate()
{
let incoming = points.iter().rev().find(|&&p| p != start).copied();
// Only the final leg has a known continuation. Intermediate
// authored pins must not inherit the target's terminal direction.
let outgoing = (index == pins.len()).then_some(b.point);
let path =
fast_path(start, end, &space, &occupied, incoming, outgoing).or_else(|| {
if !search {
return None;
}
let (path, hit_limit) =
grid_path(start, end, &space, &occupied, incoming, outgoing, budget);
exhausted |= hit_limit;
path
});
let path = fast_path(start, end, &space, &occupied, incoming).or_else(|| {
if !search {
return None;
}
let (path, hit_limit) =
grid_path(start, end, &space, &occupied, incoming, budget);
exhausted |= hit_limit;
path
});
let Some(path) = path else {
failed = true;
break;
@@ -1055,7 +1051,6 @@ fn fast_path(
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option<Position>,
outgoing: Option<Position>,
) -> Option<Vec<Position>> {
let obstacles = space.obstacles;
if obstacles
@@ -1075,9 +1070,6 @@ fn fast_path(
incoming
.zip(path.get(1))
.map_or(0.0, |(before, &after)| turn_cost(before, start, after))
+ outgoing
.zip(path.len().checked_sub(2).and_then(|i| path.get(i)))
.map_or(0.0, |(after, &before)| turn_cost(before, end, after))
};
let estimate = |path: &[Position]| base_cost(path) + join(path);
candidates.sort_by(|a, b| estimate(a).total_cmp(&estimate(b)));
@@ -1149,14 +1141,12 @@ impl Ord for QueueItem {
}
}
#[allow(clippy::too_many_arguments)]
fn grid_path(
start: Position,
end: Position,
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option<Position>,
outgoing: Option<Position>,
budget: &mut usize,
) -> (Option<Vec<Position>>, bool) {
let obstacles = space.obstacles;
@@ -1247,10 +1237,6 @@ fn grid_path(
let cost = current.cost
+ manhattan(a, b)
+ before.map_or(0.0, |before| turn_cost(before, a, b))
// Charge before queueing the target so competing arrival
// directions are compared by their complete terminal cost.
+ outgoing.filter(|_| next == last)
.map_or(0.0, |after| turn_cost(a, b, after))
+ crossing_cost(a, b, previous);
if cost >= costs[state] {
continue;
@@ -1605,146 +1591,3 @@ impl BoundsBuilder {
}
}
}
#[cfg(test)]
mod terminal_join_tests {
use super::*;
fn complete_cost(
path: &[Position],
before: Position,
after: Position,
occupied: &[Segment],
) -> f32 {
let mut complete = vec![before];
complete.extend_from_slice(path);
complete.push(after);
path_cost(&complete, occupied)
}
#[test]
fn terminal_join_prefers_equal_bend_witness_without_shared_trunk() {
// Four orientations prevent an accidental horizontal/right-only fix.
for orientation in 0..4 {
let transform = |p: Position| match orientation {
0 => p,
1 => pos(-p.x, p.y),
2 => pos(p.y, p.x),
_ => pos(-p.y, -p.x),
};
let start = transform(pos(0.0, 0.0));
let end = transform(pos(46.0, 180.0));
let before = transform(pos(-18.0, 0.0));
let after = transform(pos(64.0, 180.0));
let occupied = vec![Segment {
a: start,
b: transform(pos(46.0, 0.0)),
count: 1,
}];
let space = RoutingSpace::new(&[]);
let witness = vec![start, transform(pos(0.0, 180.0)), end];
// Length262 + two turns48 =310. The other complete L path has
// the same bends/length but pays46*0.12=5.52 in shared-run cost.
assert!((complete_cost(&witness, before, after, &occupied) - 310.0).abs() < EPSILON);
let fast = fast_path(start, end, &space, &occupied, Some(before), Some(after)).unwrap();
assert_eq!(fast, witness);
assert!((complete_cost(&fast, before, after, &occupied) - 310.0).abs() < EPSILON);
// Exercise grid directly; public routing's valid fast path would
// otherwise hide a target-state ordering regression in grid search.
let mut budget = VIEW_SEARCH_BUDGET;
let (grid, exhausted) = grid_path(
start,
end,
&space,
&occupied,
Some(before),
Some(after),
&mut budget,
);
assert!(!exhausted);
let grid = grid.unwrap();
assert_eq!(grid, witness);
assert!((complete_cost(&grid, before, after, &occupied) - 310.0).abs() < EPSILON);
}
}
#[test]
fn unspecified_continuation_keeps_intermediate_leg_choice() {
let start = pos(0.0, 0.0);
let pin = pos(46.0, 180.0);
let before = pos(-18.0, 0.0);
let occupied = vec![Segment {
a: start,
b: pos(46.0, 0.0),
count: 1,
}];
let space = RoutingSpace::new(&[]);
let witness = vec![start, pos(46.0, 0.0), pin];
let fast = fast_path(start, pin, &space, &occupied, Some(before), None).unwrap();
assert_eq!(fast, witness);
// An intermediate authored pin has no known outgoing continuation:
// length226 + one turn24 + shared5.52 =255.52 for this leg.
assert!((path_cost(&fast, &occupied) - 255.52).abs() < EPSILON);
let mut budget = VIEW_SEARCH_BUDGET;
let (grid, exhausted) = grid_path(
start,
pin,
&space,
&occupied,
Some(before),
None,
&mut budget,
);
assert!(!exhausted);
assert_eq!(grid.unwrap(), witness);
}
#[test]
fn straight_and_degenerate_terminal_legs_remain_exact() {
let space = RoutingSpace::new(&[]);
let start = pos(0.0, 0.0);
let end = pos(46.0, 0.0);
for after in [pos(64.0, 0.0), pos(28.0, 0.0)] {
let before = pos(-18.0, 0.0);
let fast = fast_path(start, end, &space, &[], Some(before), Some(after)).unwrap();
let expected = if after.x > end.x { 82.0 } else { 130.0 };
assert!((complete_cost(&fast, before, after, &[]) - expected).abs() < EPSILON);
let mut budget = VIEW_SEARCH_BUDGET;
let (grid, exhausted) = grid_path(
start,
end,
&space,
&[],
Some(before),
Some(after),
&mut budget,
);
assert!(!exhausted);
assert_eq!(grid.unwrap(), fast);
}
assert_eq!(
fast_path(
start,
start,
&space,
&[],
Some(pos(-18.0, 0.0)),
Some(pos(18.0, 0.0))
)
.unwrap(),
vec![start]
);
let mut budget = VIEW_SEARCH_BUDGET;
let (grid, exhausted) = grid_path(
start,
start,
&space,
&[],
Some(pos(-18.0, 0.0)),
Some(pos(18.0, 0.0)),
&mut budget,
);
assert!(!exhausted);
assert_eq!(grid.unwrap(), vec![start]);
}
}
@@ -60,6 +60,7 @@ impl Root<'_> {
return false;
}
let canvas = DiagramCanvas {
removal_input: &self.app.saved_removal_input,
selection: self.app.diagram_selection.as_ref(),
allocation: Some(self.app.allocation_capability()),
project: self.app.project(),
+190 -9
View File
@@ -19,12 +19,18 @@ mod hosted;
pub(crate) mod multiselect;
#[cfg(test)]
mod multiselect_tests;
pub(crate) mod process_removal;
mod reference_authoring;
mod removal_review;
#[cfg(test)]
mod review_navigation_tests;
pub(crate) mod saved_removal;
#[cfg(test)]
mod scenario_details_tests;
mod scenario_diagram;
mod scenario_multiselect;
pub(crate) mod scenario_removal;
pub(crate) mod scenario_reorder;
mod scenario_spacing;
#[cfg(test)]
mod source_indentation_tests;
@@ -215,11 +221,14 @@ mod direct_scenario_tests {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.visit, context.visit);
assert_eq!(fresh.representation, context.representation);
assert_ne!(
fresh.epoch, context.epoch,
"selection retires older gestures"
);
let context = fresh;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
@@ -437,6 +446,10 @@ pub enum Message {
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
ScenarioReorder(Box<scenario_reorder::Event>),
ScenarioRemoval(Box<scenario_removal::Event>),
ProcessRemoval(Box<process_removal::Event>),
SavedRemoval(Box<saved_removal::Event>),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
@@ -523,12 +536,14 @@ pub struct App {
pub guide: crate::guide::State,
pub traceability: crate::views::traceability::State,
pub behavior: crate::behavior::State,
pub(crate) scenario_reorder: scenario_reorder::State,
pub data_state: crate::data_view::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
pub selected: Id,
pub selected_node: Option<Id>,
pub diagram_selection: Option<crate::diagram_selection::Selection>,
pub(crate) saved_removal_input: crate::removal_input::Lifetime,
pub draft: Option<Draft>,
pub active_diagram: Option<Id>,
pub search: String,
@@ -645,12 +660,14 @@ impl App {
guide: crate::guide::State::default(),
traceability: Default::default(),
behavior: Default::default(),
scenario_reorder: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
selected_node: None,
diagram_selection: None,
saved_removal_input: Default::default(),
draft,
active_diagram,
search: String::new(),
@@ -1348,8 +1365,128 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Group dialogs only accept their stamped Cancel button. A delayed
// singleton dialog packet cannot close a later aggregate review.
if matches!(message, Message::CancelDelete)
&& self.delete_target.as_ref().is_some_and(|target| {
target.scenario.is_some() || target.process.is_some() || target.saved.is_some()
})
{
return Task::none();
}
if let Message::SavedRemoval(event) = message {
return self.saved_removal_event(*event);
}
if let Message::StateDiagram(behavior::state_edit::Event::ModelRemoval(event)) = message {
return self.saved_removal_event(*event);
}
if let Message::ProcessRemoval(event) = message {
return self.process_removal_event(*event);
}
// Process review Escape is captured with its token by the root widget.
if matches!(message, Message::Escape)
&& self
.delete_target
.as_ref()
.is_some_and(|target| target.process.is_some() || target.saved.is_some())
{
return Task::none();
}
if let Message::ScenarioRemoval(event) = message {
return self.scenario_removal_event(*event);
}
if let Message::ConfirmDelete(token) = &message {
if self
.delete_target
.as_ref()
.is_none_or(|target| target.snapshot.token != *token)
{
return Task::none();
}
if self
.delete_target
.as_ref()
.is_some_and(|target| target.saved.is_some())
{
self.confirm_saved_removal(*token);
return Task::none();
}
if self
.delete_target
.as_ref()
.is_some_and(|target| target.scenario.is_some())
{
self.confirm_scenario_removal(*token);
return Task::none();
}
if self
.delete_target
.as_ref()
.is_some_and(|target| target.process.is_some())
{
self.confirm_process_removal(*token);
return Task::none();
}
}
// Captured reorder packets must be admitted before shared navigation,
// camera or source boundaries can disturb a newer operation.
if let Message::ScenarioReorder(event) = message {
return self.scenario_reorder_event(*event);
}
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
{
return self.scenario_selection_event(*event);
}
// A delayed Scenario menu cannot retire the current reveal/gesture even
// when it uses the same semantic owner after a representation round trip.
if let Message::Menu(crate::model_menu::Event::Open(request)) = &message
&& (request.scenario.is_some() || request.process.is_some() || request.canvas.is_some())
&& crate::model_menu::Opened::new(self, request.clone(), self.menu_sequence).is_none()
{
return Task::none();
}
if let Message::Menu(crate::model_menu::Event::Invoke(token, _)) = &message
&& let Some(menu) = &self.model_menu
&& (menu.request.scenario.is_some()
|| menu.request.process.is_some()
|| menu.request.canvas.is_some())
&& (menu.token != *token || !menu.valid(self))
{
// Consume an expired menu's own packet without touching a newer
// menu or crossing source/selection boundaries.
if menu.token == *token {
self.model_menu = None;
}
return Task::none();
}
if self.scenario_group_selected()
&& matches!(
&message,
Message::Command(Command::MoveStep(..)) | Message::Delete
)
{
self.status =
"Use Reorder or Remove above the canvas to act on the complete Scenario selection."
.into();
return Task::none();
}
if self.topology_context().is_some()
&& self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
&& matches!(&message, Message::Delete)
{
self.status =
"Use Remove selected above the canvas to review the complete Process selection."
.into();
return Task::none();
}
let preview_read_only = self.process_preview_primary().is_some();
let selection_boundary = self.multiselect_boundary();
let scenario_selection_before = self.scenario_context().and_then(|context| {
self.multiselect_snapshot()
.map(|selection| (context, selection))
});
// Reject delayed gestures before they can retire newer input ownership.
if let Message::Connection(
crate::connection_edit::Event::Endpoint(captured, _)
@@ -1518,6 +1655,16 @@ impl App {
}) {
return Task::none();
}
if self.scenario_group_selected()
&& matches!(
event,
behavior::scenario::CanvasEvent::Select(..)
| behavior::scenario::CanvasEvent::Remove(..)
| behavior::scenario::CanvasEvent::Reorder { .. }
)
{
return Task::none();
}
// Passive layout/form reports retain their existing epoch rules;
// camera input uses its separate lifetime below. All other actions
// must match the exact context before shared reveal reconciliation.
@@ -1605,9 +1752,20 @@ impl App {
{
self.reconcile_multiselect();
}
if let Some((context, selection)) = scenario_selection_before
&& self.scenario_context() == Some(context)
&& self.multiselect_snapshot().as_ref() != Some(&selection)
{
// Legacy singleton selection retires old spacing packets. If the
// action already advanced its context, its new navigation/reveal
// owns that context and must not be retired by reconciliation.
self.behavior.scenario.cancel_spacing();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
self.refresh_draft();
}
self.scenario_reorder_reconcile();
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
@@ -2158,6 +2316,10 @@ impl App {
}
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
Message::ScenarioRemoval(event) => return self.scenario_removal_event(*event),
Message::ProcessRemoval(event) => return self.process_removal_event(*event),
Message::SavedRemoval(event) => return self.saved_removal_event(*event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
@@ -2666,6 +2828,9 @@ impl App {
if request.process.is_some() {
request.process = self.topology_context().map(Box::new);
}
if request.scenario.is_some() {
request.scenario = self.scenario_context().map(Box::new);
}
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
}
Message::Menu(crate::model_menu::Event::Close) => {
@@ -2695,9 +2860,21 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
if matches!(*action, Message::DiagramSelection(_))
|| (menu.request.process.is_some()
&& matches!(*action, Message::TopologyCanvas(_)))
if matches!(
*action,
Message::DiagramSelection(_) | Message::SavedRemoval(_)
) || (menu.request.process.is_some()
&& matches!(
*action,
Message::TopologyCanvas(_) | Message::ProcessRemoval(_)
))
|| (menu.request.scenario.is_some()
&& matches!(
*action,
Message::ScenarioCanvas(_)
| Message::ScenarioReorder(_)
| Message::ScenarioRemoval(_)
))
{
return self.update(*action);
}
@@ -2740,6 +2917,10 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.scenario_reorder.session.is_some() {
self.scenario_reorder_cancel();
return Task::none();
}
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
@@ -3608,9 +3789,9 @@ impl App {
.is_some_and(|s| s.targets().len() > 1)
{
self.status = if self.topology_context().is_some() {
"Group removal needs an aggregate review. Select one contribution to remove it individually."
"Use Remove selected above the canvas to review the complete Process selection."
} else {
"Group model-deletion review is not available yet. Select one element or use Hide selected."
"Use Delete from model above the canvas to review the complete selection, or Hide selected to remove placements."
}.into();
return Task::none();
}
@@ -63,6 +63,7 @@ fn fixture(layer: Layer) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
@@ -67,6 +67,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
@@ -299,6 +300,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
@@ -179,7 +179,7 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
let token = delete(&mut mounted, &mut app, true).await;
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
assert!(app.delete_pending, "old token does not cancel new dialog");
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
// Keep selection and epoch unchanged while moving to another Scenario owner.
let captured = app.scenario_context().unwrap();
assert_eq!(captured.scenario, owners[0]);
assert_eq!(
@@ -197,9 +197,28 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
assert_eq!(other.epoch, captured.epoch);
assert_eq!(app.selected, uses[0]);
assert!(
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the independent owner guard must reject an otherwise current token"
!app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the real captured snapshot rejects the changed Scenario destination"
);
// Isolate the independent behavior-owner predicate: a current, unqualified
// object request has no old Scenario envelope or destination to reject it.
// This test-only replacement does not refresh the captured behavior owner.
let otherwise_current = crate::model_menu::Opened::new(
&app,
crate::model_menu::Request::object(uses[0], Point::ORIGIN),
token,
)
.unwrap();
assert!(otherwise_current.valid(&app));
let target = app.delete_target.as_ref().unwrap();
assert_eq!(target.selection, app.selected);
assert_eq!(target.placement, None);
assert_eq!(app.selected_node, None);
assert_eq!(target.behavior_owner, Some(owners[0]));
assert_eq!(diagram_removal::behavior_owner(&app), Some(owners[1]));
assert!(app.delete_pending);
assert!(!app.behavior.has_pending() && !app.source_dirty() && !app.draft_dirty());
app.delete_target.as_mut().unwrap().snapshot = otherwise_current;
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
assert_eq!(app.document.bundle(), &original);
assert!(!app.delete_pending && app.delete_target.is_none());
@@ -15,6 +15,9 @@ pub struct Confirmation {
pub selection: Id,
pub placement: Option<Id>,
pub behavior_owner: Option<Id>,
pub(crate) scenario: Option<super::scenario_removal::Review>,
pub(crate) process: Option<super::process_removal::Review>,
pub(crate) saved: Option<super::saved_removal::Review>,
}
pub fn enabled(app: &App) -> bool {
@@ -209,6 +212,9 @@ impl App {
selection: self.selected,
placement: placement(self),
behavior_owner: behavior_owner(self),
scenario: None,
process: None,
saved: None,
}
});
self.delete_pending = self.delete_target.is_some();
+79 -2
View File
@@ -1,4 +1,4 @@
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
//! Keep overlay ownership ahead of cancellation and captured reorder Apply.
//!
//! Iced skips root.update when an overlay captures an event. Keeping this
//! wrapper present even when inactive preserves the child's widget state. A
@@ -24,9 +24,34 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
&& app.architecture.is_none()));
Element::new(Bridge {
content,
removal_cancel: app
.delete_target
.as_ref()
.filter(|target| {
app.delete_pending && (target.process.is_some() || target.saved.is_some())
})
.map(|target| {
if target.saved.is_some() {
return Message::SavedRemoval(Box::new(super::saved_removal::Event::Cancel {
token: target.snapshot.token,
}));
}
Message::ProcessRemoval(Box::new(super::process_removal::Event::Cancel {
token: target.snapshot.token,
}))
}),
reorder_active: app.scenario_reorder_enabled() && app.scenario_reorder.session.is_some(),
reorder_apply: crate::behavior::scenario::reorder_view::apply_message(app),
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape),
modal.then_some(Message::Escape).or_else(|| {
app.scenario_reorder.session.as_ref().map(|session| {
crate::behavior::scenario::reorder_view::message(
session,
super::scenario_reorder::Intent::Cancel,
)
})
}),
],
})
}
@@ -34,6 +59,9 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
removal_cancel: Option<Message>,
reorder_active: bool,
reorder_apply: Option<Message>,
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
@@ -87,6 +115,43 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.reorder_active
&& matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Enter), modifiers, .. })
if modifiers.control() && !modifiers.alt() && !modifiers.shift() && !modifiers.logo())
{
// A picker may have opened earlier in this same input batch, before
// App state can invalidate the captured reorder action.
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
Vector::ZERO,
) {
let mut current = overlay::Nested::new(element);
let node = current.layout(renderer, viewport.size());
current.update(
event,
Layout::new(&node),
cursor,
renderer,
clipboard,
shell,
);
shell.capture_event();
return;
}
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { repeat: false, .. })
) && let Some(message) = &self.reorder_apply
{
shell.publish(message.clone());
}
shell.capture_event();
return;
}
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
@@ -99,6 +164,11 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
// Escape would otherwise reach focused fields underneath it. Offer only
// Escape to the current overlay before updating the ordinary tree.
if escape {
if let Some(cancel) = &self.removal_cancel {
shell.publish(cancel.clone());
shell.capture_event();
return;
}
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
@@ -120,6 +190,13 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
if shell.is_event_captured() {
return;
}
if self.reorder_active
&& let Some(cancel) = &self.messages[1]
{
shell.publish(cancel.clone());
shell.capture_event();
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
+24
View File
@@ -100,6 +100,7 @@ impl App {
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
self.topology_context(),
self.behavior.topology.selection_address(),
self.scenario_context(),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
@@ -109,6 +110,9 @@ impl App {
if let Some(current) = self.topology_context() {
return Some(process_context(current));
}
if let Some(current) = self.scenario_context() {
return Some(crate::behavior::scenario::selection::context(current));
}
let current = self.state_context()?;
crate::behavior::state_edit::validate_context(self.project(), current, current)
.ok()?;
@@ -119,6 +123,14 @@ impl App {
}
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
if let Some(context) = self.scenario_context() {
return crate::behavior::scenario::selection::visible(
self.project(),
context,
self.selected,
)
.contains(target);
}
if let Some(context) = self.topology_context() {
return self
.behavior
@@ -152,6 +164,10 @@ impl App {
}
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
// Scenario input additionally needs its representation and visit.
if self.scenario_context().is_some() {
return false;
}
let Some(current) = self.multiselect_context() else {
return false;
};
@@ -219,6 +235,10 @@ impl App {
}
pub(crate) fn reconcile_multiselect(&mut self) {
if self.scenario_context().is_some() {
self.reconcile_scenario_multiselect();
return;
}
if self.topology_context().is_some() {
self.reconcile_process_multiselect();
return;
@@ -284,6 +304,10 @@ impl App {
}
pub(super) fn inspect_multiselect_primary(&mut self) {
if self.scenario_context().is_some() {
self.inspect_scenario_multiselect_primary();
return;
}
if self.topology_context().is_some() {
self.inspect_process_multiselect_primary();
return;
@@ -0,0 +1,230 @@
//! One captured Process selection, one impact review, one document transaction.
use super::multiselect::process_context;
use super::*;
use crate::behavior::topology::{self, group_removal};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
use crate::model_menu;
#[cfg(test)]
mod tests;
pub(crate) mod view;
#[derive(Debug, Clone)]
pub(crate) enum Event {
Begin {
input_generation: u64,
context: topology::Context,
snapshot: Box<Snapshot>,
},
Query {
token: u64,
query: String,
},
Page {
token: u64,
page: usize,
},
Cancel {
token: u64,
},
}
pub(crate) struct Review {
input_generation: u64,
pub(crate) context: topology::Context,
pub(crate) snapshot: Snapshot,
pub(crate) plan: group_removal::Plan,
pub(crate) query: String,
pub(crate) page: usize,
}
pub(crate) fn begin(app: &App) -> Option<Message> {
let input_generation = app.behavior.topology.removal_generation()?;
let context = app.topology_context()?;
let snapshot = app.multiselect_snapshot()?;
(snapshot.targets().len() > 1).then_some(Message::ProcessRemoval(Box::new(Event::Begin {
input_generation,
context,
snapshot: Box::new(snapshot),
})))
}
impl App {
pub(crate) fn process_removal_enabled(&self) -> bool {
diagram_removal::enabled(self)
&& !self.source_rename.open()
&& self.behavior.topology.selecting()
&& self.behavior.topology.removal_generation().is_some()
&& self.topology_context().is_some()
}
fn process_removal_matches(&self, context: topology::Context, snapshot: &Snapshot) -> bool {
if self.topology_context() != Some(context)
|| !self
.diagram_selection
.as_ref()
.is_some_and(|selected| selected.matches(snapshot, process_context(context)))
{
return false;
}
let visible = self
.behavior
.topology
.selection_targets(self.project(), context.owner);
snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
}
pub(super) fn process_removal_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Begin {
input_generation,
context,
snapshot,
} => {
// Admission precedes every shared input/selection boundary. A
// stale packet must not prune the set or retire a newer editor.
if self.behavior.topology.removal_generation() != Some(input_generation)
|| !self.process_removal_matches(context, &snapshot)
|| !self.process_removal_enabled()
|| snapshot.targets().len() < 2
{
return Task::none();
}
let plan =
match group_removal::prepare(self.project(), context.owner, snapshot.targets())
{
Ok(plan) => plan,
Err(reason) => {
self.status = reason;
return Task::none();
}
};
let Some(primary_target) = snapshot.primary().cloned() else {
return Task::none();
};
let primary = match &primary_target {
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
Target::ProcessLink(id) => *id,
_ => return Task::none(),
};
self.source_interaction();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.topology_context().expect("admitted Process");
let snapshot = self.multiselect_snapshot().expect("admitted selection");
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
request.process = Some(Box::new(context));
request.selection_target = Some(Box::new(primary_target));
self.menu_sequence = self.menu_sequence.wrapping_add(1);
self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence)
.map(|opened| diagram_removal::Confirmation {
snapshot: opened,
selection: self.selected,
placement: None,
behavior_owner: Some(context.owner),
scenario: None,
saved: None,
process: Some(Review {
input_generation,
context,
snapshot,
plan,
query: String::new(),
page: 0,
}),
});
self.delete_pending = self.delete_target.is_some();
}
Event::Query { token, query } => {
if let Some(target) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
&& let Some(review) = target.process.as_mut()
{
review.query = query;
review.page = 0;
}
}
Event::Page { token, page } => {
if let Some(target) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
&& let Some(review) = target.process.as_mut()
{
review.page = page;
}
}
Event::Cancel { token } => {
if self.delete_target.as_ref().is_some_and(|target| {
target.snapshot.token == token && target.process.is_some()
}) {
self.delete_target = None;
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Removal cancelled. Model and selection unchanged.".into();
}
}
}
Task::none()
}
pub(crate) fn process_removal_review_valid(
&self,
target: &diagram_removal::Confirmation,
) -> bool {
let Some(review) = &target.process else {
return false;
};
self.delete_pending
&& !self.busy
&& target.snapshot.valid(self)
&& self.behavior.topology.removal_generation() == Some(review.input_generation)
&& self.process_removal_matches(review.context, &review.snapshot)
&& self.selected == target.selection
&& !self.source_dirty()
&& !self.draft_dirty()
&& !self.behavior.has_pending()
&& !self.source_rename.open()
&& self.behavior.topology.selecting()
}
pub(super) fn confirm_process_removal(&mut self, token: u64) {
let Some(target) = self
.delete_target
.as_ref()
.filter(|target| target.snapshot.token == token && target.process.is_some())
else {
return;
};
let valid = self.process_removal_review_valid(target);
let target = self.delete_target.take().expect("captured review");
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if !valid {
self.status =
"Removal review expired. Nothing was removed; review the current selection again."
.into();
return;
}
let review = target.process.expect("Process review");
match self.document.execute(review.plan.command) {
Ok(_) => {
self.selected = review.context.owner;
self.selected_node = None;
self.behavior.active_owner = Some(review.context.owner);
self.sync();
self.reconcile_multiselect();
self.status = format!(
"Removed {} selected Process contributions or links in one change. Referenced definitions preserved. Undo restores the model.",
review.snapshot.targets().len()
);
}
Err(error) => self.status = format!("Nothing removed: {error}"),
}
}
}
@@ -0,0 +1,646 @@
use super::*;
use crate::behavior::topology::{CanvasEvent, CanvasTool, direct_tests};
use oa_core::source_project::SourceBundle;
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn choose(app: &mut App, target: Target, choice: multiselect::Choice) {
let context = app.multiselect_context().unwrap();
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
context, target, choice,
)));
}
fn open_project(project: Project, owner: Id) -> App {
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
app.reconcile_multiselect();
app.window_size = iced::Size::new(1600., 1000.);
app
}
fn fixture(layer: Layer) -> (App, direct_tests::Fixture) {
let f = direct_tests::fixture(layer);
let mut app = open_project(f.p.clone(), f.owner);
choose(
&mut app,
Target::ProcessLink(f.link),
multiselect::Choice::Replace,
);
choose(&mut app, local(f.nodes[0]), multiselect::Choice::Add);
assert!(app.process_removal_enabled());
assert_eq!(app.multiselect_snapshot().unwrap().targets().len(), 2);
(app, f)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ProcessRemoval(Box::new(event)));
}
fn open(app: &mut App) -> u64 {
let message = begin(app).unwrap();
let _ = app.update(message);
let target = app.delete_target.as_ref().expect("aggregate review");
assert!(app.process_removal_review_valid(target));
target.snapshot.token
}
fn native_bundle(source: &str) -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: [("project.arcadia".into(), source.into())]
.into_iter()
.collect(),
}
}
const BASELINE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/process-removal-baseline-20260925.arcadia"
));
const REMOVED: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
// V1 assumed canonical whitespace. V2 independently retains removed-token
// trivia and existing reference spelling under language section 7's rules.
"/../../harness/fixtures/process-removal-removed-v2-20260925.arcadia"
));
fn native() -> App {
let document = Document::from_bundle(native_bundle(BASELINE), Default::default()).unwrap();
let owner = alias(document.project(), "assessment");
let mut app = open_project(document.project().clone(), owner);
app.document = document;
// Replace the generated split workspace just as open_source_project does.
// sync preserves discovered files and is not a new-document initializer.
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.sync();
app.reconcile_multiselect();
assert_eq!(app.source_workspace.bundle(), native_bundle(BASELINE));
app
}
fn alias(project: &Project, name: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
}
#[test]
fn process_group_removal_literal_native_model_and_source_oracle_undoes_once() {
let mut app = native();
for (index, name) in ["a", "c", "approved"].iter().enumerate() {
let id = alias(app.project(), name);
let target = if *name == "approved" {
Target::ProcessLink(id)
} else {
local(id)
};
choose(
&mut app,
target,
if index == 0 {
multiselect::Choice::Replace
} else {
multiselect::Choice::Add
},
);
}
let before = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let camera = app.behavior.topology.camera_boundary();
let token = open(&mut app);
let review = app
.delete_target
.as_ref()
.unwrap()
.process
.as_ref()
.unwrap();
assert_eq!(review.snapshot.targets(), selected.targets());
assert_eq!(review.snapshot.primary(), selected.primary());
let rows = view::matching_rows(app.project(), review);
assert!(
rows.iter().any(|row| row.label.contains("@caller")
&& row.disposition.contains("Uses changed definition"))
);
assert!(
rows.iter().any(|row| row.label.contains("@outer")
&& row.disposition.contains("Uses changed definition"))
);
assert!(rows.iter().any(|row| {
row.label
.contains("Implicit listed order; 1 adjacent ordered pairs")
&& row.disposition.starts_with("After")
}));
assert_eq!(app.document.bundle(), &before.bundle);
let expected = Document::from_bundle(native_bundle(REMOVED), Default::default()).unwrap();
let _ = app.update(Message::ConfirmDelete(token));
assert_eq!(app.project(), expected.project());
assert_eq!(
app.document.bundle(),
expected.bundle(),
"literal independently authored source oracle"
);
assert_eq!(app.source_workspace.bundle(), *expected.bundle());
assert_eq!(app.selected, alias(app.project(), "assessment"));
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert!(app.behavior.topology.camera_boundary() == camera);
let removed = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &removed);
}
#[test]
fn process_group_removal_mixed_card_link_is_one_exact_transaction_in_both_layers() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut app, f) = fixture(layer);
let before = app.document.snapshot();
let token = open(&mut app);
let review = app
.delete_target
.as_ref()
.unwrap()
.process
.as_ref()
.unwrap();
assert_eq!(
review.plan.removed,
[f.nodes[0], f.link].into_iter().collect()
);
let _ = app.update(Message::ConfirmDelete(token));
let mut expected = f.p.clone();
expected.elements.remove(&f.nodes[0]);
expected.relations.remove(&f.link);
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.nodes[1], f.nodes[2]];
assert_eq!(app.project(), &expected);
for id in f.work.into_iter().chain([f.exchange]) {
assert!(app.project().contains(id));
}
let removed = app.document.bundle().clone();
assert_eq!(
Document::from_bundle(removed.clone(), Default::default())
.unwrap()
.project(),
&expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &removed);
}
}
#[test]
fn process_group_removal_cancel_stale_packets_and_failed_commit_preserve_redo_source_and_selection()
{
for failure in [false, true] {
let (mut app, f) = fixture(Layer::Oa);
app.document
.execute(Command::Rename(f.nodes[2], "Future name".into()))
.unwrap();
let future = app.document.bundle().clone();
assert!(app.document.undo());
app.sync();
app.reconcile_multiselect();
let baseline = app.document.snapshot();
let source = app.source_editor.text();
let selected = app.multiselect_snapshot().unwrap();
let camera = app.behavior.topology.camera_boundary();
let old_begin = begin(&app).unwrap();
let first = open(&mut app);
send(&mut app, Event::Cancel { token: first });
let second = open(&mut app);
let epoch = app.canvas_epoch;
for message in [
old_begin,
Message::ConfirmDelete(first),
Message::CancelDelete,
Message::Escape,
Message::ProcessRemoval(Box::new(Event::Cancel { token: first })),
Message::ProcessRemoval(Box::new(Event::Query {
token: first,
query: "stale".into(),
})),
Message::ProcessRemoval(Box::new(Event::Page {
token: first,
page: 90,
})),
] {
let _ = app.update(message);
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, second);
assert_eq!(app.canvas_epoch, epoch);
}
if failure {
// Force a genuine atomic engine failure after the admitted deletion.
// Neither the successful first candidate command nor history leaks.
let review = app
.delete_target
.as_mut()
.unwrap()
.process
.as_mut()
.unwrap();
review.plan.command = Command::Batch(vec![
Command::Delete(f.nodes[0]),
Command::Delete(Id::new_v4()),
]);
let _ = app.update(Message::ConfirmDelete(second));
assert!(app.status.starts_with("Nothing removed:"));
} else {
send(&mut app, Event::Cancel { token: second });
}
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert!(app.behavior.topology.camera_boundary() == camera);
assert!(app.document.can_redo() && !app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &future);
}
}
#[test]
fn process_group_removal_stale_secondary_and_context_are_rejected_before_any_boundary() {
for change in 0..4 {
let (mut app, f) = fixture(Layer::Sa);
let message = begin(&app).unwrap();
match change {
0 => {
app.diagram_selection
.as_mut()
.unwrap()
.remove(&Target::ProcessLink(f.link));
}
1 => {
app.canvas_document = Id::new_v4();
}
2 => {
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
}
_ => {
app.document
.execute(Command::Rename(f.nodes[2], "New revision".into()))
.unwrap();
}
}
let source = app.document.bundle().clone();
let selected = app.multiselect_snapshot();
let epoch = app.canvas_epoch;
let _ = app.update(message);
assert!(app.delete_target.is_none());
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.multiselect_snapshot(), selected);
assert_eq!(app.document.bundle(), &source);
}
let (mut app, f) = fixture(Layer::Oa);
let token = open(&mut app);
app.diagram_selection
.as_mut()
.unwrap()
.remove(&Target::ProcessLink(f.link));
let current = app.document.snapshot();
let _ = app.update(Message::ConfirmDelete(token));
assert!(app.status.contains("expired"));
assert!(std::sync::Arc::ptr_eq(&current, &app.document.snapshot()));
}
#[test]
fn process_group_removal_pending_inputs_and_legacy_singleton_messages_preserve_all_members() {
for pending in ["clean", "properties", "source", "tool", "busy", "form"] {
let (mut app, f) = fixture(Layer::Oa);
match pending {
"properties" => {
let _ = app.update(Message::Field("/name".into(), "Unapplied".into()));
}
"source" => {
let text = format!("{}\n// pending", app.source_workspace.text());
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(std::sync::Arc::new(text)),
)));
}
"tool" => app.behavior.topology.choose(CanvasTool::Connect),
"busy" => app.busy = true,
"form" => {
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(app.behavior.has_pending());
}
_ => {}
}
let baseline = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
let source = app.source_workspace.bundle();
if pending != "clean" {
let message = begin(&app).unwrap();
let _ = app.update(message);
}
let _ = app.update(Message::Delete);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Remove(
context, f.nodes[0],
)));
assert!(app.delete_target.is_none());
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo());
}
}
#[test]
fn process_group_removal_qualified_member_refuses_whole_set_without_path_stripping() {
let mut app = native();
let owner = alias(app.project(), "caller");
let root = alias(app.project(), "r1");
let other = alias(app.project(), "r2");
let leaf = alias(app.project(), "a");
let _ = app.update(Message::Select(owner));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Expand(context, root)));
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![root],
occurrence: leaf,
});
choose(&mut app, qualified.clone(), multiselect::Choice::Replace);
choose(&mut app, local(other), multiselect::Choice::Add);
let selected = app.multiselect_snapshot().unwrap();
assert_eq!(selected.targets(), &[qualified, local(other)]);
let before = app.document.snapshot();
let epoch = app.canvas_epoch;
let message = begin(&app).unwrap();
let _ = app.update(message);
assert!(app.delete_target.is_none());
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn process_group_removal_unselected_context_subject_does_not_replace_the_group() {
for unselected in [false, true] {
let (mut app, f) = fixture(Layer::Oa);
let selection = app.multiselect_snapshot().unwrap();
let clicked = if unselected { f.nodes[2] } else { f.nodes[0] };
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
request.process = app.topology_context().map(Box::new);
request.selection_target = Some(Box::new(local(clicked)));
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
let token = app.model_menu.as_ref().unwrap().token;
let message = begin(&app).unwrap();
let _ = app.update(Message::Menu(model_menu::Event::Invoke(
token,
Box::new(message),
)));
let review = app
.delete_target
.as_ref()
.unwrap()
.process
.as_ref()
.unwrap();
assert_eq!(review.snapshot.targets(), selection.targets());
assert_eq!(review.snapshot.primary(), selection.primary());
assert!(!review.plan.removed.contains(&f.nodes[2]));
}
}
#[test]
fn process_review_lists_transitive_callers_even_when_all_modes_stay_explicit() {
let mut app = native();
let a = alias(app.project(), "a");
let link = alias(app.project(), "approved");
choose(&mut app, local(a), multiselect::Choice::Replace);
choose(
&mut app,
Target::ProcessLink(link),
multiselect::Choice::Add,
);
let token = open(&mut app);
let review = app
.delete_target
.as_ref()
.unwrap()
.process
.as_ref()
.unwrap();
assert!(
review.plan.interpretation.is_empty(),
"remaining Control keeps the definition explicit"
);
let rows = view::matching_rows(app.project(), review);
for alias in ["@caller", "@outer"] {
assert!(
rows.iter().any(|row| row.label.contains(alias)
&& row.disposition.contains("Uses changed definition"))
);
}
send(
&mut app,
Event::Query {
token,
query: "@outer".into(),
},
);
let review = app
.delete_target
.as_ref()
.unwrap()
.process
.as_ref()
.unwrap();
let rows = view::matching_rows(app.project(), review);
assert_eq!(rows.len(), 1);
assert!(rows[0].label.contains("@assessment"));
assert!(rows[0].label.contains("03 Mission using Response"));
}
#[tokio::test]
async fn mounted_process_group_delete_and_shift_delete_capture_group_escape_is_stamped() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
keyboard::{self, key::Named},
mouse,
};
for shift in [false, true] {
let (mut app, _) = fixture(Layer::Oa);
let selected = app.multiselect_snapshot().unwrap();
let source = app.document.bundle().clone();
let mut mounted = Mounted::new(&mut app).await;
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
let mut remove = key(Named::Delete);
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove {
*modifiers = if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::default()
};
}
let output = mounted
.batch(&mut app, &[remove.clone()], mouse::Cursor::Unavailable)
.await;
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{output:?}");
let token = app.delete_target.as_ref().unwrap().snapshot.token;
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
*repeat = true;
}
let output = mounted
.batch(&mut app, &[remove], mouse::Cursor::Unavailable)
.await;
assert!(
!output
.iter()
.any(|message| matches!(message, Message::ConfirmDelete(_)))
);
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, token);
let output = mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Cancel { token: captured } if *captured == token))), "{output:?}");
assert!(app.delete_target.is_none());
assert_eq!(app.document.bundle(), &source);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert!(!app.document.can_undo());
}
}
#[tokio::test]
async fn mounted_process_group_removal_queued_delete_is_revoked_by_later_press_in_same_batch() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard::key::Named, mouse};
let (mut app, f) = fixture(Layer::Oa);
let mut mounted = Mounted::new(&mut app).await;
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let local = app
.behavior
.topology
.removal_test_point(&app, f.owner, f.nodes[0], None);
let at = bounds.position() + iced::Vector::new(local.x, local.y);
let before = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
let messages = mounted
.batch(
&mut app,
&[
key(Named::Delete),
Input::Mouse(mouse::Event::CursorMoved { position: at }),
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(at),
)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "the first event queues a real review packet: {messages:?}");
assert!(
app.delete_target.is_none(),
"later press must revoke that packet before App consumes it"
);
assert_eq!(
app.canvas_epoch, epoch,
"refusal does not retire the active gesture"
);
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.process_removal_enabled());
assert!(
begin(&app).is_none(),
"active gesture cannot capture another review"
);
mounted
.batch(
&mut app,
&[key(Named::Escape)],
mouse::Cursor::Available(at),
)
.await;
// Escape leaves viewport focus; F6 restores keyboard ownership and sends
// the canvas cancellation boundary before a fresh Delete.
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(app.process_removal_enabled());
// Cancelling the pointer gesture retains the group and permits a fresh review.
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
mounted
.batch(&mut app, &[key(Named::Delete)], mouse::Cursor::Unavailable)
.await;
assert!(app.delete_target.is_some());
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[tokio::test]
async fn mounted_process_group_removal_queued_delete_is_revoked_by_camera_pan_start() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard::key::Named, mouse};
for button in [mouse::Button::Middle, mouse::Button::Left] {
let (mut app, _) = fixture(Layer::Sa);
let mut mounted = Mounted::new(&mut app).await;
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let at = iced::Point::new(
bounds.x + bounds.width - 12.,
bounds.y + bounds.height - 12.,
);
let before = app.document.snapshot();
let messages = mounted
.batch(
&mut app,
&[
key(Named::Delete),
Input::Mouse(mouse::Event::CursorMoved { position: at }),
Input::Mouse(mouse::Event::ButtonPressed(button)),
],
mouse::Cursor::Available(at),
)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "real Delete is queued: {messages:?}");
assert!(
app.delete_target.is_none(),
"camera input retires earlier review packet"
);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
}
@@ -0,0 +1,156 @@
//! Review the complete captured removal impact before one explicit confirmation.
use super::{Event, Review};
use crate::app::{App, Message, diagram_removal};
use crate::diagram_usage::Target;
use crate::style;
use iced::widget::{button, column, container, row, scrollable, text, text_input};
use iced::{Element, Length::Fill};
use oa_core::Project;
use std::collections::BTreeSet;
const PAGE_SIZE: usize = 6;
pub(super) fn matching_rows(
project: &Project,
review: &Review,
) -> Vec<super::super::removal_review::ImpactRow> {
let selected = review
.snapshot
.targets()
.iter()
.filter_map(|target| match target {
Target::ProcessOccurrence(endpoint) if endpoint.references.is_empty() => {
Some(endpoint.occurrence)
}
Target::ProcessLink(id) => Some(*id),
_ => None,
})
.collect();
super::super::removal_review::matching_rows(
project,
&review.plan.removed,
&review.plan.changed,
&selected,
&review.plan.interpretation,
&review.plan.affected_uses,
&review.query,
)
}
pub(crate) fn view<'a>(
app: &'a App,
target: &'a diagram_removal::Confirmation,
) -> Element<'a, Message> {
let Some(review) = target.process.as_ref() else {
return text("Process removal review is unavailable.").into();
};
let token = target.snapshot.token;
let valid = app.process_removal_review_valid(target);
let rows = matching_rows(app.project(), review);
let callers: BTreeSet<_> = review
.plan
.affected_uses
.values()
.flatten()
.chain(
review
.plan
.interpretation
.iter()
.flat_map(|impact| impact.callers.iter()),
)
.copied()
.collect();
let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() {
"No listed-order or analysis-mode changes; no affected caller definitions.".to_owned()
} else {
format!(
"Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.",
review.plan.interpretation.len(),
callers.len()
)
};
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
let page = review.page.min(pages - 1);
let mut impacts = column![].spacing(12).width(Fill);
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
impacts = impacts.push(
column![
text(impact.disposition.clone())
.size(12)
.color(style::MUTED),
text(impact.label.clone())
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
]
.spacing(3)
.width(Fill),
);
}
if rows.is_empty() {
impacts = impacts.push(text("No matching effects. Try a name, alias or effect.").size(13));
}
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
Message::ProcessRemoval(Box::new(Event::Page {
token,
page: page - 1,
}))
}));
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
Message::ProcessRemoval(Box::new(Event::Page {
token,
page: page + 1,
}))
}));
let mut footer = column![].spacing(8).width(Fill);
if !valid {
footer = footer.push(
text("This review expired. Cancel and review the current selection again.")
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
}
footer = footer.push(
row![
container(
button("Cancel")
.padding(10)
.on_press(Message::ProcessRemoval(Box::new(Event::Cancel { token })))
)
.id("delete-cancel"),
container(
button("Remove selected contributions / links")
.style(button::danger)
.padding(10)
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
)
.id("delete-confirm"),
]
.spacing(10),
);
column![
text(format!("Remove {} selected contributions / links?", review.snapshot.targets().len())).size(21),
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
text_input("Search affected names, aliases or effects", &review.query)
.id("process-removal-query")
.on_input(move |query| Message::ProcessRemoval(Box::new(Event::Query { token, query })))
.size(13),
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
.spacing(10).align_y(iced::Alignment::Center),
scrollable(column![
text("Removes selected local contributions or maintained links and the additional records listed below. Referenced definitions, work and exchanges are preserved. Reused topology, entry/exit connections and diagnostics may change. Review every listed order and affected caller definition; this review does not compute complete caller graphs. One Undo restores the entire change.")
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
impacts,
].spacing(14).width(Fill))
.width(Fill).height(Fill).spacing(8).id("process-removal-impact"),
footer,
]
.spacing(12)
.width(Fill)
// Keep confirmation and search outside the flexible, scrollable impact body.
.height((app.window_size.height - 160.).clamp(260., 570.))
.into()
}
+148
View File
@@ -0,0 +1,148 @@
//! Shared stored-record and derived Process effect descriptions.
use oa_core::{Id, Project};
use std::collections::{BTreeMap, BTreeSet};
pub(crate) struct ImpactRow {
pub(crate) label: String,
pub(crate) disposition: String,
}
pub(crate) fn matching_rows(
project: &Project,
removed: &BTreeSet<Id>,
changed: &BTreeSet<Id>,
selected: &BTreeSet<Id>,
interpretation: &[crate::removal_effects::InterpretationChange],
affected_uses: &BTreeMap<Id, BTreeSet<Id>>,
query: &str,
) -> Vec<ImpactRow> {
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
let label = |id: Id| {
let name = project.named(id);
let identity = aliases
.get(&id)
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
if name == "Missing object" || name.is_empty() {
identity
} else {
format!("{name} ({identity})")
}
};
let mut rows = vec![];
for id in removed {
rows.push(ImpactRow {
label: label(*id),
disposition: if selected.contains(id) {
"Removed · selected"
} else {
"Additional removed"
}
.into(),
});
}
for id in changed {
rows.push(ImpactRow {
label: label(*id),
disposition: "Updated · preserved".into(),
});
}
// Interpretation is separate from stored record deltas: callers may keep
// identical records while their reused definition or analysis changes.
for impact in interpretation {
let owner = label(impact.owner);
let shared_order = impact.before.local_order == impact.before.expanded_order
&& impact.after.local_order == impact.after.expanded_order;
for (dimension, before, after, shown) in [
(
if shared_order {
"Listed and reused order"
} else {
"Local listed order"
},
&impact.before.local_order,
&impact.after.local_order,
true,
),
(
"Order inside reused definitions",
&impact.before.expanded_order,
&impact.after.expanded_order,
!shared_order,
),
] {
if !shown || before == after {
continue;
}
for (when, order) in [("Before", before), ("After", after)] {
let summary = match order {
None => "Explicit topology; no implicit listed order".to_owned(),
Some(ids) if ids.is_empty() => {
"Empty order; no ordered pair; contextual flow may be incomplete".to_owned()
}
Some(ids) if ids.len() == 1 => {
"One item in this order; no ordered pair".to_owned()
}
Some(ids) => format!(
"Implicit listed order; {} adjacent ordered pairs",
ids.len() - 1
),
};
rows.push(ImpactRow {
label: format!("{owner}: {summary}"),
disposition: format!("{when} · {dimension}"),
});
if let Some(ids) = order {
for (index, id) in ids.iter().enumerate() {
rows.push(ImpactRow {
label: format!("{owner}: #{} {}", index + 1, label(*id)),
disposition: format!("{when} · {dimension} · listed position"),
});
}
}
}
}
if impact.before.contextual_analysis != impact.after.contextual_analysis {
rows.push(ImpactRow {
label: format!(
"{owner}: contextual analysis {} → {}",
if impact.before.contextual_analysis {
"enabled"
} else {
"disabled"
},
if impact.after.contextual_analysis {
"enabled"
} else {
"disabled"
}
),
disposition:
"Analysis mode changes; this is not a claim of complete graph connectivity"
.into(),
});
}
}
let mut affected = affected_uses.clone();
for impact in interpretation {
affected
.entry(impact.owner)
.or_default()
.extend(impact.callers.iter().copied());
}
for (definition, callers) in affected {
for caller in callers {
rows.push(ImpactRow {
label: format!("{} uses {}", label(caller), label(definition)),
disposition: "Uses changed definition · affected caller definition".into(),
});
}
}
let query = query.trim().to_lowercase();
rows.into_iter()
.filter(|row| {
query.is_empty()
|| row.label.to_lowercase().contains(&query)
|| row.disposition.to_lowercase().contains(&query)
})
.collect()
}
+296
View File
@@ -0,0 +1,296 @@
//! Explicit saved-view model deletion: one captured group and one transaction.
use super::*;
use crate::diagram::group_removal;
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
use crate::{model_menu, route_edit::Context};
#[cfg(test)]
mod tests;
pub(crate) mod view;
#[derive(Debug, Clone)]
pub(crate) enum Event {
Begin {
input_generation: u64,
context: Context,
snapshot: Box<Snapshot>,
},
Query {
token: u64,
query: String,
},
Page {
token: u64,
page: usize,
},
Cancel {
token: u64,
},
}
pub(crate) struct Review {
input_generation: u64,
pub(crate) context: Context,
pub(crate) snapshot: Snapshot,
pub(crate) plan: group_removal::Plan,
pub(crate) query: String,
pub(crate) page: usize,
}
fn context(app: &App) -> Option<Context> {
let current = match app.page {
Page::Diagram => app.route_context()?,
Page::Behavior if app.topology_context().is_none() && app.scenario_context().is_none() => {
app.state_context()?
}
_ => return None,
};
let saved = app.project().diagrams.get(&current.diagram)?;
(saved.projection.is_architecture() || saved.projection.state_machine().is_some())
.then_some(current)
}
pub(crate) fn begin(app: &App) -> Option<Message> {
let input_generation = app.saved_removal_input.idle()?;
let context = context(app)?;
let snapshot = app.multiselect_snapshot()?;
(snapshot.targets().len() > 1).then_some(Message::SavedRemoval(Box::new(Event::Begin {
input_generation,
context,
snapshot: Box::new(snapshot),
})))
}
impl App {
fn saved_removal_selecting(&self) -> bool {
self.connection.is_none()
&& self.selected_bend.is_none()
&& match self.page {
Page::Diagram => self
.architecture
.as_ref()
.is_none_or(|state| !state.inline() && !state.saved.placing),
Page::Behavior => {
self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select
}
_ => false,
}
}
pub(crate) fn saved_removal_enabled(&self) -> bool {
diagram_removal::enabled(self)
&& !self.source_rename.open()
&& self.saved_removal_selecting()
&& self.saved_removal_input.idle().is_some()
&& context(self).is_some()
}
fn saved_removal_matches(&self, captured: Context, snapshot: &Snapshot) -> bool {
context(self) == Some(captured)
&& self.diagram_selection.as_ref().is_some_and(|selection| {
selection.matches(snapshot, multiselect::context(captured))
})
&& snapshot
.targets()
.iter()
.all(|target| self.multiselect_target_visible(target))
}
pub(super) fn saved_removal_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Begin {
input_generation,
context: captured,
snapshot,
} => {
// Refuse before source/editor/selection boundaries can disturb a
// newer interaction, including a later press in the same batch.
if self.saved_removal_input.idle() != Some(input_generation)
|| !self.saved_removal_matches(captured, &snapshot)
|| !self.saved_removal_enabled()
|| snapshot.targets().len() < 2
{
return Task::none();
}
let plan = match group_removal::prepare(
self.project(),
captured.diagram,
snapshot.targets(),
) {
Ok(plan) => plan,
Err(reason) => {
self.status = reason;
return Task::none();
}
};
let Some(primary_target) = snapshot.primary().cloned() else {
return Task::none();
};
let (primary, placement) = match &primary_target {
Target::Placement(id) => (
self.project().diagrams[&captured.diagram].nodes[id].element,
Some(*id),
),
Target::Port { port, .. } => (*port, None),
Target::Edge(id) => (*id, None),
Target::StateRow { relation, .. } => (*relation, None),
_ => return Task::none(),
};
let Some(token) = self.menu_sequence.checked_add(1) else {
return Task::none();
};
self.source_interaction();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = context(self).expect("admitted saved view");
let snapshot = self.multiselect_snapshot().expect("admitted selection");
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
request.canvas = Some(context);
request.placement = placement;
request.selection_target = Some(Box::new(primary_target));
self.menu_sequence = token;
self.delete_target = model_menu::Opened::new(self, request, token).map(|opened| {
diagram_removal::Confirmation {
snapshot: opened,
selection: self.selected,
placement,
behavior_owner: None,
scenario: None,
process: None,
saved: Some(Review {
input_generation,
context,
snapshot,
plan,
query: String::new(),
page: 0,
}),
}
});
self.delete_pending = self.delete_target.is_some();
}
Event::Query { token, query } => {
if let Some(review) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
.and_then(|target| target.saved.as_mut())
{
review.query = query;
review.page = 0;
}
}
Event::Page { token, page } => {
if let Some(review) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
.and_then(|target| target.saved.as_mut())
{
review.page = page;
}
}
Event::Cancel { token } => {
if self
.delete_target
.as_ref()
.is_some_and(|target| target.snapshot.token == token && target.saved.is_some())
{
self.delete_target = None;
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Model deletion cancelled. Model and selection unchanged.".into();
}
}
}
Task::none()
}
pub(crate) fn saved_removal_review_valid(
&self,
target: &diagram_removal::Confirmation,
) -> bool {
let Some(review) = &target.saved else {
return false;
};
self.delete_pending
&& !self.busy
&& target.snapshot.valid(self)
&& self.saved_removal_input.idle() == Some(review.input_generation)
&& self.saved_removal_matches(review.context, &review.snapshot)
&& self.selected == target.selection
&& !self.source_dirty()
&& !self.draft_dirty()
&& !self.behavior.has_pending()
&& !self.source_rename.open()
&& self.saved_removal_selecting()
}
pub(super) fn confirm_saved_removal(&mut self, token: u64) {
let Some(target) = self
.delete_target
.as_ref()
.filter(|target| target.snapshot.token == token && target.saved.is_some())
else {
return;
};
let valid = self.saved_removal_review_valid(target);
let target = self.delete_target.take().expect("captured saved review");
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if !valid {
self.status = "Model deletion review expired. Nothing was deleted; review the current selection again.".into();
return;
}
let review = target.saved.expect("saved review");
let machine = self.project().diagrams[&review.context.diagram]
.projection
.state_machine();
match self.document.execute(review.plan.command) {
Ok(_) => {
self.selected_node = None;
self.behavior.diagram.placement = None;
// A selected machine can own the current saved view. Retire
// those UI references only after its transaction succeeds.
if self
.behavior
.active_owner
.is_some_and(|id| !self.project().contains(id))
{
self.behavior.active_owner = None;
}
if self
.behavior
.diagram
.diagram
.is_some_and(|id| !self.project().diagrams.contains_key(&id))
{
self.behavior.diagram = Default::default();
}
if self.page == Page::Behavior
&& let Some(machine) = machine.filter(|id| self.project().contains(*id))
{
self.selected = machine;
self.behavior.active_owner = Some(machine);
} else {
self.selected = if self
.project()
.diagrams
.contains_key(&review.context.diagram)
{
review.context.diagram
} else {
self.project().id
};
}
self.sync();
self.reconcile_multiselect();
self.status = format!(
"Deleted {} selected model subjects in one change across all views. Undo restores the model.",
review.plan.selected.len()
);
}
Err(error) => self.status = format!("Nothing deleted: {error}"),
}
}
}
@@ -0,0 +1,940 @@
//! Saved semantic removal through App, with independently edited complete models.
use super::*;
use crate::diagram_usage::Target;
use oa_core::*;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn saved(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: format!("saved_{}", id.simple()),
name: "Saved view".into(),
description: "Authored view metadata must survive".into(),
layer,
projection,
nodes: Default::default(),
routes: vec![],
layout: None,
view_options: Default::default(),
},
);
id
}
fn architecture(project: Project, diagram: Id) -> App {
validate_candidate(&project).unwrap();
let layer = project.diagrams[&diagram].layer;
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = layer;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.selected_node = None;
app.refresh_draft();
app.reconcile_multiselect();
app.window_size = iced::Size::new(1600., 1000.);
app
}
fn choose(app: &mut App, targets: &[Target]) {
for (index, target) in targets.iter().enumerate() {
let context = app.multiselect_context().unwrap();
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
context,
target.clone(),
if index == 0 {
multiselect::Choice::Replace
} else {
multiselect::Choice::Add
},
)));
}
assert_eq!(app.multiselect_snapshot().unwrap().targets(), targets);
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::SavedRemoval(Box::new(event)));
}
fn open(app: &mut App) -> u64 {
assert!(app.saved_removal_enabled());
let message = begin(app).expect("captured saved group");
let _ = app.update(message);
let confirmation = app.delete_target.as_ref().expect("saved group review");
assert!(app.saved_removal_review_valid(confirmation));
assert!(confirmation.saved.is_some());
confirmation.snapshot.token
}
fn source_matches(app: &App) {
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
}
fn mounted_fixture(state_view: bool) -> App {
if state_view {
let mut f = state_fixture(Layer::Sa);
choose(
&mut f.app,
&[Target::Placement(f.na), Target::Placement(f.nb)],
);
f.app
} else {
architecture_fixture(Layer::Sa).app
}
}
fn focus_canvas(mounted: &mut crate::behavior::canvas_workspace::tests::Mounted, app: &App) {
use iced::advanced::widget::{self, operation::Focusable};
struct Focus;
impl widget::Operation for Focus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: iced::Rectangle,
state: &mut dyn Focusable,
) {
if id == Some(&widget::Id::from("diagram-keyboard-owner")) {
state.focus();
} else {
state.unfocus();
}
}
}
mounted.operate(app, &mut Focus);
}
fn shift_delete() -> iced::Event {
let mut event =
crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
*modifiers = iced::keyboard::Modifiers::SHIFT;
}
event
}
#[tokio::test]
async fn saved_removal_mounted_shift_delete_review_query_and_stamped_escape_preserve_source() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{keyboard::key::Named, mouse};
for state_view in [false, true] {
let mut app = mounted_fixture(state_view);
let mut mounted = Mounted::new(&mut app).await;
focus_canvas(&mut mounted, &app);
let selected = app.multiselect_snapshot().unwrap();
let before = app.document.snapshot();
let messages = mounted
.batch(&mut app, &[shift_delete()], mouse::Cursor::Unavailable)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{state_view}: {messages:?}");
let confirmation = app.delete_target.as_ref().unwrap();
assert!(app.saved_removal_review_valid(confirmation));
let token = confirmation.snapshot.token;
send(
&mut app,
Event::Query {
token,
query: "no matching record".into(),
},
);
mounted.settle(&mut app).await;
assert!(app.saved_removal_review_valid(app.delete_target.as_ref().unwrap()));
let messages = mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Cancel { token: t } if *t == token))), "{messages:?}");
assert!(app.delete_target.is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
}
}
#[tokio::test]
async fn saved_removal_mounted_later_press_or_tab_revokes_queued_review_before_app() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard::key::Named, mouse};
for state_view in [false, true] {
for tab in [false, true] {
let mut app = mounted_fixture(state_view);
let mut mounted = Mounted::new(&mut app).await;
focus_canvas(&mut mounted, &app);
let geometry = mounted.geometry(&app);
let bounds = geometry
.focused
.iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.unwrap()
.1;
let at = iced::Point::new(
bounds.x + bounds.width - 12.,
bounds.y + bounds.height - 12.,
);
let before = app.document.snapshot();
let boundary = if tab {
key(Named::Tab)
} else {
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
};
let messages = mounted
.batch(
&mut app,
&[
shift_delete(),
Input::Mouse(mouse::Event::CursorMoved { position: at }),
boundary,
],
mouse::Cursor::Available(at),
)
.await;
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { .. }))), "{state_view}/{tab}: {messages:?}");
assert!(
app.delete_target.is_none(),
"later input must retire the queued request"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
if !tab {
assert!(begin(&app).is_none(), "active pan owns an input lease");
mounted
.batch(
&mut app,
&[Input::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
app.saved_removal_input.idle().is_some(),
"abandoned gesture releases its lease"
);
}
}
}
}
struct Architecture {
app: App,
diagram: Id,
parent: Id,
child: Id,
survivor: Id,
parent_node: Id,
child_node: Id,
survivor_node: Id,
edge: Id,
port: Option<Id>,
expected: Project,
}
fn architecture_fixture(layer: Layer) -> Architecture {
let mut project = Project::empty();
let kind = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let parent = project.add_element(kind, layer, "Parent", None).unwrap();
let child = project
.add_element(kind, layer, "Child", Some(parent))
.unwrap();
let survivor = project.add_element(kind, layer, "Survivor", None).unwrap();
project.elements.get_mut(&survivor).unwrap().description =
"Unrelated authored description".into();
let edge = project.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Incident",
child,
survivor,
);
let port = match project.relations[&edge].body {
RelationBody::FunctionalExchange { source_port, .. } => Some(source_port),
_ => None,
};
let diagram = saved(&mut project, layer, ProjectionKind::Overview);
let parent_node = project.place(diagram, parent, point(0., 0.));
let child_node = project.place(diagram, child, point(40., 100.));
let survivor_node = project.place(diagram, survivor, point(800., 50.));
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(450.25, 90.5)],
label: None,
});
let other = saved(&mut project, layer, ProjectionKind::Overview);
let other_child = project.place(other, child, point(-40., 100.));
project.place(other, survivor, point(700., 100.));
project
.diagrams
.get_mut(&other)
.unwrap()
.view_options
.hidden_edges
.push(edge);
let mut expected = project.clone();
expected.elements.remove(&parent);
expected.elements.remove(&child);
if let Some(port) = port {
expected.elements.remove(&port);
}
expected.relations.remove(&edge);
let view = expected.diagrams.get_mut(&diagram).unwrap();
view.nodes.remove(&parent_node);
view.nodes.remove(&child_node);
view.routes.clear();
let view = expected.diagrams.get_mut(&other).unwrap();
view.nodes.remove(&other_child);
view.view_options.hidden_edges.clear();
validate_candidate(&expected).unwrap();
let mut app = architecture(project, diagram);
let mut targets = vec![
Target::Placement(parent_node),
Target::Placement(child_node),
Target::Edge(edge),
];
if let Some(port) = port {
targets.push(Target::Port {
port,
placement: child_node,
});
}
choose(&mut app, &targets);
Architecture {
app,
diagram,
parent,
child,
survivor,
parent_node,
child_node,
survivor_node,
edge,
port,
expected,
}
}
#[test]
fn saved_architecture_mixed_semantics_are_one_exact_model_source_transaction_in_oa_and_sa() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = architecture_fixture(layer);
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
let camera = (f.app.zoom, f.app.pan);
let token = open(&mut f.app);
let review = f
.app
.delete_target
.as_ref()
.unwrap()
.saved
.as_ref()
.unwrap();
let mut subjects: BTreeSet<_> = [f.parent, f.child, f.edge].into_iter().collect();
subjects.extend(f.port);
assert_eq!(review.plan.selected, subjects);
assert_eq!(review.snapshot.targets(), selection.targets());
assert_eq!(review.snapshot.primary(), selection.primary());
assert_eq!(f.app.document.bundle(), &before.bundle);
let _ = f.app.update(Message::ConfirmDelete(token));
assert_eq!(f.app.project(), &f.expected);
assert_eq!(f.app.selected, f.diagram);
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!((f.app.zoom, f.app.pan), camera);
source_matches(&f.app);
let after = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &f.expected);
assert_eq!(f.app.document.bundle(), &after);
source_matches(&f.app);
}
}
struct States {
app: App,
diagram: Id,
other: Id,
machine: Id,
region: Id,
a: Id,
b: Id,
na: Id,
nb: Id,
work: Id,
action: Id,
row: Id,
edge: Id,
}
fn state_fixture(layer: Layer) -> States {
let mut project = Project::empty();
let owner = project
.add_element(
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
"Owner",
None,
)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, layer, "Modes", Some(owner))
.unwrap();
let region = project
.add_element(Kind::Region, layer, "Region", Some(machine))
.unwrap();
let a = project
.add_element(Kind::State, layer, "Operating", Some(region))
.unwrap();
let b = project
.add_element(Kind::State, layer, "Standby", Some(region))
.unwrap();
let work = project
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
"Reusable work",
None,
)
.unwrap();
let action = project.relate(RelationKind::StateAction, "Entry work", a, work);
let row = project.relate(RelationKind::StateTransition, "Internal check", a, a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut project.relations.get_mut(&row).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
let edge = project.relate(RelationKind::StateTransition, "Change mode", a, b);
let diagram = saved(
&mut project,
layer,
ProjectionKind::StateMachine { machine },
);
let na = project.place(diagram, a, point(10., 20.));
let nb = project.place(diagram, b, point(600., 20.));
let other = saved(
&mut project,
layer,
ProjectionKind::StateMachine { machine },
);
project.place(other, a, point(70., 80.));
project.place(other, b, point(670., 80.));
let architecture_view = saved(&mut project, layer, ProjectionKind::Overview);
project.place(architecture_view, owner, point(0., 0.));
validate_candidate(&project).unwrap();
let mut app = architecture(project, architecture_view);
app.page = Page::Behavior;
app.behavior.active_owner = Some(machine);
app.behavior.diagram = crate::behavior::state_view::State {
diagram: Some(diagram),
..Default::default()
};
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
States {
app,
diagram,
other,
machine,
region,
a,
b,
na,
nb,
work,
action,
row,
edge,
}
}
#[test]
fn saved_state_rows_delete_relations_not_referenced_work_and_state_overlap_is_exact() {
for layer in [Layer::Oa, Layer::Sa] {
for include_owner in [false, true] {
let mut f = state_fixture(layer);
let mut targets = vec![
Target::StateRow {
placement: f.na,
relation: f.action,
},
Target::StateRow {
placement: f.na,
relation: f.row,
},
];
if include_owner {
targets.push(Target::Placement(f.na));
} else {
targets.push(Target::Placement(f.nb));
}
choose(&mut f.app, &targets);
let before = f.app.document.snapshot();
let camera = (f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan);
let mut expected = f.app.project().clone();
expected.relations.remove(&f.action);
expected.relations.remove(&f.row);
expected.relations.remove(&f.edge);
let removed_state = if include_owner { f.a } else { f.b };
expected.elements.remove(&removed_state);
for view in expected.diagrams.values_mut() {
view.nodes.retain(|_, node| node.element != removed_state);
}
validate_candidate(&expected).unwrap();
let token = open(&mut f.app);
let review = f
.app
.delete_target
.as_ref()
.unwrap()
.saved
.as_ref()
.unwrap();
assert_eq!(
review.context.diagram, f.diagram,
"must not resolve stale Architecture field"
);
assert_eq!(
review.plan.selected,
[f.action, f.row, removed_state].into_iter().collect()
);
assert!(!review.plan.removed.contains(&f.work));
let _ = f.app.update(Message::ConfirmDelete(token));
assert_eq!(f.app.project(), &expected);
assert!(f.app.project().contains(f.work));
assert_eq!(
(f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan),
camera
);
assert!(f.app.project().contains(f.app.selected));
source_matches(&f.app);
let after = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &after);
}
}
}
#[test]
fn saved_cancel_and_stale_modal_packets_preserve_exact_redo_source_selection_and_camera() {
let mut f = architecture_fixture(Layer::Sa);
f.app
.document
.execute(Command::Rename(f.survivor, "Future name".into()))
.unwrap();
let future = f.app.document.snapshot();
assert!(f.app.document.undo());
f.app.sync();
f.app.reconcile_multiselect();
let before = f.app.document.snapshot();
let editor = f.app.source_editor.text();
let selected = f.app.multiselect_snapshot().unwrap();
let camera = (f.app.zoom, f.app.pan);
let old_begin = begin(&f.app).unwrap();
let first = open(&mut f.app);
send(&mut f.app, Event::Cancel { token: first });
let second = open(&mut f.app);
let epoch = f.app.canvas_epoch;
for message in [
old_begin,
Message::ConfirmDelete(first),
Message::CancelDelete,
Message::Escape,
Message::SavedRemoval(Box::new(Event::Cancel { token: first })),
Message::SavedRemoval(Box::new(Event::Query {
token: first,
query: "stale".into(),
})),
Message::SavedRemoval(Box::new(Event::Page {
token: first,
page: 999,
})),
] {
let _ = f.app.update(message);
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, second);
assert_eq!(f.app.canvas_epoch, epoch);
let review = f
.app
.delete_target
.as_ref()
.unwrap()
.saved
.as_ref()
.unwrap();
assert!(review.query.is_empty());
assert_eq!(review.page, 0);
}
send(&mut f.app, Event::Cancel { token: second });
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.source_editor.text(), editor);
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert_eq!((f.app.zoom, f.app.pan), camera);
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &future.decoded.project);
assert_eq!(f.app.document.bundle(), &future.bundle);
}
#[test]
fn saved_begin_and_confirm_reject_selection_membership_and_primary_aba() {
for after_open in [false, true] {
for primary_only in [false, true] {
let mut f = architecture_fixture(Layer::Oa);
let delayed = begin(&f.app).unwrap();
let token = after_open.then(|| open(&mut f.app));
let before = f.app.document.snapshot();
let captured = f.app.multiselect_snapshot().unwrap();
let selected = f.app.diagram_selection.as_mut().unwrap();
let primary = captured.primary().unwrap();
if primary_only {
assert!(selected.set_primary(&Target::Placement(f.parent_node)));
assert!(selected.set_primary(primary));
} else {
assert!(selected.remove(primary));
assert!(selected.add(primary.clone(), |_| true));
}
let restored = f.app.multiselect_snapshot().unwrap();
assert_eq!(restored.targets(), captured.targets());
assert_eq!(restored.primary(), captured.primary());
assert_ne!(restored.generation(), captured.generation());
let epoch = f.app.canvas_epoch;
let _ = f
.app
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
assert!(f.app.delete_target.is_none());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
restored.targets()
);
if !after_open {
assert_eq!(f.app.canvas_epoch, epoch);
}
}
}
}
#[test]
fn saved_stale_context_packets_do_not_retire_current_selection_or_history() {
for change in 0..4 {
let mut f = architecture_fixture(Layer::Sa);
let delayed = begin(&f.app).unwrap();
match change {
0 => {
f.app.canvas_document = Id::new_v4();
}
1 => {
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
}
2 => {
f.app
.document
.execute(Command::Rename(f.survivor, "New revision".into()))
.unwrap();
}
_ => {
f.app.active_diagram = f
.app
.project()
.diagrams
.keys()
.copied()
.find(|id| *id != f.diagram);
}
}
let current = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot();
let epoch = f.app.canvas_epoch;
let _ = f.app.update(delayed);
assert!(f.app.delete_target.is_none());
assert!(Arc::ptr_eq(&current, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!(f.app.canvas_epoch, epoch);
}
}
#[test]
fn saved_admission_preserves_property_source_and_state_editor_drafts_and_nonselect_tool() {
for pending in ["properties", "source", "form", "tool", "busy"] {
let mut f = state_fixture(Layer::Sa);
choose(
&mut f.app,
&[Target::Placement(f.na), Target::Placement(f.nb)],
);
let delayed = begin(&f.app).unwrap();
match pending {
"properties" => {
let _ = f
.app
.update(Message::Field("/name".into(), "Unapplied".into()));
}
"source" => {
let text = format!("{}\n// pending", f.app.source_workspace.text());
let _ = f
.app
.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
}
"form" => {
let _ = f.app.update(Message::Behavior(behavior::Event::States(
behavior::states::Event::Edit(f.edge),
)));
let _ = f.app.update(Message::Behavior(behavior::Event::States(
behavior::states::Event::Guard("Retained guard".into()),
)));
}
"tool" => {
f.app.behavior.diagram.tool = behavior::state_edit::Tool::Transition;
}
_ => f.app.busy = true,
}
let before = f.app.document.snapshot();
let source = f.app.source_workspace.bundle();
let selection = f.app.multiselect_snapshot();
let draft = f
.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
let form = f.app.behavior.recovery_snapshot();
let epoch = f.app.canvas_epoch;
let _ = f.app.update(delayed);
assert!(f.app.delete_target.is_none(), "{pending}");
assert!(
Arc::ptr_eq(&before, &f.app.document.snapshot()),
"{pending}"
);
assert_eq!(f.app.source_workspace.bundle(), source);
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!(
f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
assert_eq!(f.app.behavior.recovery_snapshot(), form);
assert_eq!(f.app.canvas_epoch, epoch);
}
}
#[test]
fn saved_input_lease_and_finished_gesture_reject_old_packets_at_begin_and_confirm() {
for after_open in [false, true] {
for finished in [false, true] {
let mut f = architecture_fixture(Layer::Oa);
let delayed = begin(&f.app).unwrap();
let token = after_open.then(|| open(&mut f.app));
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let lease = f.app.saved_removal_input.acquire().unwrap();
let mut lease = Some(lease);
if finished {
drop(lease.take());
}
let _ = f
.app
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
assert!(f.app.delete_target.is_none());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
drop(lease);
}
}
}
#[test]
fn saved_context_menu_keeps_the_whole_group_even_when_unselected_object_was_clicked() {
for unselected in [false, true] {
let mut f = architecture_fixture(Layer::Sa);
let selected = f.app.multiselect_snapshot().unwrap();
let (object, placement) = if unselected {
(f.survivor, f.survivor_node)
} else {
(f.child, f.child_node)
};
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
request.canvas = f.app.route_context();
request.placement = Some(placement);
request.selection_target = Some(Box::new(Target::Placement(placement)));
let _ = f
.app
.update(Message::Menu(model_menu::Event::Open(request)));
let entries = model_menu::popup(&f.app).unwrap().entries;
let invoke = entries
.into_iter()
.find_map(|entry| match entry {
crate::context_menu::Entry::Action { label, message, .. }
if label == "Delete selected from model…" =>
{
message
}
_ => None,
})
.expect("explicit semantic group menu action");
let _ = f.app.update(invoke);
let review = f
.app
.delete_target
.as_ref()
.unwrap()
.saved
.as_ref()
.unwrap();
assert_eq!(review.snapshot.targets(), selected.targets());
assert!(!review.plan.selected.contains(&f.survivor));
}
}
#[test]
fn saved_hide_and_plain_delete_never_promote_mixed_targets_to_model_removal() {
for message_kind in ["delete", "hide"] {
let mut f = architecture_fixture(Layer::Sa);
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let _ = f.app.update(if message_kind == "delete" {
Message::Delete
} else {
Message::HideSelected
});
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert!(f.app.delete_target.is_none());
let token = open(&mut f.app);
let _ = f.app.update(Message::ConfirmDelete(token));
assert_eq!(f.app.project(), &f.expected);
}
let mut f = architecture_fixture(Layer::Oa);
choose(
&mut f.app,
&[
Target::Placement(f.parent_node),
Target::Placement(f.survivor_node),
],
);
let mut expected = f.app.project().clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.remove(&f.parent_node);
view.nodes.remove(&f.survivor_node);
let _ = f.app.update(Message::HideSelected);
assert_eq!(f.app.project(), &expected);
assert!(f.app.project().contains(f.parent) && f.app.project().contains(f.survivor));
source_matches(&f.app);
}
#[test]
fn saved_state_owner_deletion_removes_owned_views_and_chooses_surviving_inspection_context() {
let mut f = state_fixture(Layer::Sa);
let mut project = f.app.project().clone();
let owner_node = project.place(f.diagram, f.machine, point(-400., -200.));
f.app.document = Document::from_project(project.clone(), LIMITS).unwrap();
f.app.source_workspace = Workspace::new(f.app.document.bundle(), true);
f.app.sync();
f.app.reconcile_multiselect();
choose(
&mut f.app,
&[Target::Placement(owner_node), Target::Placement(f.na)],
);
let before = f.app.document.snapshot();
let mut expected = project;
for id in [f.machine, f.region, f.a, f.b] {
expected.elements.remove(&id);
}
for id in [f.action, f.row, f.edge] {
expected.relations.remove(&id);
}
expected.diagrams.remove(&f.diagram);
expected.diagrams.remove(&f.other);
validate_candidate(&expected).unwrap();
let token = open(&mut f.app);
let _ = f.app.update(Message::ConfirmDelete(token));
assert_eq!(f.app.project(), &expected);
assert!(f.app.project().contains(f.app.selected));
assert!(
f.app
.behavior
.active_owner
.is_none_or(|id| f.app.project().contains(id))
);
assert!(
f.app
.behavior
.diagram
.diagram
.is_none_or(|id| f.app.project().diagrams.contains_key(&id))
);
assert!(
f.app
.multiselect_snapshot()
.is_none_or(|s| s.targets().is_empty())
);
source_matches(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
@@ -0,0 +1,145 @@
//! Review the complete captured removal impact before one explicit confirmation.
use super::{Event, Review};
use crate::app::{App, Message, diagram_removal};
use crate::style;
use iced::widget::{button, column, container, row, scrollable, text, text_input};
use iced::{Element, Length::Fill};
use oa_core::Project;
use std::collections::BTreeSet;
const PAGE_SIZE: usize = 6;
pub(super) fn matching_rows(
project: &Project,
review: &Review,
) -> Vec<super::super::removal_review::ImpactRow> {
super::super::removal_review::matching_rows(
project,
&review.plan.removed,
&review.plan.changed,
&review.plan.selected,
&review.plan.interpretation,
&review.plan.affected_uses,
&review.query,
)
}
pub(crate) fn view<'a>(
app: &'a App,
target: &'a diagram_removal::Confirmation,
) -> Element<'a, Message> {
let Some(review) = target.saved.as_ref() else {
return text("Model deletion review is unavailable.").into();
};
let token = target.snapshot.token;
let valid = app.saved_removal_review_valid(target);
let rows = matching_rows(app.project(), review);
let callers: BTreeSet<_> = review
.plan
.affected_uses
.values()
.flatten()
.chain(
review
.plan
.interpretation
.iter()
.flat_map(|impact| impact.callers.iter()),
)
.copied()
.collect();
let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() {
"No detected Process order/analysis changes or affected Process caller definitions."
.to_owned()
} else {
format!(
"Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.",
review.plan.interpretation.len(),
callers.len()
)
};
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
let page = review.page.min(pages - 1);
let mut impacts = column![].spacing(12).width(Fill);
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
impacts = impacts.push(
column![
text(impact.disposition.clone())
.size(12)
.color(style::MUTED),
text(impact.label.clone())
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
]
.spacing(3)
.width(Fill),
);
}
if rows.is_empty() {
impacts = impacts.push(text("No matching effects. Try a name, alias or effect.").size(13));
}
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
Message::SavedRemoval(Box::new(Event::Page {
token,
page: page - 1,
}))
}));
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
Message::SavedRemoval(Box::new(Event::Page {
token,
page: page + 1,
}))
}));
let mut footer = column![].spacing(8).width(Fill);
if !valid {
footer = footer.push(
text("This review expired. Cancel and review the current selection again.")
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
}
footer = footer.push(
row![
container(
button("Cancel")
.padding(10)
.on_press(Message::SavedRemoval(Box::new(Event::Cancel { token })))
)
.id("delete-cancel"),
container(
button("Delete selected from model")
.style(button::danger)
.padding(10)
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
)
.id("delete-confirm"),
]
.spacing(10),
);
column![
text(format!("Delete {} model subjects?", review.plan.selected.len())).size(21),
text(format!("{} selected diagram objects · {} unique model subjects", review.snapshot.targets().len(), review.plan.selected.len())).size(13),
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
text_input("Search affected names, aliases or effects", &review.query)
.id("saved-removal-query")
.on_input(move |query| Message::SavedRemoval(Box::new(Event::Query { token, query })))
.size(13),
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
.spacing(10).align_y(iced::Alignment::Center),
scrollable(column![
text("Deletes the selected model subjects and their listed dependent records across all views. This is different from hiding placements. Removing a State row preserves its referenced work; deleting a trace preserves its endpoints unless separately selected or owned by another deleted subject. Reused Process definitions may change; review their listed effects and callers. One Undo restores the entire model and source change.")
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
impacts,
].spacing(14).width(Fill))
.width(Fill).height(Fill).spacing(8).id("saved-removal-impact"),
footer,
]
.spacing(12)
.width(Fill)
// Keep confirmation and search outside the flexible, scrollable impact body.
.height((app.window_size.height - 160.).clamp(260., 570.))
.into()
}
@@ -0,0 +1,135 @@
//! Exact rendered Scenario selection, independent of semantic movement permission.
use super::*;
use crate::behavior::scenario::{self, selection};
use crate::diagram_selection::Selection;
use crate::diagram_usage::Target;
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn scenario_selection_envelope(&self, event: &selection::Event) -> bool {
self.scenario_context() == Some(event.context)
&& self.diagram_selection.as_ref().is_some_and(|selected| {
selected.matches(&event.snapshot, selection::context(event.context))
})
}
pub(super) fn reconcile_scenario_multiselect(&mut self) {
let Some(context) = self.scenario_context() else {
return;
};
let scope = selection::context(context).scope;
let visible = selection::visible(self.project(), context, self.selected);
let old = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == scope)
.and_then(Selection::primary)
.cloned();
let legacy = Target::ScenarioObject(self.selected);
let held = old.as_ref() == Some(&legacy);
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
let selected = self
.diagram_selection
.get_or_insert_with(|| Selection::new(scope));
selected.reconcile(scope, |target| visible.contains(target));
if held || lost {
if selected.primary() != old.as_ref() {
self.inspect_scenario_multiselect_primary();
}
} else if selected.primary() != Some(&legacy) {
if visible.contains(&legacy) {
selected.replace(legacy, |target| visible.contains(target));
} else {
selected.clear();
}
}
}
pub(super) fn inspect_scenario_multiselect_primary(&mut self) {
let Some(context) = self.scenario_context() else {
return;
};
self.selected = match self.diagram_selection.as_ref().and_then(Selection::primary) {
Some(Target::ScenarioObject(id)) => *id,
_ => context.scenario,
};
self.selected_node = None;
self.selected_bend = None;
self.selected_attachment = None;
self.behavior.diagram.placement = None;
self.diagram_navigation_focus = None;
self.refresh_draft();
}
/// This is dispatched before shared navigation/gesture retirement. Invalid
/// captures and unchanged selection must not disturb a newer input owner.
pub(super) fn scenario_selection_event(&mut self, event: selection::Event) -> Task<Message> {
use multiselect::Choice;
use selection::Intent;
if !self.scenario_selection_envelope(&event) {
return Task::none();
}
let visible = selection::visible(self.project(), event.context, self.selected);
if !event.snapshot.targets().iter().all(|t| visible.contains(t)) {
return Task::none();
}
if !diagram_removal::enabled(self)
|| self.source_rename.open()
|| self.behavior.scenario.canvas_tool(event.context.scenario)
!= scenario::CanvasTool::Select
{
self.status =
"Finish or cancel the active edit before changing the Scenario selection.".into();
return Task::none();
}
let selected = self.diagram_selection.as_mut().expect("admitted selection");
let valid = |target: &Target| visible.contains(target);
let changed = match &event.intent {
Intent::Choose(target, choice) => {
if !valid(target) {
return Task::none();
}
match choice {
Choice::Replace => selected.replace(target.clone(), valid),
Choice::Add => selected.add(target.clone(), valid),
Choice::Remove => selected.remove(target),
Choice::Toggle => selected.toggle(target.clone(), valid),
Choice::Inspect => selected.set_primary(target),
}
}
Intent::Collapse(target) => {
if !selected.contains(target) || !valid(target) {
return Task::none();
}
selected.replace(target.clone(), valid)
}
Intent::Clear => selected.clear(),
Intent::Refuse(reason) => {
self.status = reason.clone();
return Task::none();
}
};
if changed {
self.scenario_reorder_cancel();
self.source_interaction();
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
scenario::CanvasEvent::Selection(Box::new(event)),
));
self.behavior.scenario.cancel_spacing();
// Every generation change, including a secondary membership ABA,
// retires old singleton spacing packets as well as group captures.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.inspect_scenario_multiselect_primary();
}
Task::none()
}
pub(super) fn scenario_group_selected(&self) -> bool {
self.scenario_context().is_some()
&& self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
}
}
@@ -0,0 +1,379 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, selection::Intent, spacing};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Surface;
use iced::{Size, Vector};
fn setup(saved: bool) -> (App, Id, Id, Id, Id) {
let (document, owner, diagram, lane, message) = spacing::controller::tests::fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
if saved {
let _ = app.update(Message::Diagram(super::super::Choice {
id: diagram,
label: "Saved".into(),
}));
} else {
let _ = app.update(Message::Page(Page::Behavior));
}
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(900., 600.),
)));
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
(app, owner, diagram, lane, message)
}
fn capture(app: &App, intent: Intent) -> selection::Event {
selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent,
}
}
fn send(app: &mut App, event: selection::Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
send(
app,
capture(app, Intent::Choose(Target::ScenarioObject(id), choice)),
);
}
fn inert(app: &mut App, messages: Vec<Message>, preserve_status: bool) {
let model = app.project().clone();
let bundle = app.document.bundle().clone();
let source = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (app.document.can_undo(), app.document.can_redo());
let selected = app.multiselect_snapshot();
let primary = app.selected;
let context = app.scenario_context();
let camera = app.camera_boundary();
let status = app.status.clone();
let spacing = (
app.behavior.scenario.spacing_stamp(),
app.behavior.scenario.spacing_token(),
);
for message in messages {
let _ = app.update(message);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(app.multiselect_snapshot(), selected);
assert_eq!(app.selected, primary);
assert_eq!(app.scenario_context(), context);
assert!(app.camera_boundary() == camera);
assert_eq!(
(
app.behavior.scenario.spacing_stamp(),
app.behavior.scenario.spacing_token()
),
spacing
);
if preserve_status {
assert_eq!(app.status, status);
}
}
}
fn packet(event: selection::Event) -> Message {
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event)))
}
#[test]
fn exact_scenario_membership_primary_and_noops_preserve_model_source_and_history() {
for saved in [false, true] {
let (mut app, owner, diagram, lane, message) = setup(saved);
let model = app.project().clone();
let bundle = app.document.bundle().clone();
let revision = app.document.revision();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
choose(&mut app, message, multiselect::Choice::Inspect);
let snapshot = app.multiselect_snapshot().unwrap();
assert_eq!(
snapshot.targets(),
&[
Target::ScenarioObject(message),
Target::ScenarioObject(lane)
]
);
assert_eq!(snapshot.primary(), Some(&Target::ScenarioObject(message)));
assert_eq!(
snapshot.context().scope.surface,
if saved {
Surface::Saved(diagram)
} else {
Surface::Scenario(owner)
}
);
assert_eq!(app.selected, message);
let repeated = capture(
&app,
Intent::Choose(Target::ScenarioObject(message), multiselect::Choice::Add),
);
inert(&mut app, vec![packet(repeated)], true);
let stale_collapse = capture(&app, Intent::Collapse(Target::ScenarioObject(lane)));
choose(&mut app, lane, multiselect::Choice::Remove);
choose(&mut app, lane, multiselect::Choice::Add);
choose(&mut app, message, multiselect::Choice::Inspect);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
snapshot.primary()
);
inert(&mut app, vec![packet(stale_collapse)], true);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn same_owner_representation_round_trip_clears_group_and_rejects_old_packets() {
let (mut app, owner, diagram, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let old = capture(&app, Intent::Clear);
let snapshot = app.multiselect_snapshot().unwrap();
let mut second = app.project().diagrams[&diagram].clone();
let other = Id::new_v4();
second.id = other;
second.alias = "other_saved".into();
second.name = "Other saved".into();
// Navigation itself is tested against a model with both saved identities.
let mut project = app.project().clone();
project.diagrams.insert(other, second);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(super::super::Choice {
id: diagram,
label: "A".into(),
}));
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let first = capture(&app, Intent::Clear);
let first_visit = first.context.visit;
for representation in [
Representation::Saved { diagram: other },
Representation::Derived { owner },
Representation::Saved { diagram },
] {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
context,
representation,
)));
assert!(app.multiselect_snapshot().unwrap().targets().len() <= 1);
}
assert_ne!(app.scenario_context().unwrap().visit, first_visit);
choose(&mut app, message, multiselect::Choice::Replace);
choose(&mut app, lane, multiselect::Choice::Add);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
inert(&mut app, vec![packet(first), packet(old)], true);
}
#[test]
fn unsupported_targets_and_bare_generic_packets_are_inert_before_side_effects() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let current = app.multiselect_context().unwrap();
let events = [
Target::Whole,
Target::Placement(message),
Target::ScenarioObject(Id::new_v4()),
]
.into_iter()
.map(|target| {
packet(capture(
&app,
Intent::Choose(target, multiselect::Choice::Replace),
))
})
.chain([
Message::DiagramSelection(multiselect::Event::Clear(current)),
Message::DiagramSelection(multiselect::Event::Choose(
current,
Target::ScenarioObject(message),
multiselect::Choice::Replace,
)),
])
.collect();
inert(&mut app, events, true);
}
#[test]
fn group_rejects_legacy_singleton_remove_reorder_and_index_arrows() {
let (mut app, owner, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let context = app.scenario_context().unwrap();
inert(
&mut app,
vec![
Message::ScenarioCanvas(CanvasEvent::Select(context, message)),
Message::ScenarioCanvas(CanvasEvent::Remove(context, lane)),
Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: message,
slot: scenario::Slot {
parent: owner,
before: None,
},
}),
Message::Command(Command::MoveStep(owner, 0, 1)),
Message::Delete,
],
false,
);
assert!(!app.delete_pending);
}
#[test]
fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let old = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
choose(&mut app, lane, multiselect::Choice::Add);
let group_begin = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(group_begin)));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert_eq!(
app.canvas_epoch, epoch,
"pending click retains its admitted lifetime"
);
assert_eq!(
app.behavior.scenario.spacing_token(),
Some(group_begin.token())
);
choose(&mut app, lane, multiselect::Choice::Remove);
assert!(app.behavior.scenario.spacing_token().is_none());
inert(
&mut app,
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
true,
);
let current = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let spacing::Event::Begin { press_screen, .. } = current else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
inert(
&mut app,
vec![
Message::ScenarioCanvas(CanvasEvent::Spacing(old)),
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Cancel {
stamp: old.stamp(),
token: old.token(),
})),
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish {
stamp: old.stamp(),
token: old.token(),
screen: press_screen + Vector::new(0., 25.),
})),
],
true,
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Finish {
stamp: current.stamp(),
token: current.token(),
screen: press_screen + Vector::new(0., 25.),
},
)));
assert!(app.document.can_undo());
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(message)]
);
}
#[test]
fn legacy_singleton_selection_aba_retires_old_spacing_begin() {
for canvas in [false, true] {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let old = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
for id in [lane, message] {
let event = if canvas {
Message::ScenarioCanvas(CanvasEvent::Select(app.scenario_context().unwrap(), id))
} else {
Message::Select(id)
};
let _ = app.update(event);
assert_eq!(app.selected, id);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(id)]
);
}
assert_ne!(app.scenario_context().unwrap(), old.stamp().context);
inert(
&mut app,
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
true,
);
let current = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
}
}
#[test]
fn pending_primary_edit_is_not_applied_by_selection_input() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let _ = app.update(Message::Field(
"/name".into(),
"Pending primary name".into(),
));
assert!(app.draft_dirty());
let event = capture(
&app,
Intent::Choose(Target::ScenarioObject(lane), multiselect::Choice::Add),
);
inert(&mut app, vec![packet(event)], false);
assert!(app.draft_dirty());
assert_ne!(app.project().named(message), "Pending primary name");
}
@@ -0,0 +1,213 @@
//! One captured Scenario selection, one impact review, one document transaction.
use super::*;
use crate::behavior::scenario::{self, group_removal, selection};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
use crate::model_menu;
#[cfg(test)]
mod tests;
pub(crate) mod view;
#[derive(Debug, Clone)]
pub(crate) enum Event {
Begin {
context: scenario::Context,
snapshot: Box<Snapshot>,
},
Query {
token: u64,
query: String,
},
Page {
token: u64,
page: usize,
},
Cancel {
token: u64,
},
}
pub(crate) struct Review {
pub(crate) context: scenario::Context,
pub(crate) snapshot: Snapshot,
pub(crate) plan: group_removal::Plan,
pub(crate) query: String,
pub(crate) page: usize,
}
pub(crate) fn begin(app: &App) -> Option<Message> {
let context = app.scenario_context()?;
let snapshot = app.multiselect_snapshot()?;
(snapshot.targets().len() > 1).then_some(Message::ScenarioRemoval(Box::new(Event::Begin {
context,
snapshot: Box::new(snapshot),
})))
}
impl App {
pub(crate) fn scenario_removal_enabled(&self) -> bool {
self.scenario_reorder_enabled() && self.scenario_reorder.session.is_none()
}
fn scenario_removal_matches(&self, context: scenario::Context, snapshot: &Snapshot) -> bool {
if self.scenario_context() != Some(context)
|| !self
.diagram_selection
.as_ref()
.is_some_and(|selected| selected.matches(snapshot, selection::context(context)))
{
return false;
}
let visible = selection::visible(self.project(), context, self.selected);
snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
}
pub(super) fn scenario_removal_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Begin { context, snapshot } => {
// Admission precedes every shared input/selection boundary. A
// stale packet must not prune the set or retire a newer editor.
if !self.scenario_removal_matches(context, &snapshot)
|| !self.scenario_removal_enabled()
|| snapshot.targets().len() < 2
{
return Task::none();
}
let plan = match group_removal::prepare(
self.project(),
context.scenario,
snapshot.targets(),
) {
Ok(plan) => plan,
Err(reason) => {
self.status = reason;
return Task::none();
}
};
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
return Task::none();
};
let primary = *primary;
self.scenario_reorder_cancel();
self.source_interaction();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.scenario_context().expect("admitted Scenario");
let snapshot = self.multiselect_snapshot().expect("admitted selection");
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
request.scenario = Some(Box::new(context));
request.selection_target = Some(Box::new(Target::ScenarioObject(primary)));
self.menu_sequence = self.menu_sequence.wrapping_add(1);
self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence)
.map(|opened| diagram_removal::Confirmation {
snapshot: opened,
selection: self.selected,
placement: None,
behavior_owner: Some(context.scenario),
process: None,
saved: None,
scenario: Some(Review {
context,
snapshot,
plan,
query: String::new(),
page: 0,
}),
});
self.delete_pending = self.delete_target.is_some();
}
Event::Query { token, query } => {
if let Some(target) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
&& let Some(review) = target.scenario.as_mut()
{
review.query = query;
review.page = 0;
}
}
Event::Page { token, page } => {
if let Some(target) = self
.delete_target
.as_mut()
.filter(|target| target.snapshot.token == token)
&& let Some(review) = target.scenario.as_mut()
{
review.page = page;
}
}
Event::Cancel { token } => {
if self.delete_target.as_ref().is_some_and(|target| {
target.snapshot.token == token && target.scenario.is_some()
}) {
self.delete_target = None;
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Removal cancelled. Model and selection unchanged.".into();
}
}
}
Task::none()
}
pub(crate) fn scenario_removal_review_valid(
&self,
target: &diagram_removal::Confirmation,
) -> bool {
let Some(review) = &target.scenario else {
return false;
};
self.delete_pending
&& !self.busy
&& target.snapshot.valid(self)
&& self.scenario_removal_matches(review.context, &review.snapshot)
&& self.selected == target.selection
&& !self.source_dirty()
&& !self.draft_dirty()
&& !self.behavior.has_pending()
&& !self.source_rename.open()
&& self.scenario_reorder.session.is_none()
&& self.behavior.scenario.spacing_token().is_none()
&& self.behavior.scenario.canvas_tool(review.context.scenario)
== scenario::CanvasTool::Select
}
pub(super) fn confirm_scenario_removal(&mut self, token: u64) {
let Some(target) = self
.delete_target
.as_ref()
.filter(|target| target.snapshot.token == token && target.scenario.is_some())
else {
return;
};
let valid = self.scenario_removal_review_valid(target);
let target = self.delete_target.take().expect("captured review");
self.delete_pending = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if !valid {
self.status =
"Removal review expired. Nothing was removed; review the current selection again."
.into();
return;
}
let review = target.scenario.expect("Scenario review");
match self.document.execute(review.plan.command) {
Ok(_) => {
self.selected = review.context.scenario;
self.selected_node = None;
self.behavior.active_owner = Some(review.context.scenario);
self.sync();
self.reconcile_multiselect();
self.status = format!(
"Removed {} selected Scenario objects in one change. Referenced definitions preserved. Undo restores the model.",
review.snapshot.targets().len()
);
}
Err(error) => self.status = format!("Nothing removed: {error}"),
}
}
}
@@ -0,0 +1,758 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, direct_tests};
use crate::behavior::scenario_layout::Representation;
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
struct Fixture {
app: App,
owner: Id,
call: Id,
reply: Id,
tail: Id,
exchange: Id,
}
fn setup(saved: bool) -> Fixture {
let f = direct_tests::fixture();
let mut project = f.project;
let mut tail = project
.make_element(
Kind::Occurrence,
Layer::Oa,
"Unselected tail",
Some(f.owner),
)
.unwrap();
if let Some(SharedBody::Occurrence { body, .. }) = tail.shared_mut() {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
let tail_id = tail.id;
project.elements.insert(tail_id, tail);
project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(tail_id);
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "removal_view".into(),
name: "Removal view".into(),
description: "Preserved authored view".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
},
);
let other = project
.make_element(Kind::Scenario, Layer::Oa, "Unrelated Scenario", None)
.unwrap();
let other_owner = other.id;
project.elements.insert(other_owner, other);
let mut other_view = project.diagrams[&diagram].clone();
other_view.id = Id::new_v4();
other_view.alias = "unrelated_removal_view".into();
other_view.name = "Unrelated saved view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
project.diagrams.insert(other_view.id, other_view);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(if saved {
Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
})
} else {
Message::Page(Page::Behavior)
});
// Deliberately reverse semantic order; the primary is call, not the last step.
for (id, choice) in [
(f.reply, multiselect::Choice::Replace),
(f.call, multiselect::Choice::Add),
] {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
app.window_size = iced::Size::new(1600., 1000.);
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(900., 600.),
)));
assert!(app.scenario_removal_enabled());
Fixture {
app,
owner: f.owner,
call: f.call,
reply: f.reply,
tail: tail_id,
exchange: f.exchange,
}
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioRemoval(Box::new(event)));
}
fn captured(app: &App) -> Event {
Event::Begin {
context: app.scenario_context().unwrap(),
snapshot: Box::new(app.multiselect_snapshot().unwrap()),
}
}
fn open(app: &mut App) -> u64 {
let event = captured(app);
send(app, event);
let target = app
.delete_target
.as_ref()
.expect("whole-selection review opened");
assert!(app.delete_pending && target.scenario.is_some());
assert!(
target.snapshot.valid(app),
"review captures refreshed modal epoch"
);
target.snapshot.token
}
#[test]
fn scenario_group_removal_is_one_exact_model_source_transaction_in_both_representations() {
for saved in [false, true] {
let Fixture {
mut app,
owner,
call,
reply,
tail,
exchange,
} = setup(saved);
let baseline = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let token = open(&mut app);
let review = app
.delete_target
.as_ref()
.unwrap()
.scenario
.as_ref()
.unwrap();
assert_eq!(review.snapshot.targets(), selected.targets());
assert_eq!(review.snapshot.primary(), selected.primary());
assert_eq!(review.plan.removed, [call, reply].into_iter().collect());
assert!(review.plan.changed.contains(&owner));
assert!(!review.plan.removed.contains(&exchange));
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::ConfirmDelete(token));
let mut expected = baseline.decoded.project.clone();
expected.elements.remove(&call);
expected.elements.remove(&reply);
*expected
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![tail];
assert_eq!(app.project(), &expected);
assert!(app.project().contains(exchange));
assert!(!app.delete_pending && app.delete_target.is_none());
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert!(app.behavior.scenario.camera_boundary() == camera);
let removed = app.document.bundle().clone();
assert_ne!(removed, baseline.bundle);
let reopened = Document::from_bundle(removed.clone(), Default::default()).unwrap();
assert_eq!(reopened.project(), &expected);
let _ = app.update(Message::ConfirmDelete(token));
assert_eq!(app.document.bundle(), &removed);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(
!app.document.can_undo(),
"one Undo restores the entire group"
);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &removed);
assert_eq!(app.project(), &expected);
}
}
#[test]
fn scenario_group_removal_source_write_failure_preserves_snapshot_selection_camera_and_redo() {
use oa_core::source_project::{self, ProjectLimits};
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
.unwrap();
let lanes = ["a", "b"].map(|alias| {
let id = project
.add_element(Kind::Lifeline, Layer::Oa, alias, Some(owner))
.unwrap();
project.elements.get_mut(&id).unwrap().alias = alias.into();
id
});
let mut messages = Vec::new();
for index in 0..256 {
let mut message = project
.make_element(
Kind::Occurrence,
Layer::Oa,
&format!("Message {index}"),
Some(owner),
)
.unwrap();
let SharedBody::Occurrence { body, .. } = message.shared_mut().unwrap() else {
panic!("message occurrence");
};
*body = OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: oa_core::MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
};
messages.push(message.id);
project.elements.insert(message.id, message);
}
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.clone();
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "removal_view".into(),
name: "Removal view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
},
);
oa_core::validate_candidate(&project).unwrap();
let bundle = source_project::format_bundle(&project);
let bytes = |bundle: &source_project::SourceBundle| {
bundle.files.values().map(String::len).sum::<usize>()
};
let targets = lanes.map(Target::ScenarioObject);
let plan = group_removal::prepare(&project, owner, &targets).unwrap();
assert_eq!(plan.removed, lanes.into_iter().collect());
let mut expected = project.clone();
for lane in lanes {
expected.elements.remove(&lane);
}
for id in &messages {
let SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
sender, receiver, ..
},
..
} = expected.elements.get_mut(id).unwrap().shared_mut().unwrap()
else {
panic!("surviving message");
};
*sender = None;
*receiver = None;
}
oa_core::validate_candidate(&expected).unwrap();
let mut unrestricted = Document::from_bundle(bundle.clone(), Default::default()).unwrap();
unrestricted.execute(plan.command.clone()).unwrap();
assert_eq!(unrestricted.project(), &expected);
// Removal can grow losslessly edited source: each authored @a/@b becomes
// explicit none in a surviving message. Prove net growth after deletion,
// rather than assuming a budget failure or changing private Document limits.
assert!(bytes(unrestricted.bundle()) > bytes(&bundle));
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bytes(&bundle);
let mut bounded = Document::from_bundle(bundle.clone(), limits).unwrap();
let error = bounded.execute(plan.command).unwrap_err();
assert!(
error
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.diagnostic.code == "source_limit")
);
let refusal = format!("Nothing removed: {error}");
for saved in [false, true] {
for with_redo in [false, true] {
let mut app = App::with_project(project.clone());
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
let redo = if with_redo {
// A genuine earlier rename fits the same budget. Undo leaves
// its exact model/source snapshot available before this review.
app.document
.execute(Command::Rename(owner, "Short".into()))
.unwrap();
let future = app.document.snapshot();
assert!(app.document.undo());
Some(future)
} else {
None
};
app.sync();
let _ = app.update(Message::Select(owner));
let _ = app.update(if saved {
Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
})
} else {
Message::Page(Page::Behavior)
});
for (id, choice) in [
(lanes[1], multiselect::Choice::Replace),
(lanes[0], multiselect::Choice::Add),
] {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
app.window_size = iced::Size::new(1600., 1000.);
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(900., 600.),
)));
assert!(app.scenario_removal_enabled());
let baseline = app.document.snapshot();
let revision = app.document.revision();
let selected = app.multiselect_snapshot().unwrap();
assert_eq!(
selected.targets(),
&[targets[1].clone(), targets[0].clone()]
);
let context = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let navigation = (
app.page,
app.active_diagram,
app.selected,
app.selected_node,
app.behavior.active_owner,
);
let editor = app.source_editor.text();
assert!(!app.document.dirty() && !app.document.can_undo());
assert_eq!(app.document.can_redo(), with_redo);
let token = open(&mut app);
let review = app.delete_target.as_ref().unwrap();
assert!(app.scenario_removal_review_valid(review));
assert_eq!(review.scenario.as_ref().unwrap().plan.removed, plan.removed);
let epoch = app.canvas_epoch;
let _ = app.update(Message::ConfirmDelete(token));
assert_eq!(app.status, refusal);
assert!(!app.delete_pending && app.delete_target.is_none());
assert_ne!(app.canvas_epoch, epoch, "failed confirmation retires input");
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.dirty() && !app.document.can_undo());
assert_eq!(app.document.can_redo(), with_redo);
let retained = app.multiselect_snapshot().unwrap();
assert_eq!(retained.targets(), selected.targets());
assert_eq!(retained.primary(), selected.primary());
assert_eq!(retained.generation(), selected.generation());
let after = app.scenario_context().unwrap();
assert_eq!(after.document, context.document);
assert_eq!(after.revision, context.revision);
assert_eq!(after.representation, context.representation);
assert_eq!(after.visit, context.visit);
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(
(
app.page,
app.active_diagram,
app.selected,
app.selected_node,
app.behavior.active_owner,
),
navigation
);
assert_eq!(app.source_editor.text(), editor);
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
assert!(!app.source_dirty() && !app.draft_dirty());
let epoch = app.canvas_epoch;
let _ = app.update(Message::ConfirmDelete(token));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.multiselect_snapshot().unwrap(), retained);
assert_eq!(app.status, refusal);
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
if let Some(future) = redo {
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &future.decoded.project);
assert_eq!(app.document.bundle(), &future.bundle);
assert!(!app.document.can_redo());
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo() && !app.document.dirty());
}
}
}
}
#[test]
fn scenario_group_removal_old_modal_packets_do_not_touch_the_new_review() {
let mut f = setup(true);
let old_begin = captured(&f.app);
let first = open(&mut f.app);
send(&mut f.app, Event::Cancel { token: first });
let second = open(&mut f.app);
assert_ne!(first, second);
let epoch = f.app.canvas_epoch;
let selected = f.app.multiselect_snapshot().unwrap();
let source = f.app.document.bundle().clone();
for message in [
Message::ScenarioRemoval(Box::new(old_begin)),
Message::ScenarioRemoval(Box::new(Event::Cancel { token: first })),
Message::ScenarioRemoval(Box::new(Event::Query {
token: first,
query: "stale".into(),
})),
Message::ScenarioRemoval(Box::new(Event::Page {
token: first,
page: 99,
})),
Message::ConfirmDelete(first),
Message::CancelDelete,
] {
let _ = f.app.update(message);
let target = f.app.delete_target.as_ref().unwrap();
assert_eq!(target.snapshot.token, second);
assert!(target.scenario.as_ref().unwrap().query.is_empty());
assert_eq!(target.scenario.as_ref().unwrap().page, 0);
assert_eq!(f.app.canvas_epoch, epoch);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
assert_eq!(f.app.document.bundle(), &source);
}
send(
&mut f.app,
Event::Query {
token: second,
query: "reply".into(),
},
);
send(
&mut f.app,
Event::Page {
token: second,
page: 1,
},
);
assert_eq!(f.app.canvas_epoch, epoch);
assert_eq!(f.app.document.bundle(), &source);
send(&mut f.app, Event::Cancel { token: second });
assert!(!f.app.delete_pending);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert!(!f.app.document.can_undo());
}
#[test]
fn scenario_group_removal_confirm_expires_for_each_context_or_selection_change() {
for change in 0..5 {
let mut f = setup(true);
let token = open(&mut f.app);
match change {
0 => f.app.canvas_document = Id::new_v4(),
1 => {
let context = f.app.scenario_context().unwrap();
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(900., 560.),
)));
assert_ne!(f.app.canvas_epoch, context.epoch);
}
2 => {
f.app
.document
.execute(Command::Rename(f.owner, "New revision".into()))
.unwrap();
}
3 => {
let context = f.app.scenario_context().unwrap();
f.app.behavior.scenario.enter_representation(
context.document,
f.owner,
Representation::Derived { owner: f.owner },
);
f.app.behavior.scenario.enter_representation(
context.document,
f.owner,
context.representation,
);
assert_ne!(f.app.scenario_context().unwrap().visit, context.visit);
}
_ => {
let selected = f.app.diagram_selection.as_mut().unwrap();
assert!(selected.set_primary(&Target::ScenarioObject(f.reply)));
assert!(selected.set_primary(&Target::ScenarioObject(f.call)));
}
}
let current = f.app.document.snapshot();
let _ = f.app.update(Message::ConfirmDelete(token));
assert_eq!(f.app.project(), &current.decoded.project);
assert_eq!(f.app.document.bundle(), &current.bundle);
assert!(!f.app.delete_pending && f.app.delete_target.is_none());
assert!(f.app.status.contains("expired"));
}
}
#[test]
fn scenario_group_removal_cancel_preserves_existing_redo_and_full_selection() {
let mut f = setup(true);
let _ = f.app.update(Message::Command(Command::Rename(
f.tail,
"Redo remains available".into(),
)));
let renamed = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert!(f.app.document.can_redo());
// Rename inspects the renamed tail; Undo preserves that inspection. Restore
// the real group before testing cancellation of its removal review.
for (id, choice) in [
(f.reply, multiselect::Choice::Replace),
(f.call, multiselect::Choice::Add),
] {
let event = selection::Event {
context: f.app.scenario_context().unwrap(),
snapshot: f.app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
let _ = f
.app
.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
let selected = f.app.multiselect_snapshot().unwrap();
assert_eq!(selected.targets().len(), 2);
assert!(f.app.document.can_redo());
let source = f.app.document.bundle().clone();
let token = open(&mut f.app);
send(&mut f.app, Event::Cancel { token });
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.document.bundle(), &renamed);
}
#[test]
fn scenario_group_removal_does_not_retire_active_reorder_or_spacing() {
for spacing in [false, true] {
let mut f = setup(true);
if spacing {
let event = crate::app::scenario_spacing::tests::packet_for_app(
&f.app,
scenario::spacing::Target::Message(f.call),
);
let _ = f
.app
.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
assert!(f.app.behavior.scenario.spacing_token().is_some());
} else {
let event = crate::app::scenario_reorder::Event {
context: f.app.scenario_context().unwrap(),
snapshot: f.app.multiselect_snapshot().unwrap(),
token: Id::new_v4(),
intent: crate::app::scenario_reorder::Intent::Begin,
};
let _ = f.app.update(Message::ScenarioReorder(Box::new(event)));
assert!(f.app.scenario_reorder.session.is_some());
}
let epoch = f.app.canvas_epoch;
let spacing_token = f.app.behavior.scenario.spacing_token();
let reorder_token = f
.app
.scenario_reorder
.session
.as_ref()
.map(|session| session.token());
let source = f.app.document.bundle().clone();
let event = captured(&f.app);
send(&mut f.app, event);
assert!(f.app.delete_target.is_none());
assert_eq!(f.app.canvas_epoch, epoch);
assert_eq!(f.app.behavior.scenario.spacing_token(), spacing_token);
assert_eq!(
f.app
.scenario_reorder
.session
.as_ref()
.map(|session| session.token()),
reorder_token
);
assert_eq!(f.app.document.bundle(), &source);
}
}
#[test]
fn scenario_group_removal_menu_does_not_select_its_clicked_subject() {
for unselected in [false, true] {
let mut f = setup(true);
let selection = f.app.multiselect_snapshot().unwrap();
let clicked = if unselected { f.tail } else { f.reply };
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
request.scenario = f.app.scenario_context().map(Box::new);
request.selection_target = Some(Box::new(Target::ScenarioObject(clicked)));
let _ = f
.app
.update(Message::Menu(model_menu::Event::Open(request)));
let menu = f.app.model_menu.as_ref().expect("Scenario menu");
let token = menu.token;
let action = begin(&f.app).expect("captured group menu action");
let _ = f.app.update(Message::Menu(model_menu::Event::Invoke(
token,
Box::new(action),
)));
let review = f
.app
.delete_target
.as_ref()
.unwrap()
.scenario
.as_ref()
.unwrap();
assert_eq!(review.snapshot.targets(), selection.targets());
assert_eq!(review.snapshot.primary(), selection.primary());
assert_eq!(f.app.selected, f.call);
assert!(!review.plan.removed.contains(&f.tail));
assert!(f.app.project().contains(clicked));
}
}
#[test]
fn scenario_group_removal_rejects_dirty_properties_and_legacy_singleton_messages() {
let mut f = setup(false);
let baseline = f.app.document.bundle().clone();
let selected = f.app.multiselect_snapshot().unwrap();
let epoch = f.app.canvas_epoch;
let _ = f.app.update(Message::Delete);
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Remove(
f.app.scenario_context().unwrap(),
f.call,
)));
assert!(f.app.delete_target.is_none());
assert_eq!(f.app.canvas_epoch, epoch);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
let _ = f.app.update(Message::Field(
"/name".into(),
"Unapplied group primary".into(),
));
assert!(f.app.draft_dirty());
let event = captured(&f.app);
let epoch = f.app.canvas_epoch;
send(&mut f.app, event);
assert!(f.app.delete_target.is_none() && f.app.draft_dirty());
assert_eq!(f.app.canvas_epoch, epoch);
assert_eq!(f.app.document.bundle(), &baseline);
assert!(!f.app.document.can_undo());
}
#[tokio::test]
async fn mounted_scenario_group_delete_captures_whole_set_and_repeat_does_not_confirm() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
keyboard::{self, key::Named},
mouse,
};
for shift in [false, true] {
let mut f = setup(true);
let mut mounted = Mounted::new(&mut f.app).await;
mounted
.batch(&mut f.app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
let selected = f.app.multiselect_snapshot().unwrap();
let source = f.app.document.bundle().clone();
let mut remove = key(Named::Delete);
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove {
*modifiers = if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::default()
};
}
let output = mounted
.batch(&mut f.app, &[remove.clone()], mouse::Cursor::Unavailable)
.await;
assert!(output.iter().any(|message| matches!(message, Message::ScenarioRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets()==selected.targets() && snapshot.primary()==selected.primary()))), "actual canvas Delete emits full captured group: {output:?}");
assert!(!output.iter().any(|message| matches!(
message,
Message::Delete | Message::ScenarioCanvas(CanvasEvent::Remove(..))
)));
let token = f
.app
.delete_target
.as_ref()
.expect("mounted group review")
.snapshot
.token;
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
*repeat = true;
}
let output = mounted
.batch(&mut f.app, &[remove], mouse::Cursor::Unavailable)
.await;
assert!(
!output
.iter()
.any(|message| matches!(message, Message::ConfirmDelete(_)))
);
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, token);
assert_eq!(f.app.document.bundle(), &source);
send(&mut f.app, Event::Cancel { token });
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert!(!f.app.document.can_undo());
}
}
@@ -0,0 +1,155 @@
//! Review the complete captured removal impact before one explicit confirmation.
use super::{Event, Review};
use crate::app::{App, Message, diagram_removal};
use crate::diagram_usage::Target;
use crate::style;
use iced::widget::{button, column, container, row, scrollable, text, text_input};
use iced::{Element, Length::Fill};
use oa_core::{Id, Project};
use std::collections::BTreeMap;
const PAGE_SIZE: usize = 6;
struct ImpactRow {
label: String,
disposition: &'static str,
}
fn matching_rows(project: &Project, review: &Review) -> Vec<ImpactRow> {
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
let query = review.query.trim().to_lowercase();
review
.plan
.removed
.iter()
.map(|id| {
let selected = review
.snapshot
.targets()
.contains(&Target::ScenarioObject(*id));
(
*id,
if selected {
"Removed · selected"
} else {
"Additional removed"
},
)
})
.chain(
review
.plan
.changed
.iter()
.map(|id| (*id, "Updated · preserved")),
)
.filter_map(|(id, disposition)| {
let name = project.named(id);
let identity = aliases
.get(&id)
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
let label = if name == "Missing object" || name.is_empty() {
identity
} else {
format!("{name} ({identity})")
};
(query.is_empty()
|| label.to_lowercase().contains(&query)
|| id.to_string().contains(&query))
.then_some(ImpactRow { label, disposition })
})
.collect()
}
pub(crate) fn view<'a>(
app: &'a App,
target: &'a diagram_removal::Confirmation,
) -> Element<'a, Message> {
let Some(review) = target.scenario.as_ref() else {
return text("Scenario removal review is unavailable.").into();
};
let token = target.snapshot.token;
let valid = app.scenario_removal_review_valid(target);
let rows = matching_rows(app.project(), review);
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
let page = review.page.min(pages - 1);
let mut impacts = column![].spacing(12).width(Fill);
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
impacts = impacts.push(
column![
text(impact.disposition).size(12).color(style::MUTED),
text(impact.label.clone())
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
]
.spacing(3)
.width(Fill),
);
}
if rows.is_empty() {
impacts = impacts.push(text("No matching affected records. Try a name or alias.").size(13));
}
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
Message::ScenarioRemoval(Box::new(Event::Page {
token,
page: page - 1,
}))
}));
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
Message::ScenarioRemoval(Box::new(Event::Page {
token,
page: page + 1,
}))
}));
let mut footer = column![].spacing(8).width(Fill);
if !valid {
footer = footer.push(
text("This review expired. Cancel and review the current selection again.")
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
}
footer = footer.push(
row![
container(
button("Cancel")
.padding(10)
.on_press(Message::ScenarioRemoval(Box::new(Event::Cancel { token })))
)
.id("delete-cancel"),
container(
button("Remove from Scenario")
.style(button::danger)
.padding(10)
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
)
.id("delete-confirm"),
]
.spacing(10),
);
column![
text(format!("Remove {} Scenario objects?", review.snapshot.targets().len())).size(21),
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
text_input("Search affected names or aliases", &review.query)
.id("scenario-removal-query")
.on_input(move |query| Message::ScenarioRemoval(Box::new(Event::Query { token, query })))
.size(13),
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
.spacing(10).align_y(iced::Alignment::Center),
scrollable(column![
text("Removes local occurrences or participants and their owned descendants. Affected references are cleared; reusable definitions are preserved. Updated records include owning sequences and affected views. One Undo restores the entire change.")
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
impacts,
].spacing(14).width(Fill))
.width(Fill).height(Fill).spacing(8).id("scenario-removal-impact"),
footer,
]
.spacing(12)
.width(Fill)
// At the reviewed 732px window height, 570px content plus the modal's
// 52px padding leaves 110px outside the card. Only the impact body flexes.
.height((app.window_size.height - 160.).clamp(260., 570.))
.into()
}
@@ -0,0 +1,339 @@
//! Exact-input adapter for an explicit semantic reorder operation.
use super::*;
use crate::behavior::scenario_layout::presentation::PresentationStatus;
use crate::{
behavior::scenario::{
self,
reorder_session::{Outcome, Session},
selection,
},
diagram_selection::Snapshot,
};
#[derive(Default)]
pub(crate) struct State {
pub(crate) session: Option<Session>,
pub(crate) query: String,
pub(crate) page: usize,
pub(crate) summary: Option<(scenario::Context, String)>,
summary_selection: Option<Snapshot>,
}
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub(crate) context: scenario::Context,
pub(crate) snapshot: Snapshot,
pub(crate) token: Id,
pub(crate) intent: Intent,
}
#[derive(Debug, Clone)]
pub(crate) enum Intent {
Begin,
Choose(scenario::Slot),
Query(String),
Page(usize),
Finish(scenario::Slot),
Cancel,
}
fn short_label(value: &str) -> String {
crate::views::short(value, 24)
}
/// Actual committed block order, independent of capture order or saved Y.
fn result_neighborhood(project: &oa_core::Project, parent: Id, snapshot: &Snapshot) -> String {
let Some(order) = project
.elements
.get(&parent)
.and_then(|element| element.steps())
else {
return String::new();
};
let selected: Vec<_> = order
.iter()
.enumerate()
.filter(|(_, id)| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(**id))
})
.collect();
let Some((first, _)) = selected.first() else {
return String::new();
};
let last = selected.last().expect("nonempty block").0;
let label = |index: usize| format!("#{} @{}", index + 1, project.elements[&order[index]].alias);
let left = first
.checked_sub(1)
.map_or_else(|| "Start".to_owned(), label);
let right = if last + 1 < order.len() {
label(last + 1)
} else {
"End".to_owned()
};
let mut members = selected
.iter()
.take(3)
.map(|(index, _)| label(*index))
.collect::<Vec<_>>()
.join(", ");
if selected.len() > 3 {
members.push_str(&format!(", +{} more", selected.len() - 3));
}
format!("{left} → [{members}] → {right}")
}
impl App {
pub(crate) fn scenario_reorder_enabled(&self) -> bool {
self.spacing_enabled()
&& diagram_removal::enabled(self)
&& self.behavior.scenario.spacing_token().is_none()
&& self.scenario_context().is_some_and(|context| {
self.behavior.scenario.canvas_tool(context.scenario) == scenario::CanvasTool::Select
})
}
pub(crate) fn scenario_reorder_cancel(&mut self) {
if self.scenario_reorder.session.take().is_some() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
}
pub(crate) fn scenario_reorder_reconcile(&mut self) {
if self
.scenario_reorder
.summary
.as_ref()
.is_some_and(|(context, _)| {
// A committed result is presentation, not a live authoring packet.
// Resizing diagnostics or moving the camera retires input epochs
// without changing the result's document, view or selection.
let same_result = self.scenario_context().is_some_and(|mut current| {
current.epoch = context.epoch;
current == *context
});
let same_selection = self
.scenario_reorder
.summary_selection
.as_ref()
.zip(self.diagram_selection.as_ref())
.is_some_and(|(snapshot, selected)| {
selected.matches(snapshot, snapshot.context())
});
!same_result || !same_selection
})
{
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
}
let Some(session) = self.scenario_reorder.session.as_ref() else {
return;
};
let valid = self.scenario_reorder_enabled()
&& self
.scenario_context()
.zip(self.multiselect_snapshot())
.is_some_and(|(context, snapshot)| {
session.matches(self.project(), context, &snapshot, session.token())
});
if !valid {
self.scenario_reorder_cancel();
}
}
pub(crate) fn scenario_reorder_event(&mut self, event: Event) -> Task<Message> {
// Reject stale envelopes before touching any newer operation or UI state.
if event.token.is_nil()
|| self.scenario_context() != Some(event.context)
|| !self
.diagram_selection
.as_ref()
.is_some_and(|selection_state| {
selection_state.matches(&event.snapshot, selection::context(event.context))
})
{
return Task::none();
}
if matches!(&event.intent, Intent::Begin) {
if self.scenario_reorder.session.is_some() || !self.scenario_reorder_enabled() {
return Task::none();
}
// Retire the captured Begin before construction, including failed starts.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.scenario_context().expect("admitted scenario");
let snapshot = self.multiselect_snapshot().expect("admitted selection");
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
match Session::new(
self.document.snapshot().decoded.project.clone().into(),
context,
snapshot,
event.token,
) {
Ok(session) => {
self.scenario_reorder.session = Some(session);
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
return scenario::reorder_view::focus_query(event.token);
}
Err(reason) => self.status = reason,
}
return Task::none();
}
let Some(session) = self.scenario_reorder.session.as_ref() else {
return Task::none();
};
if !session.matches(self.project(), event.context, &event.snapshot, event.token) {
return Task::none();
}
if let Intent::Finish(slot) = &event.intent
&& session
.current_preview()
.is_none_or(|preview| preview.slot != *slot)
{
return Task::none();
}
if matches!(&event.intent, Intent::Cancel) {
self.scenario_reorder_cancel();
self.status = "Reorder cancelled. Sequence unchanged.".into();
return Task::none();
}
if !self.scenario_reorder_enabled() {
self.scenario_reorder_cancel();
return Task::none();
}
match event.intent {
Intent::Choose(slot) => {
let preview = self
.scenario_reorder
.session
.as_mut()
.expect("admitted session")
.preview(slot);
self.status = match &preview.outcome {
Outcome::Refused(reason) => reason.clone(),
Outcome::Noop => "This destination leaves the semantic order unchanged.".into(),
Outcome::Change => match preview.presentation.as_ref().map(|shown| shown.status()) {
Some(PresentationStatus::Ready) => "Review the semantic order, then apply reorder. Saved positions are preserved.".into(),
Some(PresentationStatus::Fallback { .. }) => "The new semantic order requires automatic display fallback. Apply keeps the requested saved view and its authored positions.".into(),
_ => "The candidate cannot be displayed; reorder cannot be applied.".into(),
},
};
}
Intent::Query(query) => {
self.scenario_reorder.query = query;
self.scenario_reorder.page = 0;
}
Intent::Page(page) => self.scenario_reorder.page = page,
Intent::Finish(slot) => {
let label = |id| {
self.project().elements.get(&id).map_or_else(
|| id.to_string(),
|element| {
format!(
"{} (@{})",
short_label(&element.name),
short_label(&element.alias)
)
},
)
};
let parent_name = label(slot.parent);
let destination = slot.before.map_or_else(
|| "at the end".to_owned(),
|anchor| format!("before {}", label(anchor)),
);
let session = self
.scenario_reorder
.session
.take()
.expect("admitted session");
let selection_visible = session
.current_preview()
.is_some_and(|preview| preview.selection_visible);
let result =
session.finish(self.project(), event.context, &event.snapshot, event.token);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
match result {
Err(reason) => self.status = reason,
Ok(completion) => {
debug_assert_eq!(completion.representation, event.context.representation);
if !selection_visible {
self.status = "The candidate would hide a selected occurrence; reorder was not applied.".into();
} else if matches!(
completion.presentation_status,
PresentationStatus::Unavailable { .. }
) {
self.status =
"The candidate cannot be displayed; reorder was not applied."
.into();
} else if let Some(command) = completion.command {
match self.document.execute(*command) {
Ok(_) => {
if let Some(crate::diagram_usage::Target::ScenarioObject(
primary,
)) = completion.snapshot.primary()
{
self.selected = *primary;
}
self.sync();
self.reconcile_multiselect();
self.inspect_scenario_multiselect_primary();
let mut summary = format!(
"Moved {} selected occurrences {} in {}. {}. Saved positions are preserved.",
completion.snapshot.targets().len(),
destination,
parent_name,
result_neighborhood(
self.project(),
slot.parent,
&completion.snapshot
)
);
if matches!(
completion.presentation_status,
PresentationStatus::Fallback { .. }
) {
summary.push_str(" Automatic display fallback is active.");
}
self.scenario_reorder.summary =
self.scenario_context().map(|context| (context, summary));
self.scenario_reorder.summary_selection =
self.multiselect_snapshot();
self.status = if matches!(
completion.presentation_status,
PresentationStatus::Fallback { .. }
) {
"Semantic order updated. Automatic display fallback is active; the requested saved view and authored positions are preserved.".into()
} else {
"Semantic order updated. Saved positions are preserved."
.into()
};
}
Err(error) => {
self.status = format!("Reorder was not applied: {error}")
}
}
} else {
self.status = "Semantic order unchanged.".into();
}
}
}
}
Intent::Begin | Intent::Cancel => unreachable!(),
}
Task::none()
}
}
#[cfg(test)]
mod summary_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,231 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, Slot, direct_tests};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Target;
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
fn committed() -> (App, Id, Id, oa_core::source_project::SourceBundle) {
let f = direct_tests::fixture();
let mut project = f.project;
let mut extra = project
.make_element(Kind::Occurrence, Layer::Oa, "Tail", Some(f.owner))
.unwrap();
if let Some(oa_core::SharedBody::Occurrence { body, .. }) = extra.shared_mut() {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
let extra_id = extra.id;
project.elements.insert(extra_id, extra);
project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(extra_id);
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "summary_view".into(),
name: "Summary view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
},
);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Summary view".into(),
}));
for (id, choice) in [
(f.call, multiselect::Choice::Replace),
(f.reply, multiselect::Choice::Add),
] {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
viewport(&mut app, 700.);
let baseline = app.document.bundle().clone();
dispatch(&mut app, Intent::Begin);
let slot = Slot {
parent: f.owner,
before: None,
};
dispatch(&mut app, Intent::Choose(slot));
dispatch(&mut app, Intent::Finish(slot));
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[extra_id, f.call, f.reply]
);
assert!(app.scenario_reorder.summary.is_some(), "{}", app.status);
(app, f.owner, f.call, baseline)
}
fn dispatch(app: &mut App, intent: Intent) {
let event = Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
token: app
.scenario_reorder
.session
.as_ref()
.map_or_else(Id::new_v4, Session::token),
intent,
};
let _ = app.update(Message::ScenarioReorder(Box::new(event)));
}
fn viewport(app: &mut App, height: f32) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(900., height),
)));
}
#[test]
fn scenario_reorder_summary_survives_diagnostics_resize_and_camera_then_undo_clears() {
let (mut app, _, _, baseline) = committed();
let summary = app.scenario_reorder.summary.clone();
let changed = app.document.bundle().clone();
let epoch = app.canvas_epoch;
viewport(&mut app, 660.);
assert_ne!(
app.canvas_epoch, epoch,
"real diagnostics-height viewport change must retire input"
);
assert_eq!(app.scenario_reorder.summary, summary);
let context = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
crate::behavior::canvas_workspace::Event::Pan(iced::Vector::new(24., 12.)),
)));
assert!(app.behavior.scenario.camera_boundary() != camera);
assert_eq!(app.scenario_reorder.summary, summary);
assert_eq!(app.document.bundle(), &changed);
let _ = app.update(Message::Undo);
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
assert_eq!(app.document.bundle(), &baseline);
}
#[test]
fn scenario_reorder_summary_retires_on_view_selection_and_new_operation() {
for action in 0..3 {
let (mut app, owner, call, _) = committed();
let source = app.document.bundle().clone();
match action {
0 => {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
context,
Representation::Derived { owner },
)));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived { owner }
);
}
1 => {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Collapse(Target::ScenarioObject(call)),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
_ => {
dispatch(&mut app, Intent::Begin);
assert!(app.scenario_reorder.session.is_some());
dispatch(&mut app, Intent::Cancel);
assert!(app.scenario_reorder.session.is_none());
assert_eq!(app.status, "Reorder cancelled. Sequence unchanged.");
}
}
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
assert_eq!(app.document.bundle(), &source);
}
}
#[test]
fn scenario_reorder_summary_selection_generation_aba_is_not_a_camera_change() {
let (mut app, _, call, _) = committed();
let captured = app.multiselect_snapshot().unwrap();
let selected = app.diagram_selection.as_mut().unwrap();
assert!(selected.set_primary(&Target::ScenarioObject(call)));
assert!(selected.set_primary(captured.primary().unwrap()));
assert_eq!(selected.targets(), captured.targets());
assert_eq!(selected.primary(), captured.primary());
app.scenario_reorder_reconcile();
assert!(app.scenario_reorder.summary.is_none());
}
#[test]
fn scenario_reorder_summary_clears_after_real_spacing_commit_without_another_event() {
use crate::behavior::scenario::spacing::{Event as Spacing, Target as SpacingTarget};
let (mut app, owner, call, _) = committed();
let reordered = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let begin =
crate::app::scenario_spacing::tests::packet_for_app(&app, SpacingTarget::Message(call));
let Spacing::Begin { press_screen, .. } = begin else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
assert!(app.scenario_reorder.summary.is_some());
let screen = press_screen + iced::Vector::new(0., 24.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
)));
assert_eq!(app.document.bundle(), &reordered.bundle);
let finish = Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(finish)));
assert_ne!(
app.document.bundle(),
&reordered.bundle,
"spacing must actually commit: {}",
app.status
);
assert_eq!(
app.project().elements[&owner].steps(),
reordered.decoded.project.elements[&owner].steps()
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &reordered.bundle);
}
File diff suppressed because it is too large Load Diff
+113 -27
View File
@@ -1,9 +1,33 @@
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
use super::*;
use behavior::scenario::spacing::{Event, controller::Finish};
use crate::diagram_usage::Target;
use behavior::scenario::{
selection,
spacing::{Event, Refusal, controller::Finish},
};
#[cfg(test)]
mod tests;
mod group_tests;
#[cfg(test)]
pub(super) mod tests;
fn refusal_reason(refusal: &Refusal) -> String {
match refusal {
Refusal::InvalidSelection(reason) => (*reason).into(),
Refusal::TargetNotRendered(_) => {
"Every selected object must be a rendered local message or lifeline in this saved view."
.into()
}
Refusal::Geometry(_) => {
"These positions cannot be displayed together. The saved arrangement is unchanged."
.into()
}
Refusal::Command(reason) => reason.clone(),
_ => {
"The drag could not be applied in this view. The saved arrangement is unchanged.".into()
}
}
}
impl App {
pub(super) fn spacing_enabled(&self) -> bool {
@@ -16,22 +40,54 @@ impl App {
}
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
if self.scenario_reorder.session.is_some() {
return Task::none();
}
let stamp = event.stamp();
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
{
return Task::none();
}
let Some(snapshot) = self.multiselect_snapshot() else {
return Task::none();
};
if snapshot.generation() != stamp.selection_generation
|| snapshot.context() != selection::context(stamp.context)
{
return Task::none();
}
let state = &self.behavior.scenario;
let matches =
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
if match event {
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
_ => !matches,
_ => {
!matches
|| !state.spacing_snapshot().is_some_and(|captured| {
self.diagram_selection.as_ref().is_some_and(|selected| {
selected.matches(captured, selection::context(stamp.context))
})
})
}
} {
return Task::none();
}
if matches!(event, Event::Begin { .. }) {
let visible = selection::visible(self.project(), stamp.context, self.selected);
if !snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
{
// This otherwise current Begin cannot be replayed after repair.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status =
"Every selected object must still be visible before moving the group.".into();
return Task::none();
}
}
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
if self.behavior.scenario.cancel_spacing() {
if self.behavior.scenario.cancel_spacing() || matches!(event, Event::Begin { .. }) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
@@ -39,18 +95,38 @@ impl App {
match event {
Event::Begin { .. } => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_spacing(self.project(), event);
let result = state.begin_spacing_with_snapshot(self.project(), event, snapshot);
self.behavior.scenario = state;
if result.is_err() {
self.status = "The drag could not start in this view. Try again.".into();
if let Err(error) = result {
// Consume admitted failed starts too; a repeated Begin is stale.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = refusal_reason(&error);
}
}
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
Event::Finish { screen, .. } => {
// Full captured selection matched above, before taking the session.
let captured = self
.behavior
.scenario
.spacing_snapshot()
.cloned()
.expect("admitted session snapshot");
let group = captured.targets().len() > 1;
let finished = self.behavior.scenario.finish_spacing(screen);
// Retire queued packets even for zero motion or a refused finish.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
match finished {
Ok(Finish::Click(target)) if group => {
let target = Target::ScenarioObject(target);
// The controller only captures a pressed member of this set.
if captured.targets().contains(&target) {
self.diagram_selection
.as_mut()
.expect("admitted selection")
.replace(target, |_| true);
self.inspect_scenario_multiselect_primary();
}
}
Ok(Finish::Click(target)) => self.select(target),
Ok(Finish::Noop) => {}
Ok(Finish::Commit {
@@ -58,28 +134,38 @@ impl App {
selected,
scale,
pan,
}) => {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
self.status = "Scenario spacing updated.".into();
}) => match self.document.execute(*command) {
Ok(_) => {
self.selected = if group {
match captured.primary() {
Some(Target::ScenarioObject(id)) => *id,
_ => selected,
}
} else {
selected
};
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
if group {
self.inspect_scenario_multiselect_primary();
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
self.status = if group {
format!(
"Spacing updated for {} selected objects. Scenario order is unchanged.",
captured.targets().len()
)
} else {
"Scenario spacing updated.".into()
};
}
}
Err(_) => {
self.status =
"That position cannot be applied. The saved arrangement is unchanged."
.into()
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
},
Err(error) => self.status = refusal_reason(&error),
}
self.reconcile_multiselect();
self.scenario_reorder_reconcile();
}
Event::Cancel { .. } => unreachable!(),
}
@@ -0,0 +1,374 @@
use super::tests::{begin, finish, move_to, packet_for_app, send, setup};
use super::*;
use crate::behavior::scenario::{CanvasEvent, selection, spacing::Target};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Target as SelectedTarget;
use iced::Vector;
use oa_core::{
DiagramLayout, OccurrenceEndpoint, ScenarioLanePosition, ScenarioLayout,
ScenarioMessagePosition,
};
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(SelectedTarget::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
fn group(messages: bool, reverse: bool) -> (App, [Id; 2]) {
let (mut app, lane, message) = setup();
let ids = if messages {
[message, id(&app, "second")]
} else {
[lane, id(&app, "operator")]
};
let order = if reverse { [ids[1], ids[0]] } else { ids };
for member in order {
choose(&mut app, member, multiselect::Choice::Add);
}
(app, ids)
}
fn target(messages: bool, id: Id) -> Target {
if messages {
Target::Message(id)
} else {
Target::Lane(id)
}
}
fn delta(messages: bool, pixels: f32) -> Vector {
if messages {
Vector::new(0., pixels)
} else {
Vector::new(pixels, 0.)
}
}
#[test]
fn either_pressed_member_moves_complete_group_and_preserves_primary_source_and_one_history_step() {
for messages in [false, true] {
for pressed_index in 0..2 {
let (mut app, ids) = group(messages, true);
// Automatic lane order depends on generated identities. Give this
// literal geometry oracle explicit authored starting coordinates.
let c = app.scenario_context().unwrap();
let Representation::Saved { diagram } = c.representation else {
unreachable!()
};
let mut initial = app.project().clone();
let mut members = ids.to_vec();
members.sort();
let mut layout = ScenarioLayout::default();
for member in members {
if messages {
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: member,
},
send_y: if member == ids[0] { 148. } else { 240. },
});
} else {
layout.lanes.push(ScenarioLanePosition {
lifeline: member,
x: if member == ids[0] { 160. } else { 400. },
});
}
}
initial.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(layout));
app.document =
Document::from_project(initial, oa_core::source_project::ProjectLimits::default())
.unwrap();
app.sync();
let original = app.document.snapshot();
let original_selection = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let c = app.scenario_context().unwrap();
let Representation::Saved { diagram } = c.representation else {
unreachable!()
};
let (event, start, scale) = begin(&mut app, target(messages, ids[pressed_index]));
let world_delta = 24. / scale;
move_to(&mut app, event, start + delta(messages, 24.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.selected, primary);
assert_eq!(
app.behavior.scenario.spacing_snapshot(),
Some(&original_selection)
);
let mut expected = original.decoded.project.clone();
let mut members = ids.to_vec();
members.sort();
let mut layout = ScenarioLayout::default();
for member in members {
if messages {
let y: f32 = if member == ids[0] { 148. } else { 240. };
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: member,
},
send_y: (f64::from(y) + f64::from(world_delta)) as f32,
});
} else {
let x: f32 = if member == ids[0] { 160. } else { 400. };
layout.lanes.push(ScenarioLanePosition {
lifeline: member,
x: (f64::from(x) + f64::from(world_delta)) as f32,
});
}
}
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(layout));
finish(&mut app, event, start + delta(messages, 24.));
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, primary);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
original_selection.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
original_selection.primary()
);
let saved = app.document.bundle().clone();
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &saved);
let reopened =
Document::from_bundle(saved, oa_core::source_project::ProjectLimits::default())
.unwrap();
assert_eq!(reopened.project(), &expected);
}
}
}
#[test]
fn group_stationary_click_collapses_but_offaxis_outback_cancel_preserve_group_and_redo() {
for messages in [false, true] {
for mode in 0..4 {
let (mut app, ids) = group(messages, true);
let (seed, start, _) = begin(&mut app, target(messages, ids[0]));
finish(&mut app, seed, start + delta(messages, 20.));
let _ = app.update(Message::ShortcutUndo(false));
assert!(app.document.can_redo());
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, target(messages, ids[1]));
match mode {
0 => finish(&mut app, event, start),
1 => finish(&mut app, event, start + delta(!messages, 50.)),
2 => {
move_to(&mut app, event, start + delta(messages, 30.));
finish(&mut app, event, start);
}
_ => {
move_to(&mut app, event, start + delta(messages, 30.));
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
}
}
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.can_redo() && !app.document.can_undo());
assert!(camera == app.behavior.scenario.camera_boundary());
if mode == 0 {
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[SelectedTarget::ScenarioObject(ids[1])]
);
assert_eq!(app.selected, ids[1]);
} else {
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(app.selected, primary);
}
}
}
}
#[test]
fn stale_group_packets_and_generation_forgery_do_not_disturb_a_newer_live_gesture() {
let (mut app, ids) = group(true, true);
let (old, start, _) = begin(&mut app, Target::Message(ids[0]));
send(
&mut app,
Event::Cancel {
stamp: old.stamp(),
token: old.token(),
},
);
let (current, current_start, _) = begin(&mut app, Target::Message(ids[1]));
let original = app.document.snapshot();
let selection = app.multiselect_snapshot();
let epoch = app.canvas_epoch;
let status = app.status.clone();
let mut forged = current;
if let Event::Begin { stamp, .. } = &mut forged {
stamp.selection_generation = stamp.selection_generation.wrapping_add(1);
}
for event in [
old,
forged,
Event::Move {
stamp: old.stamp(),
token: old.token(),
screen: start + Vector::new(0., 90.),
},
Event::Finish {
stamp: old.stamp(),
token: old.token(),
screen: start + Vector::new(0., 90.),
},
Event::Cancel {
stamp: old.stamp(),
token: old.token(),
},
] {
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.status, status);
assert_eq!(app.multiselect_snapshot(), selection);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
}
finish(&mut app, current, current_start + Vector::new(0., 24.));
assert!(app.document.can_undo());
}
#[test]
fn mixed_group_drag_refuses_whole_set_without_primary_only_change_or_replay() {
let (mut app, lane, message) = setup();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let event = packet_for_app(&app, Target::Message(message));
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
move_to(&mut app, event, press_screen + Vector::new(0., 30.));
assert_eq!(app.project(), &original.decoded.project);
finish(&mut app, event, press_screen + Vector::new(0., 30.));
assert_ne!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
let status = app.status.clone();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
#[test]
fn mixed_group_stationary_click_on_either_role_still_selects_only_that_member() {
for lane_hit in [false, true] {
let (mut app, lane, message) = setup();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let original = app.document.snapshot();
let member = if lane_hit { lane } else { message };
let (event, start, _) = begin(&mut app, target(!lane_hit, member));
finish(&mut app, event, start);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[SelectedTarget::ScenarioObject(member)]
);
assert_eq!(app.selected, member);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
}
#[test]
fn malformed_current_begin_consumes_its_lifetime_without_editing_or_replaying() {
let (mut app, ids) = group(true, true);
let mut event = packet_for_app(&app, Target::Message(ids[0]));
if let Event::Begin { canvas_bounds, .. } = &mut event {
canvas_bounds.width = 0.;
}
let original = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_ne!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
let epoch = app.canvas_epoch;
let status = app.status.clone();
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.status, status);
}
#[test]
fn group_source_write_failure_is_atomic_and_preserves_selection_and_camera() {
let (mut app, ids) = group(true, true);
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(ids[1]));
finish(&mut app, event, start + Vector::new(0., 24.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(app.selected, primary);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
@@ -8,7 +8,7 @@ use behavior::scenario::{
};
use iced::{Point, Size, Vector};
fn setup() -> (App, Id, Id) {
pub(super) fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
@@ -88,14 +88,21 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture()
"the current gesture remains usable"
);
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
pub(in crate::app) fn packet_for_app(app: &App, target: Target) -> Event {
let mut event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
if let Event::Begin { stamp, .. } = &mut event {
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
}
event
}
pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet_for_app(app, target);
let Event::Begin {
press_screen,
scale,
@@ -108,10 +115,10 @@ fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
fn send(app: &mut App, event: Event) {
pub(super) fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
fn move_to(app: &mut App, begin: Event, screen: Point) {
pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
@@ -121,7 +128,7 @@ fn move_to(app: &mut App, begin: Event, screen: Point) {
},
);
}
fn finish(app: &mut App, begin: Event, screen: Point) {
pub(super) fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
@@ -116,6 +116,7 @@ impl App {
}
match event {
Event::Selection(event) => self.multiselect_event(event),
Event::ModelRemoval(event) => return self.saved_removal_event(*event),
Event::Remove(intent) => self.remove_from_diagram(intent),
Event::Viewport(_, size) => {
if size.width.is_finite()
@@ -10,11 +10,6 @@ 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);
@@ -46,11 +41,17 @@ 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]);
// 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 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.);
let before = app.document.snapshot();
let original = app.project().clone();
let source = app.document.bundle().clone();
+219 -6
View File
@@ -1243,6 +1243,7 @@ struct Scene {
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1262,6 +1263,8 @@ struct SequenceSlot {
}
#[derive(Default)]
struct SequenceInput {
modifiers: iced::keyboard::Modifiers,
group_press: Option<ScenarioGroupPress>,
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
@@ -1269,6 +1272,13 @@ struct SequenceInput {
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
struct ScenarioGroupPress {
context: scenario::Context,
snapshot: crate::diagram_selection::Snapshot,
target: crate::diagram_usage::Target,
start: Point,
moved: bool,
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
@@ -1309,11 +1319,48 @@ impl SequenceInput {
cancel
}
fn cancel(&mut self) {
self.group_press = None;
self.source = None;
self.press = None;
}
}
impl Scene {
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
let mut ids = std::collections::BTreeSet::new();
ids.extend(
self.boxes
.iter()
.filter(|b| !b.title.is_empty())
.map(|b| b.id),
);
ids.extend(self.lines.iter().map(|line| line.id));
ids.extend(self.activations.iter().map(|segment| segment.call));
ids.extend(self.render_lanes.keys().copied());
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
ids.into_iter()
.map(crate::diagram_usage::Target::ScenarioObject)
.collect()
}
fn is_selected(&self, id: Id) -> bool {
self.selection
.as_ref()
.map_or(id == self.selected, |(_, snapshot)| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
})
}
fn is_group(&self) -> bool {
self.selection
.as_ref()
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
}
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
let (context, snapshot) = self.selection.as_ref()?;
Some(
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
)
}
fn removal_target(&self, owner: Id, id: Id) -> bool {
id != owner
&& (self.boxes.iter().any(|item| item.id == id)
@@ -1340,6 +1387,7 @@ impl Scene {
selected,
interaction: None,
spacing_stamp: None,
selection: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
@@ -1982,7 +2030,24 @@ impl canvas::Program<Message> for Scene {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use crate::{app::multiselect::Choice, diagram_usage::Target};
use scenario::selection::Intent;
use scenario::{CanvasEvent, CanvasTool};
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
return None;
}
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
}
if state.group_press.as_ref().is_some_and(|press| {
self.selection.as_ref().is_none_or(|(context, snapshot)| {
*context != press.context || *snapshot != press.snapshot
})
}) {
state.cancel();
}
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
@@ -2007,10 +2072,14 @@ impl canvas::Program<Message> for Scene {
}
state.bounds = Some(bounds);
if (!editable || !self.coordinate_input)
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
}
if !editable {
state.cancel();
}
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
@@ -2037,6 +2106,9 @@ impl canvas::Program<Message> for Scene {
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(press) = &mut state.group_press {
press.moved |= position.distance(press.start) >= 3.;
}
if let Some(press) = &mut state.press {
match press {
SequencePress::Message { start, moved, .. }
@@ -2072,9 +2144,22 @@ impl canvas::Program<Message> for Scene {
&& tool == CanvasTool::Select
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
&& state.press.is_none()
&& state.group_press.is_none()
&& state.source.is_none() =>
{
let context = context?;
if self.is_group() {
let (_, snapshot) = self.selection.as_ref()?;
return Some(
Action::publish(Message::ScenarioRemoval(Box::new(
crate::app::scenario_removal::Event::Begin {
context,
snapshot: Box::new(snapshot.clone()),
},
)))
.and_capture(),
);
}
if self.removal_target(context.scenario, self.selected) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
@@ -2089,7 +2174,10 @@ impl canvas::Program<Message> for Scene {
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
}) if state.source.is_some()
|| state.press.is_some()
|| state.group_press.is_some() =>
{
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
@@ -2103,6 +2191,18 @@ impl canvas::Program<Message> for Scene {
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
let (context, _) = self.selection.as_ref()?;
let hit = self.hit_item(world?)?;
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
request.scenario = Some(Box::new(*context));
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
state.cancel();
Some(
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
.and_capture(),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
return None;
@@ -2112,6 +2212,83 @@ impl canvas::Program<Message> for Scene {
if !editable {
return None;
}
if let Some((selection_context, snapshot)) = &self.selection {
let hit = self.hit_item(point).or_else(|| {
(!self.is_group()
&& self.is_selected(self.selected)
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
))
.then(|| {
self.reorder_handle()
.filter(|handle| handle.contains(point))
})
.flatten()
.map(|_| SceneHit {
id: self.selected,
role: SceneHitRole::Box,
})
});
if tool != CanvasTool::Select && self.is_group() {
return self.selection_action(Intent::Refuse(
"Select one occurrence before using an authoring tool".into(),
));
}
if tool == CanvasTool::Select {
if let Some(hit) = hit {
let target = Target::ScenarioObject(hit.id);
if state.modifiers.shift() {
state.cancel();
return self
.selection_action(Intent::Choose(target, Choice::Toggle));
}
let group_spacing_hit = self.coordinate_input
&& self.spacing_stamp.is_some()
&& matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
)
&& matches!(
hit.role,
SceneHitRole::LifelineHeader
| SceneHitRole::MessageBody
| SceneHitRole::MessageLabel
);
if self.is_group() && self.is_selected(hit.id) && !group_spacing_hit
{
state.group_press = Some(ScenarioGroupPress {
context: *selection_context,
snapshot: snapshot.clone(),
target,
start: cursor.position()?,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
// Preserve the existing first-press singleton
// spacing/reorder gesture. Replacing a displayed
// group is an explicit selection change first.
if (self.is_group() && !self.is_selected(hit.id))
|| !self.coordinate_input
{
return self
.selection_action(Intent::Choose(target, Choice::Replace));
}
} else {
state.cancel();
if state.modifiers.shift() {
return Some(Action::capture());
}
// Background clearing shares this press with camera panning.
return Some(Action::publish(scenario::selection::message(
*selection_context,
snapshot,
Intent::Clear,
)));
}
}
}
if !self.coordinate_input {
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
@@ -2214,6 +2391,27 @@ impl canvas::Program<Message> for Scene {
.map(|id| Action::publish(Message::Select(id)).and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if let Some(press) = state.group_press.take() {
if world.is_none() {
return Some(Action::request_redraw());
}
let intent = if press.moved {
Intent::Refuse(
"This selection cannot be moved here; select one occurrence for its individual actions"
.into(),
)
} else {
Intent::Collapse(press.target)
};
return Some(
Action::publish(scenario::selection::message(
press.context,
&press.snapshot,
intent,
))
.and_capture(),
);
}
let press = state.press.take()?;
let context = context?;
match press {
@@ -2365,7 +2563,7 @@ impl canvas::Program<Message> for Scene {
}
}),
Stroke::default()
.with_color(if guide.id == self.selected {
.with_color(if self.is_selected(guide.id) {
style::TEAL
} else {
style::MUTED
@@ -2391,7 +2589,7 @@ impl canvas::Program<Message> for Scene {
f.stroke(
&path,
Stroke::default()
.with_color(if activation.call == self.selected {
.with_color(if self.is_selected(activation.call) {
style::TEAL
} else {
style::BOUNDARY
@@ -2408,7 +2606,7 @@ impl canvas::Program<Message> for Scene {
b.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
);
let color = if b.id == self.selected {
let color = if self.is_selected(b.id) {
style::TEAL
} else {
style::BOUNDARY
@@ -2430,7 +2628,7 @@ impl canvas::Program<Message> for Scene {
}
}
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if l.id == self.selected {
let color = if self.is_selected(l.id) {
style::TEAL
} else {
style::MUTED
@@ -2486,7 +2684,7 @@ impl canvas::Program<Message> for Scene {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
color: if self.is_selected(b.id) {
style::TEAL
} else {
style::INK
@@ -2516,6 +2714,19 @@ impl canvas::Program<Message> for Scene {
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if self.selection.is_some() {
// Lifecycle glyphs use their exact painted hit identity too.
for (id, point) in self.lifecycle.selection_markers() {
if self.is_selected(id) {
f.stroke(
&Path::circle(point, 12.),
Stroke::default()
.with_color(style::TEAL)
.with_width(if id == self.selected { 3. } else { 1.5 }),
);
}
}
}
if let Some((context, tool, editable)) = self.interaction {
let active = editable
&& self.coordinate_input
@@ -2542,6 +2753,8 @@ impl canvas::Program<Message> for Scene {
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& !self.is_group()
&& self.is_selected(self.selected)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
+23 -19
View File
@@ -6,6 +6,13 @@ use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus;
pub(crate) mod group_removal;
pub(crate) mod group_reorder;
pub(crate) mod reorder_session;
#[cfg(test)]
mod reorder_slot_experiments;
pub(crate) mod reorder_view;
pub(crate) mod selection;
pub(crate) mod spacing;
use super::scenario_layout::{
Representation,
@@ -143,6 +150,7 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Selection(Box<selection::Event>),
Spacing(spacing::Event),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
@@ -186,6 +194,7 @@ impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Spacing(event) => event.stamp().context,
Self::Selection(event) => event.context,
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
@@ -1253,26 +1262,21 @@ pub(crate) fn reorder(
occurrence: Id,
slot: Slot,
) -> Result<Command, String> {
let to = slot_index(p, owner, slot)?;
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
return Err(
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
.into(),
);
let prepared = group_reorder::Prepared::new(
std::sync::Arc::new(p.clone()),
owner,
&[crate::diagram_usage::Target::ScenarioObject(occurrence)],
)?;
match prepared.plan(slot)? {
group_reorder::Plan::Change(command) => Ok(*command),
group_reorder::Plan::Noop => Ok(Command::SetSteps(
slot.parent,
p.elements[&slot.parent]
.steps()
.expect("admitted sequence")
.clone(),
)),
}
let mut steps = p.elements[&slot.parent]
.steps()
.expect("slot validated")
.clone();
let from = steps
.iter()
.position(|id| *id == occurrence)
.ok_or("This occurrence is not in the sequence.")?;
steps.remove(from);
steps.insert(to - usize::from(from < to), occurrence);
let command = Command::SetSteps(slot.parent, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
Ok(command)
}
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
represented(p, referenced)
@@ -0,0 +1,182 @@
//! Whole-set local Scenario removal. UI visibility and input lifetime admission
//! belong to the caller; this planner validates structural identity and semantics.
use crate::diagram_usage::Target;
use oa_core::{Command, Id, Kind, Layer, OccurrenceBody, Project, SharedBody, execute_batch};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug)]
pub(crate) struct Plan {
pub(crate) command: Command,
/// Actual deleted identities, including owned descendants and dependents.
pub(crate) removed: BTreeSet<Id>,
/// Surviving records changed by reference clearing, sequence edits or view
/// pruning. Embedded surviving placements are included alongside diagrams.
pub(crate) changed: BTreeSet<Id>,
}
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
match project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. }
if matches!(
body,
OccurrenceBody::Exchange { .. }
| OccurrenceBody::Action { .. }
| OccurrenceBody::State { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Fragment { .. }
) =>
{
Some(body)
}
_ => None,
}
}
/// Follow the actual ordered/operand lists, not only parent pointers. Check the
/// complete local tree so malformed siblings cannot become a permissive subset.
fn local_occurrences(project: &Project, owner: Id, layer: Layer) -> Result<BTreeSet<Id>, String> {
let mut reachable = BTreeSet::new();
let mut sequences = vec![owner];
while let Some(parent) = sequences.pop() {
let steps = project.elements[&parent]
.steps()
.ok_or("The Scenario sequence is no longer available.")?;
for &id in steps {
let element = project
.elements
.get(&id)
.ok_or("A Scenario step is missing.")?;
let body =
step_body(project, id).ok_or("The Scenario contains an unsupported step.")?;
if element.parent != Some(parent) || element.layer() != layer || !reachable.insert(id) {
return Err("The Scenario lists must contain unique local occurrences of their actual parents.".into());
}
if let OccurrenceBody::Fragment { branches, .. } = body {
for &branch in branches {
let element = project
.elements
.get(&branch)
.ok_or("A Scenario operand is missing.")?;
if element.parent != Some(id)
|| element.layer() != layer
|| !matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
|| !reachable.insert(branch)
{
return Err("The Scenario fragment must list each actual local operand exactly once.".into());
}
sequences.push(branch);
}
}
}
}
Ok(reachable)
}
fn changed_records<T: PartialEq>(
before: &BTreeMap<Id, T>,
after: &BTreeMap<Id, T>,
changed: &mut BTreeSet<Id>,
) {
changed.extend(
before
.iter()
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
);
}
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
let owner_element = project
.elements
.get(&owner)
.filter(|element| element.kind() == Kind::Scenario)
.ok_or("This Scenario no longer exists.")?;
let mut selected = BTreeSet::new();
for target in targets {
let Target::ScenarioObject(id) = target else {
return Err("Remove only local Scenario participants and occurrences together.".into());
};
if !selected.insert(*id) {
return Err("Every selected Scenario subject must be unique.".into());
}
}
if selected.is_empty() {
return Err("Select at least one Scenario participant or occurrence to remove.".into());
}
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
let reachable = local_occurrences(project, owner, owner_element.layer())?;
for &id in &selected {
let element = project
.elements
.get(&id)
.ok_or("A selected Scenario subject no longer exists.")?;
let local = if element.kind() == Kind::Lifeline {
element.parent == Some(owner) && element.layer() == owner_element.layer()
} else {
reachable.contains(&id)
};
if !local {
return Err("Every selected subject must be a local participant or a listed occurrence/operand in this Scenario.".into());
}
}
// Admission above includes every original member. An admitted selected
// descendant is covered by its selected ancestor's core cascade, not dropped
// as an ineligible target. Sending both Delete commands would delete it twice.
let roots = selected.iter().copied().filter(|id| {
let mut parent = project.elements[id].parent;
while let Some(id) = parent {
if selected.contains(&id) {
return false;
}
parent = project.elements.get(&id).and_then(|element| element.parent);
}
true
});
let command = Command::Batch(roots.map(Command::Delete).collect());
// One candidate and one final semantic validation, with no intermediate
// Document transactions or singleton validation that can reject a valid union.
let candidate = execute_batch(project, std::slice::from_ref(&command))
.map_err(|error| error.to_string())?;
let retained: BTreeSet<_> = candidate
.identities()
.into_iter()
.map(|(id, _)| id)
.collect();
let removed: BTreeSet<_> = project
.identities()
.into_iter()
.map(|(id, _)| id)
.filter(|id| !retained.contains(id))
.collect();
if !selected.is_subset(&removed) {
return Err("The complete selected set could not be removed.".into());
}
let mut changed = BTreeSet::new();
changed_records(&project.elements, &candidate.elements, &mut changed);
changed_records(&project.relations, &candidate.relations, &mut changed);
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
changed_records(
&project.artifact_links,
&candidate.artifact_links,
&mut changed,
);
changed_records(&project.traces, &candidate.traces, &mut changed);
for (&id, diagram) in &project.diagrams {
if let Some(after) = candidate.diagrams.get(&id) {
changed_records(&diagram.nodes, &after.nodes, &mut changed);
}
}
Ok(Plan {
command,
removed,
changed,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,824 @@
use super::*;
use crate::{behavior::topology::occurrence, document::Document};
use oa_core::{
ArtifactLink, ArtifactRelation, Diagram, DiagramLayout, FragmentOperator, LifelineBinding,
MessageKind, OccurrenceEndpoint, Position, ProjectionKind, ScenarioLanePosition,
ScenarioLayout, ScenarioMessagePosition, Trace, TraceKind, language,
};
struct Fixture {
project: Project,
owner: Id,
work: [Id; 2],
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn add(project: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
let element = occurrence(project, parent, name, body);
let id = element.id;
project.elements.insert(id, element);
project
.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn body(project: &mut Project, id: Id) -> &mut OccurrenceBody {
let SharedBody::Occurrence { body, .. } =
project.elements.get_mut(&id).unwrap().shared_mut().unwrap()
else {
panic!("occurrence")
};
body
}
fn action(project: &mut Project, parent: Id, name: &str) -> Id {
add(
project,
parent,
name,
OccurrenceBody::Action {
action: None,
lifeline: None,
},
)
}
fn operand(project: &mut Project, fragment: Id, name: &str) -> Id {
let element = occurrence(
project,
fragment,
name,
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
let id = element.id;
project.elements.insert(id, element);
let OccurrenceBody::Fragment { branches, .. } = body(project, fragment) else {
panic!()
};
branches.push(id);
id
}
fn fixture(layer: Layer) -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, layer, "Conversation", None)
.unwrap();
let work = ["Source work", "Target work"].map(|name| {
project
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
name,
None,
)
.unwrap()
});
let lanes = ["Left", "Right"].map(|name| {
project
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap()
});
for (lane, work) in lanes.into_iter().zip(work) {
*project
.elements
.get_mut(&lane)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(work),
};
}
let messages = ["First", "Second"].map(|name| {
add(
&mut project,
owner,
name,
OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
)
});
let views = ["Saved A", "Saved B"].map(|name| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: project.fresh_alias(name, "view", id),
name: name.into(),
description: "Keep authored metadata".into(),
layer,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 140.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 540.,
},
],
messages: messages
.iter()
.enumerate()
.map(|(index, &occurrence)| ScenarioMessagePosition {
address: OccurrenceEndpoint {
occurrence,
references: vec![],
},
send_y: 220. + index as f32 * 160.,
})
.collect(),
})),
},
);
id
});
oa_core::session::validate_candidate(&project).unwrap();
Fixture {
project,
owner,
work,
lanes,
messages,
views,
}
}
fn selected(ids: &[Id]) -> Vec<Target> {
ids.iter().copied().map(Target::ScenarioObject).collect()
}
fn layout(project: &mut Project, diagram: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
}
fn exact_transaction(project: &Project, plan: Plan, expected: &Project) {
assert!(matches!(plan.command, Command::Batch(_)));
assert_eq!(
&execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(),
expected
);
let mut document = Document::from_project(project.clone(), Default::default()).unwrap();
let original = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), expected);
let changed = document.bundle().clone();
assert_eq!(
language::parse(&language::format(expected)).unwrap(),
*expected
);
assert_eq!(
Document::from_bundle(changed.clone(), Default::default())
.unwrap()
.project(),
expected
);
assert!(document.undo());
assert_eq!(document.project(), project);
assert_eq!(document.bundle(), &original);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), expected);
assert_eq!(document.bundle(), &changed);
assert!(!document.can_redo());
}
#[test]
fn whole_messages_in_both_layers_preserve_definitions_and_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
let baseline = f.project.clone();
let plan = prepare(&f.project, f.owner, &selected(&f.messages)).unwrap();
let reversed = prepare(
&f.project,
f.owner,
&selected(&[f.messages[1], f.messages[0]]),
)
.unwrap();
assert_eq!(plan.command, reversed.command);
assert_eq!(plan.removed, f.messages.into_iter().collect());
assert_eq!(
plan.changed,
[f.owner, f.views[0], f.views[1]].into_iter().collect()
);
let mut expected = f.project.clone();
for id in f.messages {
expected.elements.remove(&id);
}
expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.clear();
for view in f.views {
layout(&mut expected, view).messages.clear();
}
for id in f.work {
assert_eq!(expected.elements[&id], f.project.elements[&id]);
}
assert_eq!(f.project, baseline, "preparation is pure");
exact_transaction(&f.project, plan, &expected);
}
}
#[test]
fn mixed_participant_and_message_union_clears_surviving_references_and_prunes_both_views() {
let mut f = fixture(Layer::Oa);
let local_action = add(
&mut f.project,
f.owner,
"Local action",
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: Some(f.lanes[0]),
},
);
let observation = add(
&mut f.project,
f.owner,
"Observation",
OccurrenceBody::State {
state: None,
lifeline: Some(f.lanes[0]),
},
);
let definition = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Reused definition", None)
.unwrap();
let remote = f
.project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Remote participant",
Some(definition),
)
.unwrap();
*f.project
.elements
.get_mut(&remote)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(f.work[0]),
};
let use_id = add(
&mut f.project,
f.owner,
"Use definition",
OccurrenceBody::ScenarioUse {
scenario: Some(definition),
bindings: vec![LifelineBinding {
referenced: remote,
local: f.lanes[0],
}],
},
);
oa_core::session::validate_candidate(&f.project).unwrap();
let plan = prepare(&f.project, f.owner, &selected(&[f.lanes[0], f.messages[0]])).unwrap();
let mut expected = f.project.clone();
expected.elements.remove(&f.lanes[0]);
expected.elements.remove(&f.messages[0]);
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[1], local_action, observation, use_id];
let OccurrenceBody::Exchange { sender, .. } = body(&mut expected, f.messages[1]) else {
panic!()
};
*sender = None;
let OccurrenceBody::Action { lifeline, .. } = body(&mut expected, local_action) else {
panic!()
};
*lifeline = None;
let OccurrenceBody::State { lifeline, .. } = body(&mut expected, observation) else {
panic!()
};
*lifeline = None;
let OccurrenceBody::ScenarioUse { bindings, .. } = body(&mut expected, use_id) else {
panic!()
};
bindings.clear();
for view in f.views {
let rows = layout(&mut expected, view);
rows.lanes.remove(0);
rows.messages.remove(0);
}
assert_eq!(
plan.removed,
[f.lanes[0], f.messages[0]].into_iter().collect()
);
assert_eq!(
plan.changed,
[
f.owner,
f.messages[1],
local_action,
observation,
use_id,
f.views[0],
f.views[1]
]
.into_iter()
.collect()
);
assert_eq!(
expected.elements[&definition],
f.project.elements[&definition]
);
assert_eq!(expected.elements[&remote], f.project.elements[&remote]);
exact_transaction(&f.project, plan, &expected);
}
#[test]
fn call_removal_keeps_reply_as_warning_admitted_draft_and_reports_its_changed_identity() {
let mut f = fixture(Layer::Oa);
let OccurrenceBody::Exchange { message_kind, .. } = body(&mut f.project, f.messages[0]) else {
panic!()
};
*message_kind = MessageKind::Call;
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = body(&mut f.project, f.messages[1])
else {
panic!()
};
*sender = Some(f.lanes[1]);
*receiver = Some(f.lanes[0]);
*message_kind = MessageKind::Reply;
*reply_to = Some(f.messages[0]);
oa_core::session::validate_candidate(&f.project).unwrap();
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0], f.lanes[1]])).unwrap();
let mut expected = f.project.clone();
expected.elements.remove(&f.messages[0]);
expected.elements.remove(&f.lanes[1]);
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[1]];
let OccurrenceBody::Exchange {
sender, reply_to, ..
} = body(&mut expected, f.messages[1])
else {
panic!()
};
*sender = None;
*reply_to = None;
for view in f.views {
let rows = layout(&mut expected, view);
rows.lanes.remove(1);
rows.messages.remove(0);
}
assert_eq!(
plan.removed,
[f.messages[0], f.lanes[1]].into_iter().collect()
);
assert_eq!(
plan.changed,
[f.owner, f.messages[1], f.views[0], f.views[1]]
.into_iter()
.collect()
);
assert!(
oa_core::validation::validate(&expected)
.iter()
.any(|issue| issue.code == "missing_reply_call"
&& issue.severity == oa_core::validation::Severity::Warning)
);
exact_transaction(&f.project, plan, &expected);
}
fn nested(f: &mut Fixture) -> (Id, [Id; 2], [Id; 2]) {
let fragment = add(
&mut f.project,
f.owner,
"Alternatives",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branches = [
operand(&mut f.project, fragment, "Left operand"),
operand(&mut f.project, fragment, "Right operand"),
];
let children = [
action(&mut f.project, branches[0], "Left action"),
action(&mut f.project, branches[1], "Right action"),
];
(fragment, branches, children)
}
#[test]
fn selected_ancestor_covers_all_descendants_after_admission_and_executes_once() {
let mut f = fixture(Layer::Oa);
let (fragment, branches, children) = nested(&mut f);
oa_core::session::validate_candidate(&f.project).unwrap();
assert!(
execute_batch(
&f.project,
&[Command::Delete(fragment), Command::Delete(children[0])]
)
.is_err(),
"naive cascading singleton deletes are order-dependent"
);
let members = [children[0], f.messages[1], fragment, branches[0]];
let plan = prepare(&f.project, f.owner, &selected(&members)).unwrap();
let roots: BTreeSet<_> = [fragment, f.messages[1]].into_iter().collect();
assert_eq!(
plan.command,
Command::Batch(roots.into_iter().map(Command::Delete).collect())
);
let removed = [
fragment,
branches[0],
branches[1],
children[0],
children[1],
f.messages[1],
];
assert_eq!(plan.removed, removed.into_iter().collect());
assert_eq!(
plan.changed,
[f.owner, f.views[0], f.views[1]].into_iter().collect()
);
let mut expected = f.project.clone();
for id in removed {
expected.elements.remove(&id);
}
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[0]];
for view in f.views {
layout(&mut expected, view).messages.remove(1);
}
exact_transaction(&f.project, plan, &expected);
}
#[test]
fn independent_operands_can_be_removed_together_without_a_reorder_parent_restriction() {
let mut f = fixture(Layer::Sa);
let (fragment, branches, children) = nested(&mut f);
let plan = prepare(
&f.project,
f.owner,
&selected(&[children[1], f.messages[0], children[0]]),
)
.unwrap();
let mut expected = f.project.clone();
for id in [children[0], children[1], f.messages[0]] {
expected.elements.remove(&id);
}
for branch in branches {
expected
.elements
.get_mut(&branch)
.unwrap()
.steps_mut()
.unwrap()
.clear();
}
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[1], fragment];
for view in f.views {
layout(&mut expected, view).messages.remove(0);
}
assert_eq!(
plan.changed,
[f.owner, branches[0], branches[1], f.views[0], f.views[1]]
.into_iter()
.collect()
);
exact_transaction(&f.project, plan, &expected);
}
#[test]
fn branch_and_use_are_local_removal_targets_and_reused_definition_survives() {
let mut f = fixture(Layer::Oa);
let (fragment, branches, children) = nested(&mut f);
let definition = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
.unwrap();
let use_id = add(
&mut f.project,
f.owner,
"Use",
OccurrenceBody::ScenarioUse {
scenario: Some(definition),
bindings: vec![],
},
);
let plan = prepare(&f.project, f.owner, &selected(&[branches[0], use_id])).unwrap();
let mut expected = f.project.clone();
for id in [branches[0], children[0], use_id] {
expected.elements.remove(&id);
}
let OccurrenceBody::Fragment {
branches: remaining,
..
} = body(&mut expected, fragment)
else {
panic!()
};
*remaining = vec![branches[1]];
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[0], f.messages[1], fragment];
assert_eq!(
plan.removed,
[branches[0], children[0], use_id].into_iter().collect()
);
assert_eq!(plan.changed, [f.owner, fragment].into_iter().collect());
assert_eq!(
expected.elements[&definition],
f.project.elements[&definition]
);
exact_transaction(&f.project, plan, &expected);
}
#[test]
fn impact_includes_placements_artifacts_traces_and_changed_surviving_constraints() {
let mut f = fixture(Layer::Oa);
let overview = f.project.ensure_diagram(Layer::Oa);
let node = f
.project
.place(overview, f.messages[0], Position { x: 100., y: 100. });
let other_node = f
.project
.place(overview, f.messages[1], Position { x: 400., y: 100. });
let counterpart = f
.project
.add_element(
Kind::Function,
Layer::Sa,
"Opposite-layer counterpart",
None,
)
.unwrap();
let trace = Id::new_v4();
f.project.traces.insert(
trace,
Trace {
id: trace,
alias: "manual_trace".into(),
name: "Manual trace".into(),
description: String::new(),
oa: f.messages[0],
sa: counterpart,
kind: TraceKind::Manual,
baseline: None,
},
);
let artifact = Id::new_v4();
f.project.artifact_links.insert(
artifact,
ArtifactLink {
id: artifact,
alias: "evidence".into(),
name: "Evidence".into(),
description: String::new(),
owner: f.messages[0],
relation: ArtifactRelation::References,
uri: "evidence.txt".into(),
revision: None,
fragment: None,
},
);
let constraint = f
.project
.add_element(Kind::Constraint, Layer::Oa, "Constraint", None)
.unwrap();
*f.project
.elements
.get_mut(&constraint)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Constraint {
expression: "review".into(),
constrained: f.messages.to_vec(),
};
oa_core::session::validate_candidate(&f.project).unwrap();
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0]])).unwrap();
let mut expected = f.project.clone();
expected.elements.remove(&f.messages[0]);
expected.traces.remove(&trace);
expected.artifact_links.remove(&artifact);
expected
.diagrams
.get_mut(&overview)
.unwrap()
.nodes
.remove(&node);
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.messages[1]];
let SharedBody::Constraint { constrained, .. } = expected
.elements
.get_mut(&constraint)
.unwrap()
.shared_mut()
.unwrap()
else {
panic!()
};
*constrained = vec![f.messages[1]];
for view in f.views {
layout(&mut expected, view).messages.remove(0);
}
assert_eq!(
plan.removed,
[f.messages[0], node, trace, artifact].into_iter().collect()
);
assert_eq!(
plan.changed,
[f.owner, constraint, overview, f.views[0], f.views[1]]
.into_iter()
.collect()
);
assert_eq!(
expected.elements[&counterpart],
f.project.elements[&counterpart]
);
assert_eq!(
expected.diagrams[&overview].nodes[&other_node],
f.project.diagrams[&overview].nodes[&other_node]
);
exact_transaction(&f.project, plan, &expected);
}
#[test]
fn whole_typed_selection_refuses_missing_foreign_definitions_and_duplicates_before_collapse() {
let mut f = fixture(Layer::Oa);
let (fragment, _, children) = nested(&mut f);
let other = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Other", None)
.unwrap();
let foreign = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign", Some(other))
.unwrap();
for targets in [
vec![],
vec![Target::Whole],
selected(&[f.messages[0], f.messages[0]]),
selected(&[f.messages[0], Id::nil()]),
selected(&[f.messages[0], f.owner]),
selected(&[f.messages[0], f.work[0]]),
selected(&[f.messages[0], foreign]),
vec![
Target::ScenarioObject(f.messages[0]),
Target::ProcessOccurrence(OccurrenceEndpoint {
occurrence: f.messages[1],
references: vec![],
}),
],
selected(&[fragment, children[0], children[0]]),
selected(&[fragment, Id::nil()]),
] {
let original = f.project.clone();
assert!(prepare(&f.project, f.owner, &targets).is_err());
assert_eq!(f.project, original);
}
assert!(prepare(&f.project, f.work[0], &selected(&f.messages)).is_err());
}
#[test]
fn actual_list_reachability_and_complete_siblings_are_required() {
let mut f = fixture(Layer::Oa);
let (fragment, branches, children) = nested(&mut f);
let mut cases = Vec::new();
let mut missing_branch = f.project.clone();
let OccurrenceBody::Fragment {
branches: listed, ..
} = body(&mut missing_branch, fragment)
else {
panic!()
};
listed.clear();
cases.push(missing_branch);
let mut unlisted_child = f.project.clone();
unlisted_child
.elements
.get_mut(&branches[0])
.unwrap()
.steps_mut()
.unwrap()
.clear();
cases.push(unlisted_child);
let mut dangling_sibling = f.project.clone();
dangling_sibling
.elements
.get_mut(&branches[1])
.unwrap()
.steps_mut()
.unwrap()
.push(Id::new_v4());
cases.push(dangling_sibling);
let mut duplicate_sibling = f.project.clone();
duplicate_sibling
.elements
.get_mut(&branches[1])
.unwrap()
.steps_mut()
.unwrap()
.push(children[1]);
cases.push(duplicate_sibling);
let mut wrong_parent = f.project.clone();
wrong_parent.elements.get_mut(&children[0]).unwrap().parent = Some(branches[1]);
cases.push(wrong_parent);
for project in cases {
assert!(
prepare(&project, f.owner, &selected(&[fragment, children[0]])).is_err(),
"ancestor coverage never conceals an invalid original member/tree"
);
}
}
#[test]
fn invalid_baseline_cannot_be_silently_repaired_and_refusal_preserves_existing_redo() {
let f = fixture(Layer::Oa);
let mut invalid = f.project.clone();
let duplicate_alias = invalid.elements[&f.owner].alias.clone();
invalid.elements.get_mut(&f.messages[0]).unwrap().alias = duplicate_alias;
assert!(oa_core::session::validate_candidate(&invalid).is_err());
// The final deletion could repair this error, but the planner must admit a
// valid original baseline rather than quietly using deletion as repair.
assert!(execute_batch(&invalid, &[Command::Delete(f.messages[0])]).is_ok());
assert!(prepare(&invalid, f.owner, &selected(&[f.messages[0]])).is_err());
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
document
.execute(Command::Rename(f.messages[1], "Prior edit".into()))
.unwrap();
assert!(document.undo());
let before = document.bundle().clone();
let revision = document.revision();
assert!(
prepare(
document.project(),
f.owner,
&selected(&[f.messages[0], f.work[0]])
)
.is_err()
);
assert_eq!(document.bundle(), &before);
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
}
@@ -0,0 +1,179 @@
//! Frozen, all-or-nothing reorder of one immediate Scenario sequence.
use super::Slot;
use crate::diagram_usage::Target;
use oa_core::{Command, Id, Kind, OccurrenceBody, Project, SharedBody, execute_batch};
use std::{collections::BTreeSet, sync::Arc};
#[derive(Debug)]
pub(crate) enum Plan {
Noop,
Change(Box<Command>),
}
pub(crate) struct Prepared {
project: Arc<Project>,
parent: Id,
original: Vec<Id>,
selected: BTreeSet<Id>,
}
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
match project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. }
if matches!(
body,
OccurrenceBody::Exchange { .. }
| OccurrenceBody::Action { .. }
| OccurrenceBody::State { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Fragment { .. }
) =>
{
Some(body)
}
_ => None,
}
}
// Follow the same structural edges that make rows visible; parent pointers alone
// would admit children of an operand omitted from its fragment's branch list.
fn reachable_parent(project: &Project, owner: Id, wanted: Id) -> bool {
let mut pending = vec![owner];
let mut visited = BTreeSet::new();
while let Some(parent) = pending.pop() {
if !visited.insert(parent) {
continue;
}
if parent == wanted {
return true;
}
let Some(steps) = project.elements.get(&parent).and_then(|e| e.steps()) else {
continue;
};
for id in steps {
if project.elements.get(id).and_then(|e| e.parent) != Some(parent) {
continue;
}
if let Some(OccurrenceBody::Fragment { branches, .. }) = step_body(project, *id) {
pending.extend(branches.iter().copied().filter(|branch| {
project.elements.get(branch).is_some_and(|element| {
element.parent == Some(*id)
&& matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
}));
}
}
}
false
}
impl Prepared {
pub(crate) fn new(
project: Arc<Project>,
owner: Id,
targets: &[Target],
) -> Result<Self, String> {
if project.elements.get(&owner).map(|e| e.kind()) != Some(Kind::Scenario) {
return Err("This scenario no longer exists.".into());
}
let mut selected = BTreeSet::new();
let mut parent = None;
for target in targets {
let Target::ScenarioObject(id) = target else {
return Err("Select only ordered occurrences in this scenario.".into());
};
if !selected.insert(*id) || step_body(&project, *id).is_none() {
return Err(
"Every selected subject must be a unique ordered scenario occurrence.".into(),
);
}
let actual = project.elements[id]
.parent
.ok_or("The occurrence has no sequence.")?;
if parent.is_some_and(|parent| parent != actual) {
return Err(
"Reorder inside one immediate sequence; dragging never changes operands."
.into(),
);
}
parent = Some(actual);
}
let parent = parent.ok_or("Select at least one occurrence.")?;
if !reachable_parent(&project, owner, parent) {
return Err("The selected sequence is not reachable in this scenario.".into());
}
let original = project.elements[&parent]
.steps()
.ok_or("The parent is not a sequence.")?
.clone();
let mut listed = BTreeSet::new();
for id in &original {
if !listed.insert(*id)
|| step_body(&project, *id).is_none()
|| project.elements[id].parent != Some(parent)
{
return Err("The complete sequence must contain unique, existing occurrences of this parent.".into());
}
}
if !selected.is_subset(&listed) {
return Err("A selected occurrence is not in this sequence.".into());
}
// Cache a validated immutable baseline, including descendants and other
// sequences. Advisory authoring diagnostics deliberately remain allowed.
oa_core::session::validate_candidate(&project).map_err(|e| e.to_string())?;
Ok(Self {
project,
parent,
original,
selected,
})
}
pub(crate) fn plan(&self, slot: Slot) -> Result<Plan, String> {
if slot.parent != self.parent {
return Err("Reorder inside the occurrence's current sequence; dragging never changes its operand.".into());
}
if let Some(anchor) = slot.before {
if !self.original.contains(&anchor) {
return Err("The insertion slot changed. Choose its position again.".into());
}
if self.selected.contains(&anchor) {
return Ok(Plan::Noop);
}
}
let mut steps: Vec<_> = self
.original
.iter()
.copied()
.filter(|id| !self.selected.contains(id))
.collect();
let at = slot.before.map_or(steps.len(), |anchor| {
steps
.iter()
.position(|id| *id == anchor)
.expect("admitted anchor")
});
steps.splice(
at..at,
self.original
.iter()
.copied()
.filter(|id| self.selected.contains(id)),
);
if steps == self.original {
return Ok(Plan::Noop);
}
let command = Command::SetSteps(self.parent, steps);
execute_batch(&self.project, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(Plan::Change(Box::new(command)))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,720 @@
use super::*;
use crate::{behavior::topology::occurrence, document::Document};
use oa_core::{FragmentOperator, Layer, LifelineBinding, MessageKind, language};
fn add(p: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
let e = occurrence(p, parent, name, body);
let id = e.id;
p.elements.insert(id, e);
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn row(p: &mut Project, parent: Id, name: &str) -> Id {
add(
p,
parent,
name,
OccurrenceBody::Action {
action: None,
lifeline: None,
},
)
}
fn operand(p: &mut Project, parent: Id, operator: FragmentOperator) -> (Id, Id) {
let fragment = add(
p,
parent,
"Fragment",
OccurrenceBody::Fragment {
operator,
branches: vec![],
},
);
let branch = occurrence(
p,
fragment,
"Operand",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
let id = branch.id;
p.elements.insert(id, branch);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(id);
}
(fragment, id)
}
fn prepare(p: &Project, owner: Id, ids: &[Id]) -> Result<Prepared, String> {
Prepared::new(
Arc::new(p.clone()),
owner,
&ids.iter()
.copied()
.map(Target::ScenarioObject)
.collect::<Vec<_>>(),
)
}
fn slot(parent: Id, before: Option<Id>) -> Slot {
Slot { parent, before }
}
fn ordered(p: &Project, parent: Id, ids: &[Id]) -> Project {
let mut expected = p.clone();
*expected
.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap() = ids.to_vec();
expected
}
fn exact_transaction(p: &Project, plan: Plan, expected: Project) {
let Plan::Change(command) = plan else {
panic!("expected a change")
};
assert_eq!(
execute_batch(p, std::slice::from_ref(&command)).unwrap(),
expected
);
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let original_source = doc.bundle().clone();
doc.execute(*command).unwrap();
assert_eq!(doc.project(), &expected);
assert_eq!(
language::parse(&language::format(doc.project())).unwrap(),
expected
);
let changed_source = doc.bundle().clone();
assert_eq!(
Document::from_bundle(changed_source.clone(), Default::default())
.unwrap()
.project(),
&expected
);
assert!(doc.undo());
assert_eq!(doc.project(), p);
assert_eq!(doc.bundle(), &original_source);
assert!(!doc.can_undo());
assert!(doc.redo());
assert_eq!(doc.project(), &expected);
assert_eq!(doc.bundle(), &changed_source);
assert!(!doc.can_redo());
}
fn four(nested: bool) -> (Project, Id, Id, [Id; 4]) {
four_in_layer(nested, Layer::Oa)
}
fn four_in_layer(nested: bool, layer: Layer) -> (Project, Id, Id, [Id; 4]) {
let mut p = Project::empty();
let owner = p
.add_element(oa_core::Kind::Scenario, layer, "Scenario", None)
.unwrap();
let parent = if nested {
operand(&mut p, owner, FragmentOperator::Alternative).1
} else {
owner
};
let ids = ["A", "B", "C", "D"].map(|name| row(&mut p, parent, name));
oa_core::session::validate_candidate(&p).unwrap();
(p, owner, parent, ids)
}
#[test]
fn root_and_operand_use_semantic_block_order_with_exact_history() {
for (nested, layer) in [
(false, Layer::Oa),
(true, Layer::Oa),
(false, Layer::Sa),
(true, Layer::Sa),
] {
let (p, owner, parent, [a, b, c, d]) = four_in_layer(nested, layer);
let prepared = prepare(&p, owner, &[c, a]).unwrap();
exact_transaction(
&p,
prepared.plan(slot(parent, Some(d))).unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
exact_transaction(
&p,
prepared.plan(slot(parent, None)).unwrap(),
ordered(&p, parent, &[b, d, a, c]),
);
}
}
#[test]
fn singleton_wrapper_noop_preserves_existing_redo_and_source() {
for nested in [false, true] {
let (p, owner, parent, [a, b, c, d]) = four(nested);
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let change = super::super::reorder(doc.project(), owner, a, slot(parent, None)).unwrap();
doc.execute(change).unwrap();
assert_eq!(doc.project(), &ordered(&p, parent, &[b, c, d, a]));
let changed = doc.bundle().clone();
assert!(doc.undo());
let source = doc.bundle().clone();
let revision = doc.revision();
for before in [Some(a), Some(b)] {
let noop =
super::super::reorder(doc.project(), owner, a, slot(parent, before)).unwrap();
doc.execute(noop).unwrap();
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), &source);
assert_eq!(doc.revision(), revision);
assert!(!doc.can_undo());
assert!(doc.can_redo());
}
assert!(doc.redo());
assert_eq!(doc.bundle(), &changed);
}
}
#[test]
fn selected_anchor_and_exact_noop_preserve_redo_and_frozen_snapshot() {
let (p, owner, parent, [a, b, c, d]) = four(false);
let prepared = prepare(&p, owner, &[c, a]).unwrap();
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let Plan::Change(command) = prepared.plan(slot(parent, Some(d))).unwrap() else {
panic!()
};
doc.execute(*command).unwrap();
let changed = doc.bundle().clone();
assert!(doc.undo());
let source = doc.bundle().clone();
let revision = doc.revision();
for plan in [
prepared.plan(slot(parent, Some(c))),
prepare(&p, owner, &[a])
.unwrap()
.plan(slot(parent, Some(b))),
prepare(&p, owner, &[c, d])
.unwrap()
.plan(slot(parent, None)),
] {
assert!(matches!(plan.unwrap(), Plan::Noop));
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), &source);
assert_eq!(doc.revision(), revision);
assert!(doc.can_redo());
}
assert!(doc.redo());
assert_eq!(doc.bundle(), &changed);
// The earlier prepared value still plans from its original owned snapshot.
exact_transaction(
&p,
prepared.plan(slot(parent, Some(d))).unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
}
#[test]
fn admission_rejects_every_invalid_member_list_and_destination_before_noop() {
let (p, owner, parent, [a, b, c, d]) = four(false);
for ids in [vec![], vec![a, a], vec![a, Id::new_v4()]] {
assert!(prepare(&p, owner, &ids).is_err());
}
for target in [Target::Whole, Target::Placement(a)] {
assert!(
Prepared::new(
Arc::new(p.clone()),
owner,
&[Target::ScenarioObject(a), target]
)
.is_err()
);
}
let mut with_line = p.clone();
let line = with_line
.add_element(Kind::Lifeline, Layer::Oa, "Line", Some(owner))
.unwrap();
assert!(prepare(&with_line, owner, &[a, line]).is_err());
let mut nested = p.clone();
let (fragment, branch) = operand(&mut nested, owner, FragmentOperator::Alternative);
let child = row(&mut nested, branch, "Child");
assert!(prepare(&nested, owner, &[a, child]).is_err());
assert!(prepare(&nested, owner, &[fragment, child]).is_err());
let prepared = prepare(&p, owner, &[a, c]).unwrap();
assert!(prepared.plan(slot(branch, Some(a))).is_err());
assert!(prepared.plan(slot(parent, Some(Id::new_v4()))).is_err());
for list in [vec![a, b, c, b], vec![a, Id::new_v4(), c, d]] {
assert!(prepare(&ordered(&p, parent, &list), owner, &[a, c]).is_err());
}
let mut wrong_parent = p.clone();
wrong_parent.elements.get_mut(&b).unwrap().parent = Some(branch);
assert!(prepare(&wrong_parent, owner, &[a, c]).is_err());
for body in [
OccurrenceBody::Control {
kind: oa_core::ControlKind::Choice,
},
OccurrenceBody::SequenceReference { sequence: None },
] {
let mut wrong_type = p.clone();
if let Some(SharedBody::Occurrence { body: actual, .. }) =
wrong_type.elements.get_mut(&b).unwrap().shared_mut()
{
*actual = body;
}
assert!(prepare(&wrong_type, owner, &[a, c]).is_err());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = nested.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.clear();
}
assert!(
prepare(&nested, owner, &[child]).is_err(),
"parent ancestry cannot admit omitted operand"
);
}
#[test]
fn unlisted_warning_child_is_preserved_but_cannot_be_selected() {
let (mut p, owner, parent, [a, b, c, d]) = four(false);
let hidden = row(&mut p, parent, "Hidden draft");
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.pop();
assert!(
oa_core::validation::validate(&p)
.iter()
.any(|i| i.code == "unordered_occurrence")
);
assert!(prepare(&p, owner, &[hidden]).is_err());
exact_transaction(
&p,
prepare(&p, owner, &[c, a])
.unwrap()
.plan(slot(parent, Some(d)))
.unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
}
#[test]
fn call_reply_moves_as_explicit_stable_pair_and_invalid_baseline_refuses_noop() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
let prepared = prepare(&p, f.owner, &[f.reply, f.call]).unwrap();
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
);
assert!(
prepare(&p, f.owner, &[f.reply])
.unwrap()
.plan(slot(f.owner, Some(f.call)))
.unwrap_err()
.contains("reply_order")
);
let invalid = ordered(&p, f.owner, &[f.reply, x, f.call, y]);
assert!(prepare(&invalid, f.owner, &[x]).is_err());
}
#[test]
fn nested_fragment_descendant_reply_is_validated_and_subtree_preserved() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
p.elements.get_mut(&f.reply).unwrap().parent = Some(inner);
p.elements
.get_mut(&inner)
.unwrap()
.steps_mut()
.unwrap()
.push(f.reply);
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, fragment, y]);
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(f.call)))
.unwrap_err()
.contains("reply_order")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[f.call, y, x, fragment]),
);
}
#[test]
fn nested_lifetime_and_opaque_local_bindings_are_checked_without_expansion() {
let f = super::super::direct_tests::fixture();
let mut p = execute_batch(
&f.project,
&[Command::Delete(f.reply), Command::Delete(f.call)],
)
.unwrap();
let create = add(
&mut p,
f.owner,
"Create",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(f.sender),
receiver: Some(f.receiver),
message_kind: MessageKind::Create,
reply_to: None,
values: vec![],
operation: None,
},
);
let x = row(&mut p, f.owner, "X");
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
add(
&mut p,
inner,
"Use local",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let y = row(&mut p, f.owner, "Y");
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(create)))
.unwrap_err()
.contains("lifeline_lifetime")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[create, y, x, fragment]),
);
let definition = p
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
.unwrap();
let represented = match p.elements[&f.receiver].shared().unwrap() {
SharedBody::Lifeline { represented } => *represented,
_ => panic!(),
};
let remote = p
.add_element(Kind::Lifeline, Layer::Oa, "Formal", Some(definition))
.unwrap();
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
SharedBody::Lifeline { represented };
let uses = ["First use", "Second use"].map(|name| {
add(
&mut p,
f.owner,
name,
OccurrenceBody::ScenarioUse {
scenario: Some(definition),
bindings: vec![LifelineBinding {
referenced: remote,
local: f.receiver,
}],
},
)
});
let prepared = prepare(&p, f.owner, &[uses[1], uses[0]]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(create)))
.unwrap_err()
.contains("lifeline_lifetime")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, Some(x))).unwrap(),
ordered(&p, f.owner, &[create, uses[0], uses[1], x, fragment, y]),
);
}
fn delete_line(p: &mut Project, parent: Id, sender: Id, receiver: Id) {
add(
p,
parent,
"Delete participant",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(sender),
receiver: Some(receiver),
message_kind: MessageKind::Delete,
reply_to: None,
values: vec![],
operation: None,
},
);
}
fn second_operand(p: &mut Project, fragment: Id) -> Id {
let branch = occurrence(
p,
fragment,
"Other operand",
OccurrenceBody::Branch {
guard: "otherwise".into(),
occurrences: vec![],
},
);
let id = branch.id;
p.elements.insert(id, branch);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
else {
panic!()
};
branches.push(id);
id
}
#[test]
fn conditional_delete_rejects_moving_an_earlier_use_after_the_fragment() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let use_line = add(
&mut p,
f.owner,
"Use participant",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let x = row(&mut p, f.owner, "X");
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Alternative);
delete_line(&mut p, branch, f.sender, f.receiver);
row(&mut p, f.owner, "Y");
oa_core::session::validate_candidate(&p).unwrap();
let prepared = prepare(&p, f.owner, &[x, use_line]).unwrap();
assert!(
prepared
.plan(slot(f.owner, None))
.unwrap_err()
.contains("lifeline_lifetime")
);
}
#[test]
fn exclusive_operand_move_is_legal_but_parallel_baseline_is_invalid() {
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let (fragment, left) = operand(&mut p, f.owner, operator);
let right = second_operand(&mut p, fragment);
delete_line(&mut p, left, f.sender, f.receiver);
let use_line = add(
&mut p,
right,
"Use participant",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let x = row(&mut p, right, "X");
let y = row(&mut p, right, "Y");
if operator == FragmentOperator::Alternative {
oa_core::session::validate_candidate(&p).unwrap();
exact_transaction(
&p,
prepare(&p, f.owner, &[x, use_line])
.unwrap()
.plan(slot(right, None))
.unwrap(),
ordered(&p, right, &[y, use_line, x]),
);
} else {
// This is an invalid input control, not a valid model broken by reorder.
assert!(
oa_core::session::validate_candidate(&p)
.unwrap_err()
.to_string()
.contains("lifeline_lifetime")
);
assert!(prepare(&p, f.owner, &[x, use_line]).is_err());
}
}
}
#[test]
fn repeated_reply_warning_does_not_block_independent_sibling_reorder() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Loop);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
p.elements.get_mut(&f.reply).unwrap().parent = Some(branch);
p.elements
.get_mut(&branch)
.unwrap()
.steps_mut()
.unwrap()
.push(f.reply);
let x = row(&mut p, branch, "X");
let y = row(&mut p, branch, "Y");
let z = row(&mut p, branch, "Z");
let warning = |project: &Project| {
oa_core::validation::validate(project).iter().any(|issue| {
issue.element == Some(f.reply)
&& issue.code == "reply_repetition"
&& issue.severity == oa_core::validation::Severity::Warning
})
};
assert!(warning(&p));
let expected = ordered(&p, branch, &[f.reply, y, x, z]);
assert!(warning(&expected));
exact_transaction(
&p,
prepare(&p, f.owner, &[z, x])
.unwrap()
.plan(slot(branch, None))
.unwrap(),
expected,
);
}
#[test]
fn semantic_reorder_preserves_affected_and_unrelated_authored_layouts() {
use oa_core::{
Diagram, DiagramLayout, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition,
ScenarioLayout, ScenarioMessagePosition,
};
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
for (alias, offset) in [("group_saved_a", 0.), ("group_saved_b", 1500.)] {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: "Preserve my layout".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: f.sender,
x: -300. + offset,
},
ScenarioLanePosition {
lifeline: f.receiver,
x: 700. + offset,
},
],
messages: vec![
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: f.call,
},
send_y: 123. + offset,
},
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: f.reply,
},
send_y: 567. + offset,
},
],
})),
},
);
}
let unrelated = p
.add_element(Kind::Scenario, Layer::Oa, "Unrelated scenario", None)
.unwrap();
let line = p
.add_element(
Kind::Lifeline,
Layer::Oa,
"Unrelated clock",
Some(unrelated),
)
.unwrap();
let timer = add(
&mut p,
unrelated,
"Wait",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(line),
receiver: None,
message_kind: MessageKind::Timer {
expression: "5 s".into(),
},
reply_to: None,
values: vec![],
operation: None,
},
);
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "group_unrelated_saved".into(),
name: "Unrelated arrangement".into(),
description: "Do not touch this view".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario {
scenario: unrelated,
},
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: line,
x: -123.,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: timer,
},
send_y: 987.,
}],
})),
},
);
oa_core::session::validate_candidate(&p).unwrap();
exact_transaction(
&p,
prepare(&p, f.owner, &[f.reply, f.call])
.unwrap()
.plan(slot(f.owner, None))
.unwrap(),
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
);
}
@@ -218,6 +218,7 @@ fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestu
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
selection_generation: 0,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
@@ -0,0 +1,241 @@
//! Immutable semantic reorder operation, independent of its destination UI.
use super::{
Context, Slot,
group_reorder::{Plan, Prepared},
presentation, selection,
};
use crate::{diagram_selection::Snapshot, diagram_usage::Target};
use oa_core::{Command, Id, Project, execute_batch};
use std::sync::Arc;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Change,
Noop,
Refused(String),
}
pub(crate) struct Preview {
pub(crate) slot: Slot,
pub(crate) before: Vec<Id>,
pub(crate) after: Vec<Id>,
pub(crate) outcome: Outcome,
/// Geometry status is independent of semantic validity; requested Saved
/// representation is retained even when presentation reports fallback.
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
pub(crate) selection_visible: bool,
command: Option<Box<Command>>,
}
pub(crate) struct Completion {
pub(crate) command: Option<Box<Command>>,
pub(crate) snapshot: Snapshot,
pub(crate) representation: super::Representation,
pub(crate) presentation_status: presentation::PresentationStatus,
}
pub(crate) struct Session {
project: Arc<Project>,
context: Context,
snapshot: Snapshot,
token: Id,
primary: Id,
parent: Id,
before: Vec<Id>,
prepared: Prepared,
preview: Option<Preview>,
#[cfg(test)]
proposal_builds: usize,
}
impl Session {
pub(crate) fn new(
project: Arc<Project>,
context: Context,
snapshot: Snapshot,
token: Id,
) -> Result<Self, String> {
if token.is_nil()
|| context.document.is_nil()
|| context.visit.is_nil()
|| snapshot.context() != selection::context(context)
|| presentation::checked_owner(&project, context.representation)
!= Some(context.scenario)
{
return Err("The reorder input no longer matches this scenario view.".into());
}
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
return Err("Select ordered scenario occurrences.".into());
};
let primary = *primary;
let visible = selection::visible(&project, context, primary);
if !snapshot
.targets()
.contains(&Target::ScenarioObject(primary))
|| snapshot
.targets()
.iter()
.any(|target| !visible.contains(target))
{
return Err("Every selected occurrence must be visible in this scenario view.".into());
}
let prepared = Prepared::new(project.clone(), context.scenario, snapshot.targets())?;
let parent = project.elements[&primary]
.parent
.expect("prepared occurrence parent");
let before = project.elements[&parent]
.steps()
.expect("prepared sequence")
.clone();
Ok(Self {
project,
context,
snapshot,
token,
primary,
parent,
before,
prepared,
preview: None,
#[cfg(test)]
proposal_builds: 0,
})
}
pub(crate) fn context(&self) -> Context {
self.context
}
pub(crate) fn token(&self) -> Id {
self.token
}
pub(crate) fn snapshot(&self) -> &Snapshot {
&self.snapshot
}
pub(crate) fn parent(&self) -> Id {
self.parent
}
pub(crate) fn before(&self) -> &[Id] {
&self.before
}
pub(crate) fn current_preview(&self) -> Option<&Preview> {
self.preview.as_ref()
}
#[cfg(test)]
pub(crate) fn current_preview_mut(&mut self) -> Option<&mut Preview> {
self.preview.as_mut()
}
pub(crate) fn matches(
&self,
current: &Project,
context: Context,
snapshot: &Snapshot,
token: Id,
) -> bool {
current == self.project.as_ref()
&& context == self.context
&& snapshot == &self.snapshot
&& token == self.token
}
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
if self
.preview
.as_ref()
.is_none_or(|preview| preview.slot != slot)
{
#[cfg(test)]
{
self.proposal_builds += 1;
}
let result = self.prepared.plan(slot).and_then(|plan| match plan {
Plan::Noop => Ok((None, self.project.clone())),
Plan::Change(command) => {
let candidate =
execute_batch(&self.project, std::slice::from_ref(command.as_ref()))
.map_err(|error| error.to_string())?;
Ok((Some(command), Arc::new(candidate)))
}
});
let preview = match result {
Ok((command, candidate)) => {
let shown = presentation::present(
&candidate,
self.context.representation,
self.primary,
);
let visible = shown
.scene()
.map_or_else(Vec::new, |scene| scene.selection_targets());
let selection_visible = self
.snapshot
.targets()
.iter()
.all(|target| visible.contains(target));
Preview {
slot,
before: self.before.clone(),
after: candidate.elements[&self.parent]
.steps()
.expect("validated sequence")
.clone(),
outcome: if command.is_some() {
Outcome::Change
} else {
Outcome::Noop
},
presentation: Some(shown),
selection_visible,
command,
}
}
Err(reason) => Preview {
slot,
before: self.before.clone(),
after: self.before.clone(),
outcome: Outcome::Refused(reason),
presentation: None,
selection_visible: false,
command: None,
},
};
self.preview = Some(preview);
}
self.preview.as_ref().expect("explicit destination preview")
}
/// Consume once; never execute a Document transaction or alter selection.
pub(crate) fn finish(
self,
current: &Project,
context: Context,
snapshot: &Snapshot,
token: Id,
) -> Result<Completion, String> {
if !self.matches(current, context, snapshot, token) {
return Err("The reorder input changed; choose the operation again.".into());
}
let preview = self
.preview
.ok_or("Choose an explicit destination before applying reorder.")?;
if let Outcome::Refused(reason) = preview.outcome {
return Err(reason);
}
let presentation_status = preview
.presentation
.expect("valid plan presentation")
.status()
.clone();
Ok(Completion {
command: preview.command,
snapshot: self.snapshot,
representation: self.context.representation,
presentation_status,
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,354 @@
use super::*;
use crate::{
behavior::{
scenario::{Representation, direct_tests},
topology::occurrence,
},
diagram_selection::Selection,
};
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
fn row(project: &mut Project, parent: Id, name: &str) -> Id {
let element = occurrence(
project,
parent,
name,
OccurrenceBody::Action {
action: None,
lifeline: None,
},
);
let id = element.id;
project.elements.insert(id, element);
project
.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn snapshot(context: Context, ids: &[Id]) -> Snapshot {
let mut selection = Selection::new(selection::context(context).scope);
for id in ids {
selection.add(Target::ScenarioObject(*id), |_| true);
}
selection.snapshot(selection::context(context)).unwrap()
}
fn session(project: &Project, context: Context, ids: &[Id]) -> Session {
Session::new(
Arc::new(project.clone()),
context,
snapshot(context, ids),
Id::new_v4(),
)
.unwrap()
}
#[test]
fn explicit_destination_cache_and_exact_whole_model_saved_presentation() {
for mode in 0..3 {
let f = direct_tests::fixture();
let mut project = f.project;
let x = row(&mut project, f.owner, "X");
let y = row(&mut project, f.owner, "Y");
let mut context = direct_tests::context(f.owner);
for name in ["A", "B"] {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: name.into(),
name: name.into(),
description: "Keep this view".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![oa_core::ScenarioLanePosition {
lifeline: f.sender,
x: if name == "A" { 180. } else { 220. },
}],
messages: [(f.call, 600.), (f.reply, 800.)]
.into_iter()
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
})
.collect(),
})),
},
);
if mode != 0 && name == "A" {
context.representation = Representation::Saved { diagram: id };
if mode == 2 {
project.diagrams.get_mut(&id).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: [(f.call, 600.), (f.reply, 200.)]
.into_iter()
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
})
.collect(),
}));
}
}
}
let mut operation = session(&project, context, &[f.reply, f.call]);
assert_eq!(operation.parent(), f.owner);
assert_eq!(operation.before(), &[f.call, f.reply, x, y]);
assert_eq!(operation.context(), context);
let original_snapshot = operation.snapshot().clone();
let token = operation.token();
let slot = Slot {
parent: f.owner,
before: None,
};
let preview = operation.preview(slot);
assert_eq!(preview.before, [f.call, f.reply, x, y]);
assert_eq!(preview.after, [x, y, f.call, f.reply]);
assert_eq!(preview.outcome, Outcome::Change);
assert_eq!(
preview.presentation.as_ref().unwrap().requested(),
context.representation
);
assert_eq!(operation.proposal_builds, 1);
operation.preview(slot);
assert_eq!(operation.proposal_builds, 1);
operation.preview(Slot {
parent: f.owner,
before: Some(f.call),
});
assert_eq!(operation.proposal_builds, 2);
operation.preview(slot);
assert_eq!(operation.proposal_builds, 3);
assert_eq!(operation.preview.as_ref().unwrap().slot, slot);
let completion = operation
.finish(&project, context, &original_snapshot, token)
.unwrap();
assert_eq!(completion.snapshot, original_snapshot);
assert_eq!(completion.representation, context.representation);
if mode == 2 {
assert!(matches!(
completion.presentation_status,
presentation::PresentationStatus::Fallback { .. }
));
} else {
assert!(matches!(
completion.presentation_status,
presentation::PresentationStatus::Ready
));
}
let mut expected = project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![x, y, f.call, f.reply];
assert_eq!(
execute_batch(&project, &[*completion.command.unwrap()]).unwrap(),
expected
);
assert_eq!(expected.diagrams, project.diagrams);
}
}
#[test]
fn no_destination_is_not_noop_and_selected_anchor_is_explicit_noop() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut operation = session(&f.project, context, &[f.call, f.reply]);
let captured = operation.snapshot().clone();
let token = operation.token();
assert!(
operation
.finish(&f.project, context, &captured, token)
.is_err()
);
operation = session(&f.project, context, &[f.call, f.reply]);
let token = operation.token();
assert_eq!(
operation
.preview(Slot {
parent: f.owner,
before: Some(f.reply)
})
.outcome,
Outcome::Noop
);
assert!(
operation
.finish(&f.project, context, &captured, token)
.unwrap()
.command
.is_none()
);
}
#[test]
fn temporal_candidate_refuses_without_changing_baseline_or_selection() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut project = f.project;
let later = row(&mut project, f.owner, "Later action");
let mut operation = session(&project, context, &[f.reply]);
let captured = operation.snapshot().clone();
let token = operation.token();
// A previously valid proposal must not survive a new refused destination.
let valid = operation.preview(Slot {
parent: f.owner,
before: None,
});
assert_eq!(valid.outcome, Outcome::Change);
assert_eq!(valid.after, [f.call, later, f.reply]);
let preview = operation.preview(Slot {
parent: f.owner,
before: Some(f.call),
});
assert!(matches!(&preview.outcome, Outcome::Refused(reason) if reason.contains("reply_order")));
assert!(preview.presentation.is_none());
assert_eq!(preview.before, preview.after);
assert!(
operation
.finish(&project, context, &captured, token)
.is_err()
);
}
#[test]
fn mixed_cross_parent_and_unrendered_sets_never_reduce_to_primary() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut project = f.project;
let other = project
.add_element(oa_core::Kind::Scenario, Layer::Oa, "Other", None)
.unwrap();
let foreign = row(&mut project, other, "Foreign");
let fragment = occurrence(
&project,
f.owner,
"Fragment",
OccurrenceBody::Fragment {
operator: oa_core::FragmentOperator::Alternative,
branches: vec![],
},
);
let fragment_id = fragment.id;
project.elements.insert(fragment_id, fragment);
project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(fragment_id);
let branch = occurrence(
&project,
fragment_id,
"Branch",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
let branch_id = branch.id;
project.elements.insert(branch_id, branch);
if let Some(oa_core::SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = project.elements.get_mut(&fragment_id).unwrap().shared_mut()
{
branches.push(branch_id);
}
let nested = row(&mut project, branch_id, "Nested");
assert!(
selection::visible(&project, context, f.call).contains(&Target::ScenarioObject(nested))
);
for ids in [
vec![f.sender, f.call],
vec![nested, f.call],
vec![foreign, f.call],
vec![Id::new_v4(), f.call],
] {
assert!(
Session::new(
Arc::new(project.clone()),
context,
snapshot(context, &ids),
Id::new_v4()
)
.is_err()
);
}
}
#[test]
fn stale_finish_checks_every_immutable_input_and_constructor_rejects_nil_lifetimes() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
for altered in 0..8 {
let mut operation = session(&f.project, context, &[f.call, f.reply]);
let mut token = operation.token();
let mut captured = operation.snapshot().clone();
let mut current = f.project.clone();
let mut live = context;
operation.preview(Slot {
parent: f.owner,
before: None,
});
match altered {
0 => {
current.elements.get_mut(&f.call).unwrap().name.push('!');
}
1 => live.epoch += 1,
2 => captured = snapshot(context, &[f.reply, f.call]),
3 => token = Id::new_v4(),
4 => live.visit = Id::new_v4(),
5 => live.document = Id::new_v4(),
6 => live.revision += 1,
_ => {
let mut selection = Selection::new(selection::context(context).scope);
selection.add(Target::ScenarioObject(f.call), |_| true);
selection.add(Target::ScenarioObject(f.reply), |_| true);
selection.set_primary(&Target::ScenarioObject(f.call));
selection.set_primary(&Target::ScenarioObject(f.reply));
captured = selection.snapshot(selection::context(context)).unwrap();
assert_eq!(captured.targets(), operation.snapshot().targets());
assert_eq!(captured.primary(), operation.snapshot().primary());
assert_ne!(captured.generation(), operation.snapshot().generation());
}
}
assert!(operation.finish(&current, live, &captured, token).is_err());
}
for altered in 0..3 {
let mut live = context;
let mut token = Id::new_v4();
match altered {
0 => live.document = Id::nil(),
1 => live.visit = Id::nil(),
_ => token = Id::nil(),
}
assert!(
Session::new(
Arc::new(f.project.clone()),
live,
snapshot(live, &[f.call]),
token
)
.is_err()
);
}
}
@@ -0,0 +1,234 @@
//! Candidate-input experiment, not an accepted new interaction policy.
//! Exercise production presentation, nearest-Y picking, and exact semantic slots.
use super::{Representation, Slot, group_reorder, presentation};
use crate::{behavior::Scene, diagram_usage::Target};
use iced::Point;
use oa_core::{
Diagram, DiagramLayout, Id, OccurrenceEndpoint, Project, ProjectionKind, ScenarioLayout,
ScenarioMessagePosition, execute_batch, language, validation,
};
use std::sync::Arc;
struct Fixture {
project: Project,
owner: Id,
diagram: Id,
ids: [Id; 4],
}
fn fixture(ys: [f32; 4]) -> Fixture {
let mut project = language::parse(SOURCE).unwrap();
let named = |name: &str| {
project
.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let owner = named("conversation");
let ids = [named("a"), named("b"), named("c"), named("d")];
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Authored coordinates".into(),
description: String::new(),
layer: project.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: ids
.into_iter()
.zip(ys)
.map(|(occurrence, send_y)| ScenarioMessagePosition {
address: OccurrenceEndpoint {
occurrence,
references: vec![],
},
send_y,
})
.collect(),
})),
},
);
assert!(
validation::validate(&project)
.iter()
.all(|issue| issue.severity != validation::Severity::Error)
);
Fixture {
project,
owner,
diagram,
ids,
}
}
fn shown(f: &Fixture) -> presentation::ScenarioPresentation {
let shown = presentation::present(
&f.project,
Representation::Saved { diagram: f.diagram },
f.ids[0],
);
assert_eq!(shown.status(), &presentation::PresentationStatus::Ready);
assert_eq!(shown.requested(), shown.geometry_representation().unwrap());
shown
}
fn before(f: &Fixture, id: Id) -> Slot {
Slot {
parent: f.owner,
before: Some(id),
}
}
fn y(scene: &Scene, slot: Slot) -> f32 {
scene.slots.iter().find(|item| item.slot == slot).unwrap().y
}
fn picked(scene: &Scene, f: &Fixture, world_y: f32) -> Slot {
scene
.slot_at(Point::new(100., world_y), Some(f.owner))
.unwrap()
.slot
}
fn exact_change(f: &Fixture, selected: &[Id], slot: Slot, order: &[Id]) {
let prepared = group_reorder::Prepared::new(
Arc::new(f.project.clone()),
f.owner,
&selected
.iter()
.copied()
.map(Target::ScenarioObject)
.collect::<Vec<_>>(),
)
.unwrap();
let group_reorder::Plan::Change(command) = prepared.plan(slot).unwrap() else {
panic!("expected an actual semantic change")
};
let actual = execute_batch(&f.project, std::slice::from_ref(command.as_ref())).unwrap();
let mut expected = f.project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = order.to_vec();
// Literal full-model oracle: every authored position and other field survives.
assert_eq!(actual, expected);
assert_eq!(
language::parse(&language::format(&actual)).unwrap(),
expected
);
assert_eq!(
presentation::present(
&actual,
Representation::Saved { diagram: f.diagram },
selected[0]
)
.status(),
&presentation::PresentationStatus::Ready
);
}
#[test]
fn crossed_saved_slots_are_reachable_but_screen_order_is_not_semantic_order() {
let f = fixture([200., 500., 300., 400.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(
f.ids.map(|id| y(scene, before(&f, id))),
[158., 458., 258., 358.]
);
let mut spatial = f.ids.to_vec();
spatial.sort_by(|a, b| y(scene, before(&f, *a)).total_cmp(&y(scene, before(&f, *b))));
assert_eq!(spatial, [a, c, d, b]);
for id in f.ids {
assert_eq!(picked(scene, &f, y(scene, before(&f, id))), before(&f, id));
}
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
}
#[test]
fn equal_saved_slots_hide_one_destination_while_exact_chooser_can_select_it() {
let f = fixture([200., 300., 300., 500.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, b)), 258.);
assert_eq!(y(scene, before(&f, c)), 258.);
assert!(
scene
.slots
.iter()
.position(|item| item.slot == before(&f, b))
< scene
.slots
.iter()
.position(|item| item.slot == before(&f, c))
);
// The two distances are identical for every finite Y; min_by keeps the first
// tie. The sample exercises that production behavior, not an exhaustive proof.
for quarter_pixel in -4000..=12000 {
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, c));
}
assert_eq!(picked(scene, &f, 258.), before(&f, b));
exact_change(&f, &[d, a], before(&f, c), &[b, a, d, c]);
exact_change(&f, &[d, a], picked(scene, &f, 258.), &[a, d, b, c]);
}
#[test]
fn close_saved_slots_have_subpixel_hit_regions_without_becoming_unreachable_continuously() {
let f = fixture([200., 300., 300.125, 300.25]);
let [_, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, b)), 258.);
assert_eq!(y(scene, before(&f, c)), 258.125);
assert_eq!(y(scene, before(&f, d)), 258.25);
assert_eq!(picked(scene, &f, 258.125), before(&f, c));
// Fixed scale=1/pan=0: C's open Voronoi interval is only
// (258.0625, 258.1875). Integer-pixel samples miss it. This does not assert
// that all platforms quantize pointer input to integer coordinates.
for screen_y in 0..=600 {
assert_ne!(picked(scene, &f, screen_y as f32), before(&f, c));
}
}
#[test]
fn offscreen_saved_slot_needs_camera_change_or_nonspatial_destination_input() {
let f = fixture([200., 300., 400., 2000.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, d)), 1958.);
// Fixed scale=1/pan=0 and a 600px-high canvas. The C/D midpoint is
// 1158, beyond the whole viewport, so D cannot win within that viewport.
for quarter_pixel in 0..=2400 {
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, d));
}
assert_eq!(picked(scene, &f, 1958.), before(&f, d));
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
}
const SOURCE: &str = r#"arcadia 7;
project slot_probe "Slot probe" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000004" { viewpoint OperationalActivity; steps [@a, @b, @c, @d]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000005" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @target; }
oa_occurrence a "A" id "71000000-0000-4000-8000-000000000007" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence b "B" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence c "C" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence d "D" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
}
"#;
@@ -0,0 +1,323 @@
//! Identity-based destination choice, independent of saved screen coordinates.
use super::*;
use crate::app::scenario_reorder::{Event, Intent};
use crate::behavior::scenario_layout::presentation::PresentationStatus;
use iced::widget::{column, opaque, scrollable};
const PAGE_SIZE: usize = 5;
pub(crate) fn query_id(token: Id) -> iced::widget::Id {
iced::widget::Id::from(format!("scenario-reorder-query:{token}"))
}
pub(crate) fn focus_query(token: Id) -> iced::Task<Message> {
use iced::advanced::widget::{Operation, operation};
struct Find {
target: iced::widget::Id,
present: bool,
}
impl Operation for Find {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&iced::widget::Id>,
_: iced::Rectangle,
_: &mut dyn operation::Focusable,
) {
self.present |= id == Some(&self.target);
}
fn finish(&self) -> operation::Outcome<()> {
if self.present {
operation::Outcome::Chain(Box::new(operation::focusable::focus(
self.target.clone(),
)))
} else {
operation::Outcome::None
}
}
}
// Generic focus would blur every current widget if the old token vanished.
// Discover the exact target without changing focus before chaining it.
iced::advanced::widget::operate(Find {
target: query_id(token),
present: false,
})
.discard()
}
pub(crate) fn apply_message(app: &App) -> Option<Message> {
if !app.scenario_reorder_enabled() {
return None;
}
let session = app.scenario_reorder.session.as_ref()?;
let preview = session.current_preview()?;
(preview.selection_visible
&& !matches!(preview.outcome, reorder_session::Outcome::Refused(_))
&& matches!(
preview.presentation.as_ref()?.status(),
PresentationStatus::Ready | PresentationStatus::Fallback { .. }
))
.then(|| message(session, Intent::Finish(preview.slot)))
}
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
Message::ScenarioReorder(Box::new(Event {
context: session.context(),
snapshot: session.snapshot().clone(),
token: session.token(),
intent,
}))
}
pub(crate) fn begin(app: &App) -> Option<Message> {
(app.scenario_reorder_enabled() && app.scenario_reorder.session.is_none()).then_some(())?;
let snapshot = app.multiselect_snapshot()?;
if snapshot.targets().is_empty() {
return None;
}
Some(Message::ScenarioReorder(Box::new(Event {
context: app.scenario_context()?,
snapshot,
token: Id::new_v4(),
intent: Intent::Begin,
})))
}
fn label(p: &Project, id: Id) -> String {
p.elements.get(&id).map_or_else(
|| id.to_string(),
|e| format!("{} (@{})", short(&e.name, 38), e.alias),
)
}
fn destination(p: &Project, before: &[Id], slot: Slot) -> String {
slot.before.map_or_else(
|| "End of sequence".into(),
|id| {
let position = before
.iter()
.position(|item| *item == id)
.map_or(0, |i| i + 1);
format!("Before {position}. {}", label(p, id))
},
)
}
fn order(p: &Project, ids: &[Id], snapshot: &crate::diagram_selection::Snapshot) -> String {
// A bounded neighborhood keeps the moved block and its immediate neighbors
// visible. Ellipses explicitly mark omitted, unchanged sequence members.
let selected = |id: &Id| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(*id))
};
let mut parts = Vec::new();
let mut omitted = false;
for (index, id) in ids.iter().enumerate() {
let near = selected(id)
|| index.checked_sub(1).is_some_and(|i| selected(&ids[i]))
|| ids.get(index + 1).is_some_and(selected);
if !near {
omitted = true;
continue;
}
if omitted {
parts.push("…".into());
omitted = false;
}
parts.push(format!(
"{}{}. {}",
if selected(id) { "• " } else { "" },
index + 1,
label(p, *id)
));
}
if omitted {
parts.push("…".into());
}
parts.join("\n")
}
pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Message>> {
let session = app.scenario_reorder.session.as_ref()?;
let p = app.project();
let state = &app.scenario_reorder;
let query = state.query.to_lowercase();
let slots = session
.before()
.iter()
.copied()
.map(Some)
.chain(std::iter::once(None))
.enumerate()
.map(|(index, before)| {
let slot = Slot {
parent: session.parent(),
before,
};
let title = before.map_or_else(
|| "End of sequence".into(),
|id| format!("Before {}. {}", index + 1, label(p, id)),
);
(slot, title)
})
.filter(|(slot, title)| {
title.to_lowercase().contains(&query)
|| slot
.before
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| {
e.name.to_lowercase().contains(&query)
|| e.alias.to_lowercase().contains(&query)
})
})
.collect::<Vec<_>>();
let pages = slots.len().div_ceil(PAGE_SIZE).max(1);
let page = state.page.min(pages - 1);
let mut choices = column![].spacing(4).width(Fill);
for (slot, title) in slots.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
choices = choices.push(
button(
text(title.clone())
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
)
.width(Fill)
.on_press(message(session, Intent::Choose(*slot))),
);
}
if slots.is_empty() {
choices = choices
.push(text("No matching destination. Try its name, alias or position.").size(12));
}
let mut body = column![
text(format!(
"Reorder {} selected",
session.snapshot().targets().len()
))
.size(17),
text(format!("Sequence: {}", label(p, session.parent())))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
text("Changes execution order; saved positions stay as authored.").size(12),
text_input("Find destination by name, alias or position", &state.query)
.id(query_id(session.token()))
.on_input(move |query| message(session, Intent::Query(query)))
.on_submit_maybe(
(slots.len() == 1).then(|| message(session, Intent::Choose(slots[0].0)))
)
.size(13),
choices,
row![
button("Previous")
.on_press_maybe((page > 0).then(|| message(session, Intent::Page(page - 1)))),
text(format!("{} / {pages}", page + 1)).size(12),
button("Next").on_press_maybe(
(page + 1 < pages).then(|| message(session, Intent::Page(page + 1)))
)
]
.spacing(8),
]
.spacing(8)
.width(Fill);
let mut apply = None;
let mut footer =
column![text("Enter previews a unique match. Ctrl+Enter applies; Esc cancels.").size(12)]
.spacing(6);
if let Some(preview) = session.current_preview() {
footer = footer.push(
text(format!(
"Destination: {}",
destination(p, session.before(), preview.slot)
))
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
let comparison = row![
container(
column![
text("Before").size(13),
text(order(p, &preview.before, session.snapshot()))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
]
.width(Fill)
)
.width(Fill)
.id("scenario-reorder-before"),
container(
column![
text("After • selected").size(13),
text(order(p, &preview.after, session.snapshot()))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
]
.width(Fill)
)
.width(Fill)
.id("scenario-reorder-after")
]
.spacing(10)
.width(Fill);
body = body.push(comparison);
let feedback = match &preview.outcome {
reorder_session::Outcome::Refused(reason) => reason.clone(),
_ if !preview.selection_visible => {
"This destination would hide a selected occurrence; choose another destination."
.into()
}
_ => match preview.presentation.as_ref().map(|shown| shown.status()) {
Some(PresentationStatus::Unavailable { .. }) | None => {
"This order cannot be displayed; choose another destination.".into()
}
Some(status) => {
apply = Some(message(session, Intent::Finish(preview.slot)));
if matches!(status, PresentationStatus::Fallback { .. }) {
"Apply will display an automatic arrangement. Your requested saved view and authored positions are retained; they are not repaired.".into()
} else if matches!(preview.outcome, reorder_session::Outcome::Noop) {
"This destination keeps the current order; Apply will close without an edit.".into()
} else {
"The selected occurrences move together in their existing order. One Undo restores the sequence.".into()
}
}
},
};
footer = footer.push(text(feedback).size(12));
} else {
body = body.push(text("Choose a destination to preview the new sequence.").size(12));
}
let height = viewport.map_or(500., |size| (size.height - 32.).max(100.));
let card = column![
scrollable(body)
.height(Fill)
.width(Fill)
.spacing(8)
.id("scenario-reorder-body"),
footer,
row![
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
button("Apply order (Ctrl+Enter)").on_press_maybe(apply)
]
.spacing(8)
]
.spacing(10);
Some(
container(opaque(
container(card)
.padding(12)
.width(480)
.height(height.min(630.))
.style(style::card),
))
.align_right(Fill)
.padding(8)
.into(),
)
}
@@ -0,0 +1,54 @@
//! Captured exact Scenario selection input; membership grants no movement permission.
use super::{Context, presentation};
use crate::{
app::multiselect::Choice,
diagram_selection,
diagram_usage::{Surface, Target},
};
use oa_core::{Id, Project};
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub context: Context,
pub snapshot: diagram_selection::Snapshot,
pub intent: Intent,
}
#[derive(Debug, Clone)]
pub(crate) enum Intent {
Choose(Target, Choice),
Clear,
Collapse(Target),
Refuse(String),
}
pub(crate) fn context(context: Context) -> diagram_selection::Context {
diagram_selection::Context {
scope: diagram_selection::Scope {
document: context.document,
surface: match context.representation {
super::Representation::Derived { owner } => Surface::Scenario(owner),
super::Representation::Saved { diagram } => Surface::Saved(diagram),
},
},
revision: context.revision,
epoch: context.epoch,
}
}
pub(crate) fn visible(project: &Project, context: Context, selected: Id) -> Vec<Target> {
let shown = presentation::present(project, context.representation, selected);
shown
.scene()
.map_or_else(Vec::new, |scene| scene.selection_targets())
}
pub(crate) fn message(
context: Context,
snapshot: &diagram_selection::Snapshot,
intent: Intent,
) -> crate::app::Message {
crate::app::Message::ScenarioCanvas(super::CanvasEvent::Selection(Box::new(Event {
context,
snapshot: snapshot.clone(),
intent,
})))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,268 @@
use super::*;
use crate::behavior::{
Scene, SequenceInput,
scenario::{self, CanvasEvent, CanvasTool},
};
use crate::diagram_selection::Selection;
use iced::{
Point, Rectangle, Vector, mouse,
widget::canvas::{self, Program},
};
fn fixture(group: bool) -> (scenario::direct_tests::Fixture, Scene, Point) {
let f = scenario::direct_tests::fixture();
let context = scenario::direct_tests::context(f.owner);
let mut scene = Scene::sequence(&f.project, f.owner, f.reply);
scene.interaction = Some((context, CanvasTool::Select, true));
let mut selection = Selection::new(super::context(context).scope);
selection.add(Target::ScenarioObject(f.call), |_| true);
if group {
selection.add(Target::ScenarioObject(f.reply), |_| true);
}
scene.selection = Some((
context,
selection.snapshot(super::context(context)).unwrap(),
));
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = line.label_at + Vector::new(8., 8.);
assert_eq!(scene.hit(point), Some(f.call));
(f, scene, point)
}
fn send(
scene: &Scene,
state: &mut SequenceInput,
event: canvas::Event,
point: Option<Point>,
) -> Option<CanvasEvent> {
scene
.update(
state,
&event,
Rectangle::new(Point::ORIGIN, scene.size),
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
)
.and_then(|action| action.into_inner().0)
.and_then(|message| match message {
crate::app::Message::ScenarioCanvas(event) => Some(event),
_ => None,
})
}
fn mouse_event(event: mouse::Event) -> canvas::Event {
canvas::Event::Mouse(event)
}
#[test]
fn saved_group_body_press_emits_one_spacing_lifetime_without_collapsing_selection() {
use crate::behavior::scenario::spacing::{self, Event};
use crate::behavior::scenario_layout::{Representation, resolve};
let (doc, owner, diagram, _, first) = spacing::controller::tests::fixture();
let second = doc
.project()
.identities()
.into_iter()
.find(|(_, a)| *a == "second")
.unwrap()
.0;
let mut context = scenario::direct_tests::context(owner);
context.representation = Representation::Saved { diagram };
let mut scene = resolve(doc.project(), context.representation, second)
.unwrap()
.scene;
let mut selection = Selection::new(super::context(context).scope);
selection.add(Target::ScenarioObject(first), |_| true);
selection.add(Target::ScenarioObject(second), |_| true);
let snapshot = selection.snapshot(super::context(context)).unwrap();
let stamp = spacing::Stamp {
context,
camera_epoch: 9,
selection_generation: snapshot.generation(),
};
scene.selection = Some((context, snapshot.clone()));
scene.selected = second;
scene.interaction = Some((context, CanvasTool::Select, true));
scene.coordinate_input = true;
scene.spacing_stamp = Some(stamp);
let line = scene.lines.iter().find(|line| line.id == first).unwrap();
let point = Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y);
assert_eq!(scene.hit(point), Some(first));
let mut input = SequenceInput::default();
let Some(CanvasEvent::Spacing(Event::Begin {
stamp: got,
token,
target,
..
})) = send(
&scene,
&mut input,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
)
else {
panic!("group spacing begins on first press")
};
assert_eq!(got, stamp);
assert_eq!(target, spacing::Target::Message(first));
assert!(input.group_press.is_none());
let moved = point + Vector::new(0., 24.);
assert!(
matches!(send(&scene,&mut input,mouse_event(mouse::Event::CursorMoved{position:moved}),Some(moved)),Some(CanvasEvent::Spacing(Event::Move{stamp:s,token:t,..})) if s==stamp && t==token)
);
assert!(
matches!(send(&scene,&mut input,mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),Some(moved)),Some(CanvasEvent::Spacing(Event::Finish{stamp:s,token:t,..})) if s==stamp && t==token)
);
assert_eq!(scene.selection, Some((context, snapshot)));
assert_eq!(scene.selected, second);
assert!(input.press.is_none() && input.group_press.is_none());
}
#[test]
fn group_press_retains_snapshot_and_collapses_only_stationary_release_once() {
let (f, scene, point) = fixture(true);
let mut state = SequenceInput::default();
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point)
)
.is_none()
);
assert!(state.press.is_none());
let captured = state.group_press.as_ref().unwrap().snapshot.clone();
assert_eq!(captured.primary(), Some(&Target::ScenarioObject(f.reply)));
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
) else {
panic!("captured collapse")
};
assert_eq!(event.snapshot, captured);
assert!(matches!(event.intent, Intent::Collapse(Target::ScenarioObject(id)) if id == f.call));
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)
.is_none()
);
}
#[test]
fn crossed_threshold_return_to_origin_refuses_whole_set_and_outside_release_cancels() {
for outside in [false, true] {
let (_, scene, point) = fixture(true);
let mut state = SequenceInput::default();
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
let moved = point + Vector::new(12., 0.);
send(
&scene,
&mut state,
mouse_event(mouse::Event::CursorMoved { position: moved }),
Some(moved),
);
send(
&scene,
&mut state,
mouse_event(mouse::Event::CursorMoved { position: point }),
Some(point),
);
let event = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
(!outside).then_some(point),
);
if outside {
assert!(event.is_none());
} else {
let Some(CanvasEvent::Selection(event)) = event else {
panic!("whole-set refusal")
};
assert!(matches!(event.intent, Intent::Refuse(_)));
assert_eq!(event.snapshot.targets().len(), 2);
}
assert!(state.group_press.is_none() && state.press.is_none());
}
}
#[test]
fn passive_shift_survives_context_refresh_and_window_loss_clears_it() {
let (f, mut scene, point) = fixture(false);
let mut state = SequenceInput::default();
assert!(
send(
&scene,
&mut state,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT
)),
Some(point)
)
.is_none()
);
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
) else {
panic!("toggle")
};
assert!(
matches!(event.intent, Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if id == f.call)
);
assert!(state.press.is_none());
scene.interaction.as_mut().unwrap().0.epoch += 1;
send(
&scene,
&mut state,
canvas::Event::Window(iced::window::Event::Unfocused),
None,
);
assert!(state.modifiers.is_empty());
}
#[test]
fn stale_selection_and_fallback_release_do_not_move_primary() {
let (_, mut scene, point) = fixture(true);
scene.coordinate_input = false;
let mut state = SequenceInput::default();
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
) else {
panic!("fallback stationary selection remains usable")
};
assert!(matches!(event.intent, Intent::Collapse(_)));
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
scene.selection.as_mut().unwrap().0.visit = Id::new_v4();
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)
.is_none()
);
assert!(state.group_press.is_none());
}
@@ -5,13 +5,17 @@
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use crate::diagram_usage::Target as SelectionTarget;
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
use std::collections::BTreeSet;
pub(crate) mod controller;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
@@ -30,6 +34,7 @@ impl Target {
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSelection(&'static str),
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
@@ -45,6 +50,7 @@ pub(super) struct Drag {
diagram: Id,
target: Target,
origin: f32,
other_origins: Vec<(Target, f32)>,
original_geometry: ResolvedScenarioPlan,
}
@@ -58,17 +64,135 @@ pub(super) struct Preview {
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
capture_targets(project, diagram, target, &[target])
}
/// The exact selected set is one operation, never an eligible subset. Selection
/// order and the pressed member do not determine persistence order.
pub(super) fn capture_group(
project: &Project,
diagram: Id,
pressed: Target,
targets: &[SelectionTarget],
) -> Result<Drag, Refusal> {
if targets.is_empty() {
return Err(Refusal::InvalidSelection(
"Select at least one occurrence to move.",
));
}
let mut seen = BTreeSet::new();
let mut members = Vec::with_capacity(targets.len());
for selected in targets {
let SelectionTarget::ScenarioObject(id) = selected else {
return Err(Refusal::InvalidSelection(
"Move only local Scenario lifelines or messages.",
));
};
if !seen.insert(*id) {
return Err(Refusal::InvalidSelection(
"The selection contains a duplicate occurrence.",
));
}
let target = match pressed {
Target::Lane(_) => Target::Lane(*id),
Target::Message(_) => Target::Message(*id),
};
let Some(element) = project.elements.get(id) else {
return Err(Refusal::TargetNotRendered(target));
};
let same_role = match target {
Target::Lane(_) => element.kind() == Kind::Lifeline,
Target::Message(_) => matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
),
};
if !same_role {
return Err(Refusal::InvalidSelection(
"Move a group of lifelines or a group of messages; mixed and other occurrence types are not supported.",
));
}
members.push(target);
}
if !members.contains(&pressed) {
return Err(Refusal::InvalidSelection(
"The pressed occurrence is not in the captured selection.",
));
}
members.sort_by_key(|target| target.id());
capture_targets(project, diagram, pressed, &members)
}
// Require the actual local sequence/operand path, not just an ancestor pointer.
// Cross-operand groups are allowed; no member changes its existing parent.
fn local_message(project: &Project, owner: Id, id: Id) -> bool {
let mut current = id;
let mut visited = BTreeSet::new();
while current != owner && visited.insert(current) {
let Some(element) = project.elements.get(&current) else {
return false;
};
let Some(parent) = element.parent.and_then(|id| project.elements.get(&id)) else {
return false;
};
if element.layer() != project.elements[&owner].layer() {
return false;
}
let listed = match parent.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
) && branches.iter().filter(|&&id| id == current).count() == 1
}
_ => parent
.steps()
.is_some_and(|steps| steps.iter().filter(|&&id| id == current).count() == 1),
};
if !listed {
return false;
}
current = parent.id;
}
current == owner
}
fn captured_origin(
project: &Project,
owner: Id,
geometry: &ResolvedScenarioPlan,
target: Target,
) -> Result<f32, Refusal> {
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
if element.is_some_and(|e| {
e.kind() == Kind::Lifeline
&& e.parent == Some(owner)
&& e.layer() == project.elements[&owner].layer()
}) =>
{
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
// Unassigned headers are still rendered local targets.
let headers = geometry
.scene
.boxes
.iter()
.filter(|item| {
item.id == id && item.role == crate::behavior::SceneHitRole::LifelineHeader
})
.count();
(headers == 1)
.then(|| geometry.scene.render_lanes.get(&id).copied())
.flatten()
}
Target::Message(id)
if element.is_some_and(|e| {
@@ -79,17 +203,16 @@ pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<
..
})
)
}) =>
}) && local_message(project, owner, id) =>
{
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
// A lifecycle receive row is not the stored send coordinate. Reject
// ambiguous repeated drawing IDs rather than choosing the first.
let mut lines = geometry.scene.lines.iter().filter(|line| line.id == id);
let first = lines
.next()
.and_then(|line| line.points.first())
.map(|point| point.y)
.map(|point| point.y);
if lines.next().is_some() { None } else { first }
}
_ => None,
}
@@ -97,11 +220,38 @@ pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(origin)
}
fn capture_targets(
project: &Project,
diagram: Id,
target: Target,
targets: &[Target],
) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let origins = targets
.iter()
.map(|member| {
captured_origin(project, owner, &geometry, *member).map(|origin| (*member, origin))
})
.collect::<Result<Vec<_>, _>>()?;
let origin = origins
.iter()
.find(|(member, _)| *member == target)
.expect("admitted pressed member")
.1;
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
other_origins: origins
.into_iter()
.filter(|(member, _)| *member != target)
.collect(),
original_geometry: geometry,
})
}
@@ -117,28 +267,50 @@ impl Drag {
if original_delta == 0. {
return Ok(None);
}
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
let mut commands = Vec::new();
for (target, origin) in
std::iter::once((self.target, self.origin)).chain(self.other_origins.iter().copied())
{
let position = (f64::from(origin) + f64::from(original_delta)) as f32;
if !position.is_finite() || position.abs() > oa_core::MAX_COORDINATE {
return Err(Refusal::Command(
"Scenario positions must be finite and within the shared coordinate bounds."
.into(),
));
}
if position == origin {
continue;
}
let command = match target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
commands.push((target.id(), command));
}
if commands.is_empty() {
return Ok(None);
}
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
commands.sort_by_key(|(id, _)| *id);
let mut commands: Vec<_> = commands.into_iter().map(|(_, command)| command).collect();
let command = if self.other_origins.is_empty() {
commands.pop().expect("changed singleton")
} else {
Command::Batch(commands)
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
@@ -5,6 +5,7 @@ use crate::behavior::{
scenario::{CanvasTool, Context, State},
scenario_layout::Diagnostic,
};
use crate::{diagram_selection::Snapshot, diagram_usage::Target as SelectionTarget};
use iced::{Point, Rectangle, Vector};
#[cfg(test)]
@@ -16,6 +17,7 @@ mod timing_tests;
pub struct Stamp {
pub(crate) context: Context,
pub(crate) camera_epoch: u64,
pub(crate) selection_generation: u64,
}
#[derive(Clone, Copy, Debug)]
@@ -72,11 +74,14 @@ pub(crate) struct Session {
drag: Drag,
preview: Option<Preview>,
refusal: Option<Refusal>,
group_refusal: Option<Refusal>,
ever_crossed: bool,
snapshot: Option<Snapshot>,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
pub(in crate::behavior) selected_count: usize,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
@@ -98,7 +103,21 @@ pub(crate) enum Finish {
impl Session {
fn configure(&self, scene: &mut Scene) {
scene.selected = self.drag.target.id();
scene.selected = self
.snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.and_then(|s| match s.primary() {
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
_ => None,
})
.unwrap_or(self.drag.target.id());
scene.selection = self
.snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.cloned()
.map(|s| (self.stamp.context, s));
scene.scale = self.scale;
scene.camera_pan = self.pan;
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
@@ -117,8 +136,18 @@ impl Session {
Target::Lane(_) => screen.x - self.press_screen.x,
Target::Message(_) => screen.y - self.press_screen.y,
};
self.ever_crossed |= distance.abs() >= 3.;
if !self.ever_crossed {
self.ever_crossed |= if self
.snapshot
.as_ref()
.is_some_and(|s| s.targets().len() > 1)
{
screen.distance(self.press_screen) >= 3.
} else {
distance.abs() >= 3.
};
if !self.ever_crossed || self.group_refusal.is_some() {
// A rejected whole set may retain a pressed singleton only as its
// original display witness. It must never prepare a partial move.
return;
}
match self.drag.prepare(distance / self.scale) {
@@ -139,6 +168,9 @@ impl State {
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
self.spacing.as_ref().map(|s| s.stamp)
}
pub(crate) fn spacing_snapshot(&self) -> Option<&Snapshot> {
self.spacing.as_ref()?.snapshot.as_ref()
}
pub(crate) fn cancel_spacing(&mut self) -> bool {
self.spacing.take().is_some()
}
@@ -149,28 +181,55 @@ impl State {
camera_epoch: u64,
) -> Option<Paint<'_>> {
let session = self.spacing.as_ref()?;
if session.stamp
!= (Stamp {
context,
camera_epoch,
})
{
if session.stamp.context != context || session.stamp.camera_epoch != camera_epoch {
return None;
}
let original = &session.drag.original_geometry;
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
Some(Paint {
scene: &shown.scene,
selected_count: session
.snapshot
.as_ref()
.map_or(1, |s| s.targets().len().max(1)),
#[cfg(test)]
bounds: shown.bounds,
refusal: session.refusal.as_ref(),
refusal: session.refusal.as_ref().or_else(|| {
session
.ever_crossed
.then_some(session.group_refusal.as_ref())
.flatten()
}),
original_diagnostics: &original.plan.diagnostics,
original_latent: &original.latent_messages,
diagnostics: &shown.plan.diagnostics,
})
}
#[cfg(test)]
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
self.begin_spacing_capture(project, event, None)
}
pub(crate) fn begin_spacing_with_snapshot(
&mut self,
project: &Project,
event: Event,
snapshot: Snapshot,
) -> Result<(), Refusal> {
if snapshot.generation() != event.stamp().selection_generation
|| snapshot.context()
!= crate::behavior::scenario::selection::context(event.stamp().context)
{
return Err(Refusal::InvalidGesture);
}
self.begin_spacing_capture(project, event, Some(snapshot))
}
fn begin_spacing_capture(
&mut self,
project: &Project,
event: Event,
snapshot: Option<Snapshot>,
) -> Result<(), Refusal> {
let Event::Begin {
stamp,
token,
@@ -216,7 +275,34 @@ impl State {
let Representation::Saved { diagram } = context.representation else {
return Err(Refusal::InvalidGesture);
};
let mut drag = capture(project, diagram, target)?;
let mut group_refusal = None;
let mut drag = if let Some(selected) = snapshot.as_ref().filter(|s| s.targets().len() > 1) {
if !selected
.targets()
.contains(&SelectionTarget::ScenarioObject(target.id()))
{
return Err(Refusal::TargetNotHit(target));
}
match capture_group(project, diagram, target, selected.targets()) {
Ok(drag) => drag,
Err(error)
if matches!(
&error,
Refusal::InvalidSelection(_) | Refusal::TargetNotRendered(_)
) =>
{
// Selection clicks do not require movement eligibility.
// This capture supplies original paint and the exact hit
// only; group_refusal forbids calling its prepare method.
let original = capture(project, diagram, target)?;
group_refusal = Some(error);
original
}
Err(error) => return Err(error),
}
} else {
capture(project, diagram, target)?
};
let camera = self.presentation.camera(drag.original_geometry.bounds);
if self.presentation.viewport != Some(canvas_bounds.size())
|| camera.scale != scale
@@ -246,7 +332,19 @@ impl State {
}
// Configure baseline without changing the persistent camera or selection.
let scene = &mut drag.original_geometry.scene;
scene.selected = target.id();
scene.selected = snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.and_then(|s| match s.primary() {
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
_ => None,
})
.unwrap_or(target.id());
scene.selection = snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.cloned()
.map(|s| (context, s));
scene.scale = scale;
scene.camera_pan = pan;
scene.interaction = Some((context, CanvasTool::Select, true));
@@ -262,7 +360,9 @@ impl State {
drag,
preview: None,
refusal: None,
group_refusal,
ever_crossed: false,
snapshot,
});
Ok(())
}
@@ -282,6 +382,9 @@ impl State {
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
if let Some(error) = session.group_refusal {
return Err(error);
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
@@ -95,6 +95,7 @@ pub(crate) fn packet(
stamp: Stamp {
context,
camera_epoch,
selection_generation: 0,
},
token: Id::new_v4(),
target,
@@ -135,6 +136,55 @@ fn press(event: Event) -> Point {
press_screen
}
#[test]
fn group_preview_keeps_captured_members_and_nonpressed_primary() {
use crate::diagram_selection::Selection;
use crate::diagram_usage::Target as Selected;
let (doc, mut state, mut event) = setup(true);
let context = event.stamp().context;
let first = match event {
Event::Begin { target, .. } => target.id(),
_ => unreachable!(),
};
let second = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "second")
.unwrap()
.0;
let selection_context = crate::behavior::scenario::selection::context(context);
let mut selection = Selection::new(selection_context.scope);
selection.add(Selected::ScenarioObject(first), |_| true);
selection.add(Selected::ScenarioObject(second), |_| true);
let snapshot = selection.snapshot(selection_context).unwrap();
if let Event::Begin { stamp, .. } = &mut event {
stamp.selection_generation = snapshot.generation();
}
let start = press(event);
state
.begin_spacing_with_snapshot(doc.project(), event, snapshot.clone())
.unwrap();
for delta in [Vector::new(0., 0.), Vector::new(0., 24.)] {
state.move_spacing(start + delta);
let paint = state
.spacing_paint(context, event.stamp().camera_epoch)
.unwrap();
assert_eq!(paint.scene.selected, second);
assert_eq!(
paint.scene.selection.as_ref().unwrap(),
&(context, snapshot.clone())
);
assert_eq!(paint.selected_count, 2);
}
state.move_spacing(start + Vector::new(50., 0.));
assert!(matches!(
state.finish_spacing(start + Vector::new(50., 0.)).unwrap(),
Finish::Noop
));
assert!(!doc.can_undo());
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
@@ -0,0 +1,675 @@
use super::*;
use crate::document::Document;
use oa_core::{Diagram, DiagramLayout, Layer, ProjectionKind, ScenarioLayout, language};
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let right = id(&project, "right");
let mut views = Vec::new();
for (alias, layout) in [
("active_view", layout),
(
"untouched_view",
Some(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: right,
x: 700.,
}],
messages: vec![],
}),
),
] {
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: alias.into(),
name: alias.into(),
description: "Preserve authored view metadata".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: layout.map(DiagramLayout::Scenario),
},
);
views.push(diagram);
}
(
Document::from_project(project, Default::default()).unwrap(),
views[0],
views[1],
)
}
fn targets(ids: &[Id]) -> Vec<SelectionTarget> {
ids.iter()
.copied()
.map(SelectionTarget::ScenarioObject)
.collect()
}
fn message(occurrence: Id, send_y: f32) -> ScenarioMessagePosition {
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
}
}
// Literal expected authored payload, not another execution of planner commands.
fn expected(
project: &Project,
diagram: Id,
lanes: Vec<ScenarioLanePosition>,
messages: Vec<ScenarioMessagePosition>,
) -> Project {
let mut result = project.clone();
result.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout { lanes, messages }));
result
}
fn exact_transaction(mut document: Document, preview: Preview, expected: Project) {
let original = document.snapshot();
let revision = document.revision();
assert_eq!(preview.project, expected);
assert!(matches!(&preview.command, Command::Batch(_)));
document.execute(preview.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.revision(), revision + 1);
let changed = document.bundle().clone();
assert_ne!(changed, original.bundle);
assert_eq!(
language::parse(&language::format(document.project())).unwrap(),
expected
);
assert!(document.undo());
assert_eq!(document.project(), &original.decoded.project);
assert_eq!(document.bundle(), &original.bundle);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &changed);
assert!(!document.can_redo());
let reopened = Document::from_bundle(changed, Default::default()).unwrap();
assert_eq!(reopened.project(), &expected);
assert!(!reopened.dirty() && !reopened.can_undo());
}
#[test]
fn group_messages_move_from_each_original_with_press_and_selection_order_independent() {
let (document, diagram, other) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let wanted = expected(
before,
diagram,
vec![],
vec![message(first, 188.), message(second, 280.)],
);
for members in [[first, second], [second, first]] {
for pressed in [first, second] {
let drag = capture_group(
before,
diagram,
Target::Message(pressed),
&targets(&members),
)
.unwrap();
let preview = drag.prepare(40.).unwrap().unwrap();
assert_eq!(preview.project, wanted);
assert_eq!(preview.project.diagrams[&other], before.diagrams[&other]);
assert_eq!(drag.original, *before);
}
}
let preview = capture_group(
before,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(40.)
.unwrap()
.unwrap();
exact_transaction(document, preview, wanted);
}
#[test]
fn group_lanes_vacate_each_others_space_in_one_candidate_and_one_history_entry() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let left = id(before, "left");
let right = id(before, "right");
// Moving only Left into Right's old x cannot render a distinct-endpoint arrow.
assert!(matches!(
capture(before, diagram, Target::Lane(left))
.unwrap()
.prepare(240.),
Err(Refusal::Geometry(_))
));
let wanted = expected(
before,
diagram,
vec![
ScenarioLanePosition {
lifeline: left,
x: 400.,
},
ScenarioLanePosition {
lifeline: right,
x: 640.,
},
],
vec![],
);
for pressed in [left, right] {
let preview = capture_group(
before,
diagram,
Target::Lane(pressed),
&targets(&[right, left]),
)
.unwrap()
.prepare(240.)
.unwrap()
.unwrap();
assert_eq!(preview.project, wanted);
assert_eq!(preview.geometry.scene.render_lanes[&left], 400.);
assert_eq!(preview.geometry.scene.render_lanes[&right], 640.);
}
let preview = capture_group(
before,
diagram,
Target::Lane(right),
&targets(&[left, right]),
)
.unwrap()
.prepare(240.)
.unwrap()
.unwrap();
exact_transaction(document, preview, wanted);
}
#[test]
fn self_call_and_reply_are_validated_together_but_partial_candidate_is_refused() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let reply = id(before, "reply");
let first = id(before, "first");
assert!(matches!(
capture(before, diagram, Target::Message(call))
.unwrap()
.prepare(118.),
Err(Refusal::Geometry(_))
));
let drag = capture_group(
before,
diagram,
Target::Message(reply),
&targets(&[reply, call]),
)
.unwrap();
let preview = drag.prepare(118.).unwrap().unwrap();
assert_eq!(
preview.project,
expected(
before,
diagram,
vec![],
vec![message(call, 450.), message(reply, 542.)]
)
);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == call)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 450.);
assert_eq!(line.points.last().unwrap().y, 474.);
let partial = capture_group(
before,
diagram,
Target::Message(first),
&targets(&[first, call]),
)
.unwrap();
assert!(matches!(partial.prepare(118.), Err(Refusal::Geometry(_))));
assert_eq!(partial.original, *before);
assert!(!document.can_undo());
}
#[test]
fn exact_set_admission_rejects_empty_duplicates_mixed_unsupported_missing_and_unpressed() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let left = id(before, "left");
let source = id(before, "source");
let unfinished = id(before, "unfinished");
for selection in [
vec![],
targets(&[first, first]),
targets(&[first, left]),
targets(&[first, source]),
targets(&[first, Id::new_v4()]),
targets(&[second]),
targets(&[first, unfinished]),
vec![
SelectionTarget::ScenarioObject(first),
SelectionTarget::Whole,
],
vec![
SelectionTarget::ScenarioObject(first),
SelectionTarget::Placement(second),
],
] {
assert!(
capture_group(before, diagram, Target::Message(first), &selection).is_err(),
"{selection:?}"
);
}
assert!(
capture_group(
before,
Id::nil(),
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
assert!(
capture_group(
before,
diagram,
Target::Lane(first),
&targets(&[first, second])
)
.is_err()
);
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn foreign_unlisted_and_ambiguous_local_messages_cannot_enter_a_group() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let owner = id(before, "conversation");
for case in 0..3 {
let mut project = before.clone();
match case {
0 => {
let foreign = project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
project.elements.get_mut(&second).unwrap().parent = Some(foreign);
}
1 => project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != second),
_ => project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(second),
}
assert!(
capture_group(
&project,
diagram,
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
}
}
#[test]
fn fallback_baseline_is_refused_before_any_zero_motion_can_be_planned() {
let (document, diagram, _) = fixture(None);
let call = id(document.project(), "call");
let reply = id(document.project(), "reply");
let invalid = expected(
document.project(),
diagram,
vec![],
vec![message(call, 450.)],
);
let shown = crate::behavior::scenario_layout::presentation::present(
&invalid,
Representation::Saved { diagram },
call,
);
assert!(shown.capabilities().geometry_navigation);
assert!(!shown.capabilities().coordinate_input);
assert!(matches!(
capture_group(
&invalid,
diagram,
Target::Message(reply),
&targets(&[reply, call])
),
Err(Refusal::Geometry(_))
));
}
#[test]
fn group_noops_preserve_sparse_overrides_complete_source_and_existing_redo() {
for sparse in [true, false] {
let (mut document, diagram, _) = fixture((!sparse).then(ScenarioLayout::default));
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let owner = id(document.project(), "conversation");
document
.execute(Command::Rename(owner, "Redo sentinel".into()))
.unwrap();
let redo = document.snapshot();
assert!(document.undo());
let original = document.snapshot();
let revision = document.revision();
let drag = capture_group(
document.project(),
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap();
assert!(drag.prepare(30.).unwrap().is_some());
for delta in [0., -0., f32::from_bits(1)] {
assert!(drag.prepare(delta).unwrap().is_none());
}
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
assert_eq!(document.bundle(), &original.bundle);
assert_eq!(document.revision(), revision);
assert_eq!(drag.original, original.decoded.project);
assert!(document.redo());
assert_eq!(document.project(), &redo.decoded.project);
assert_eq!(document.bundle(), &redo.bundle);
}
}
#[test]
fn subprecision_unchanged_member_does_not_materialize_an_absent_override() {
let (document, diagram, _) = fixture(None);
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let project = expected(
document.project(),
diagram,
vec![],
vec![message(first, 0.)],
);
let delta = 0.000001_f32;
assert_eq!(240_f32 + delta, 240.);
let preview = capture_group(
&project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(&project, diagram, vec![], vec![message(first, delta)])
);
assert!(matches!(preview.command, Command::Batch(ref commands) if commands.len() == 1));
}
#[test]
fn invalid_delta_or_one_out_of_bounds_member_refuses_the_whole_group() {
let (document, diagram, _) = fixture(None);
let left = id(document.project(), "left");
let right = id(document.project(), "right");
let project = expected(
document.project(),
diagram,
vec![ScenarioLanePosition {
lifeline: right,
x: 999_990.,
}],
vec![],
);
let drag = capture_group(
&project,
diagram,
Target::Lane(left),
&targets(&[left, right]),
)
.unwrap();
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
}
for delta in [20., f32::MAX, -f32::MAX] {
assert!(matches!(drag.prepare(delta), Err(Refusal::Command(_))));
}
assert_eq!(drag.original, project);
assert!(drag.prepare(0.).unwrap().is_none());
}
#[test]
fn singleton_capture_group_retains_existing_singleton_command_and_result() {
let (document, diagram, _) = fixture(None);
for target in [
Target::Lane(id(document.project(), "right")),
Target::Message(id(document.project(), "second")),
] {
let single = capture(document.project(), diagram, target)
.unwrap()
.prepare(25.)
.unwrap()
.unwrap();
let group = capture_group(
document.project(),
diagram,
target,
&targets(&[target.id()]),
)
.unwrap()
.prepare(25.)
.unwrap()
.unwrap();
assert_eq!(group.project, single.project);
assert!(!matches!(group.command, Command::Batch(_)));
}
}
#[test]
fn existing_layout_order_and_unselected_values_are_preserved_exactly() {
let (document, diagram, _) = fixture(None);
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let reply = id(document.project(), "reply");
let right = id(document.project(), "right");
let lanes = vec![ScenarioLanePosition {
lifeline: right,
x: 400.,
}];
let project = expected(
document.project(),
diagram,
lanes.clone(),
vec![message(reply, 424.), message(first, 148.)],
);
let preview = capture_group(
&project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(40.)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(
&project,
diagram,
lanes,
vec![
message(reply, 424.),
message(first, 188.),
message(second, 280.)
]
)
);
}
#[test]
fn local_messages_in_different_operands_move_without_reparenting_or_reordering() {
use crate::behavior::topology::occurrence;
let (document, diagram, _) = fixture(None);
let mut project = document.project().clone();
let owner = id(&project, "conversation");
let first = id(&project, "first");
let second = id(&project, "second");
let fragment = occurrence(
&project,
owner,
"Alternatives",
OccurrenceBody::Fragment {
operator: oa_core::FragmentOperator::Alternative,
branches: vec![],
},
);
let frame = fragment.id;
project.elements.insert(frame, fragment);
let mut branches = Vec::new();
for member in [first, second] {
let branch = occurrence(
&project,
frame,
"Operand",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![member],
},
);
let branch_id = branch.id;
project.elements.insert(branch_id, branch);
project.elements.get_mut(&member).unwrap().parent = Some(branch_id);
branches.push(branch_id);
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
branches: items, ..
},
..
}) = project.elements.get_mut(&frame).unwrap().shared_mut()
{
*items = branches.clone();
} else {
unreachable!();
}
let steps = project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap();
steps.retain(|id| ![first, second].contains(id));
steps.insert(0, frame);
let document = Document::from_project(project, Default::default()).unwrap();
let project = document.project();
let baseline = resolve(project, Representation::Saved { diagram }, first).unwrap();
let original_send = |id| {
baseline
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
};
let preview = capture_group(
project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(-5.)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(
project,
diagram,
vec![],
vec![
message(first, original_send(first) - 5.),
message(second, original_send(second) - 5.)
]
)
);
assert_eq!(preview.project.elements, project.elements);
let mut omitted = project.clone();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
branches: items, ..
},
..
}) = omitted.elements.get_mut(&frame).unwrap().shared_mut()
{
items.retain(|id| *id != branches[1]);
}
assert!(
capture_group(
&omitted,
diagram,
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
}
const FIXTURE: &str = r#"arcadia 7;
project spacing_group "Saved group spacing" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
@@ -75,6 +75,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.spacing(8)
.into();
};
let snapshot = app.multiselect_snapshot();
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// Resolve once per accepted view. An active spacing session owns its exact
@@ -90,7 +91,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
},
ScenarioPresentation::capabilities,
);
let coordinate_input = clean && capabilities.coordinate_input;
let coordinate_input =
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
let notice = if let Some(paint) = &preview {
display_notice_parts(
p,
@@ -123,6 +125,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
scenario_view: Some((context.representation, context.visit)),
};
let keyboard = clean
&& app.scenario_reorder.session.is_none()
&& capabilities.geometry_navigation
&& super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
@@ -138,15 +141,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
scene.scale = camera_state.scale;
scene.camera_pan = camera_state.pan;
scene.interaction = Some((context, tool, clean));
scene.selection = snapshot.clone().map(|snapshot| (context, snapshot));
scene.spacing_stamp = (coordinate_input
&& !app.source_rename.open()
&& keyboard
&& matches!(context.representation, Representation::Saved { .. }))
.then_some(spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
});
scene.coordinate_input = capabilities.coordinate_input;
.then(|| {
snapshot.as_ref().map(|snapshot| spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
selection_generation: snapshot.generation(),
})
})
.flatten();
scene.coordinate_input =
capabilities.coordinate_input && app.scenario_reorder.session.is_none();
scene.pending_placement = coordinate_input
.then(|| state.pending_placement(owner))
.flatten();
@@ -158,8 +167,27 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
// Keep this Stack in both branches: replacing the Canvas with a wrapper
// on Begin would discard its pointer capture before Move/Finish.
let mut layers = iced::widget::stack![raw_canvas];
if let Some((_, summary)) = &app.scenario_reorder.summary {
layers = layers.push(
container(iced::widget::opaque(
container(
text(format!("{summary} Use Undo to restore the previous order.")).size(12),
)
.padding(8)
.style(style::card),
))
.align_bottom(Fill)
.width(Fill)
.padding(8),
);
}
if let Some(paint) = &preview {
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
let feedback = if paint.selected_count > 1 {
format!("{} selected - {feedback}", paint.selected_count)
} else {
feedback
};
layers = layers.push(
container(
container(text(feedback).size(12))
@@ -196,7 +224,46 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
};
let mut navigation = row![out.width(Fill)].spacing(6);
// The chooser must sit outside Viewport: that camera wrapper deliberately
// consumes Space, arrows and wheel before its child receives input.
let mut canvas_layers = iced::widget::stack![canvas];
if let Some(panel) = reorder_view::panel(app, presentation.viewport) {
canvas_layers = canvas_layers.push(panel);
}
let canvas = canvas_layers.into();
let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len());
let mut navigation = row![
out.width(Fill),
text(format!("{selected_count} selected")).size(12)
]
.spacing(6);
let reorder = reorder_view::begin(app);
navigation = navigation.push(super::super::canvas_workspace::Control::new(
button(text("Reorder…").size(12))
.padding(6)
.on_press_maybe(reorder.clone()),
scope,
"Reorder",
keyboard,
reorder,
camera,
));
if selected_count > 1 {
let removal = app
.scenario_removal_enabled()
.then(|| crate::app::scenario_removal::begin(app))
.flatten();
navigation = navigation.push(super::super::canvas_workspace::Control::new(
button(text("Remove…").size(12))
.padding(6)
.on_press_maybe(removal.clone()),
scope,
"Remove selected",
keyboard,
removal,
camera,
));
}
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
@@ -262,7 +329,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let entries = [
(
"S",
if matches!(context.representation, Representation::Saved { .. }) {
if selected_count > 1 && matches!(context.representation, Representation::Saved { .. })
{
"Drag messages vertically or lifelines horizontally; Shift-click to change selection"
} else if selected_count > 1 {
"Shift-click to change selection; click a member to select only it"
} else if matches!(context.representation, Representation::Saved { .. }) {
"Select / move messages and lifelines"
} else {
"Select / reorder occurrences"
@@ -343,7 +415,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
text(if selected_count > 1 {
format!(
"{selected_count} selected; properties: {}",
p.named(app.selected)
)
} else {
format!("Selected: {}", p.named(app.selected))
})
.size(12),
container(button(text("Properties").size(12)).on_press_maybe(
(clean && properties_target(p, context, app.selected)).then_some(
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
@@ -367,7 +447,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
sequence_rows(p, context, owner, details, clean).into(),
sequence_rows(p, context, owner, details, clean, snapshot.as_ref()).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
@@ -685,7 +765,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.into();
}
}
let editor = if pending {
let editor = if app.scenario_reorder.session.is_some() {
None
} else if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
@@ -724,9 +806,13 @@ fn display_notice_parts<'a>(
for diagnostic in diagnostics.iter().take(3) {
let explanation = match diagnostic {
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
p.named(*before),
p.named(*after)
"Saved positions place @{} below @{}, contrary to execution order.",
p.elements
.get(before)
.map_or("?", |element| element.alias.as_str()),
p.elements
.get(after)
.map_or("?", |element| element.alias.as_str())
),
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
@@ -751,7 +837,7 @@ fn display_notice_parts<'a>(
if let Some(issue) = requested_issues.first() {
notice = notice.push(text(geometry_problem(p, issue)).size(12));
}
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
notice = notice.push(text("Selection, navigation and Fit remain available. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").size(12));
}
PresentationStatus::Unavailable { .. } => {
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
@@ -908,7 +994,24 @@ fn sequence_rows<'a>(
owner: Id,
mut out: Column<'a, Message>,
enabled: bool,
snapshot: Option<&crate::diagram_selection::Snapshot>,
) -> Column<'a, Message> {
let choose = |id| {
snapshot.map_or_else(
|| Message::ScenarioCanvas(CanvasEvent::Select(context, id)),
|snapshot| {
selection::message(
context,
snapshot,
selection::Intent::Choose(
crate::diagram_usage::Target::ScenarioObject(id),
crate::app::multiselect::Choice::Replace,
),
)
},
)
};
let order_enabled = enabled && snapshot.is_none_or(|snapshot| snapshot.targets().len() <= 1);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
@@ -917,10 +1020,10 @@ fn sequence_rows<'a>(
for branch in branches {
out = out.push(sequence_action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
choose(*branch),
enabled,
));
out = sequence_rows(p, context, *branch, out, enabled);
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
@@ -929,17 +1032,17 @@ fn sequence_rows<'a>(
row![
sequence_action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id),
choose(*id),
enabled
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
.on_press_maybe((order_enabled && i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(enabled && i + 1 < steps.len())
(order_enabled && i + 1 < steps.len())
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
)
]
@@ -960,7 +1063,7 @@ fn sequence_rows<'a>(
enabled,
));
}
out = sequence_rows(p, context, *id, out, enabled);
out = sequence_rows(p, context, *id, out, enabled, snapshot);
}
}
out
@@ -388,13 +388,16 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_details(&mut app);
let begin = packet(
let mut begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
if let Spacing::Begin { stamp, .. } = &mut begin {
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
}
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
@@ -443,3 +446,180 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_selection_shift_survives_properties_focus_busy_rebuild_and_clears_on_window_loss() {
use crate::app::multiselect::Choice;
use crate::behavior::canvas_workspace::tests::focused;
use crate::diagram_usage::Target;
fn at(mounted: &mut Mounted, app: &App, target: Id) -> Point {
let context = app.scenario_context().unwrap();
let geometry = resolve(app.project(), context.representation, app.selected).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == target)
.unwrap();
let world = line.label_at + Vector::new(8., 8.);
assert_eq!(geometry.scene.hit_item(world).unwrap().id, target);
let bounds = mounted.geometry(app).bounds("scenario-viewport");
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
assert!(bounds.contains(point));
point
}
async fn focus_name(mounted: &mut Mounted, app: &mut App) -> Point {
let shown = mounted.geometry(app);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible Name input");
mounted.click(app, input).await;
assert!(!focused(mounted, app, "viewport"));
assert!(
mounted
.geometry(app)
.focused
.iter()
.any(|(_, bounds, yes)| *yes && bounds.contains(input + offset))
);
assert!(!app.draft_dirty());
input
}
let shift = |held| {
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if held {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
}))
};
let (mut app, diagram, first) = setup();
let second = id(&app, "updated_report");
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
let (details, offset) = shown.scrolls[&widget::Id::new("scenario-details")];
let properties = shown.bounds("scenario-properties").center() - offset;
assert!(details.contains(properties));
mounted.click(&mut app, properties).await;
mounted.settle(&mut app).await;
let input = focus_name(&mut mounted, &mut app).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let context = app.scenario_context().unwrap();
let selected = app.multiselect_snapshot().unwrap();
assert_eq!(selected.targets(), &[Target::ScenarioObject(first)]);
let passive = mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
.await;
assert!(
!passive
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
);
assert!(!focused(&mut mounted, &app, "viewport"));
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
let second_at = at(&mut mounted, &app, second);
let messages = mounted.click(&mut app, second_at).await;
assert_eq!(messages.iter().filter(|message| matches!(message,
Message::ScenarioCanvas(CanvasEvent::Selection(event))
if matches!(&event.intent, selection::Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if *id == second))).count(), 1);
assert!(!messages.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(_) | CanvasEvent::Reorder { .. })
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(second))
);
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(
&mut app,
&[shift(false)],
mouse::Cursor::Available(second_at),
)
.await;
app.busy = false;
mounted.settle(&mut app).await;
let first_at = at(&mut mounted, &app, first);
let messages = mounted.click(&mut app, first_at).await;
// Saved group clicks now complete the captured spacing lifetime; its
// stationary outcome must retain the same selection-only collapse.
assert!(messages.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(
super::super::spacing::Event::Finish { .. }
))
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
let input = focus_name(&mut mounted, &mut app).await;
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
.await;
app.busy = false;
mounted.settle(&mut app).await;
let second_at = at(&mut mounted, &app, second);
mounted.click(&mut app, second_at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(second))
);
mounted
.batch(
&mut app,
&[iced::Event::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
let first_at = at(&mut mounted, &app, first);
mounted.click(&mut app, first_at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(first))
);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
@@ -436,3 +436,204 @@ async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_c
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
#[tokio::test]
async fn saved_first_press_spaces_unselected_message_with_existing_singleton_selection() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram) = setup(false).await;
let initial = id(&app, "initial_report");
let target = id(&app, "updated_report");
let context = app.scenario_context().unwrap();
let snapshot = app.multiselect_snapshot().unwrap();
let task = app.update(selection::message(
context,
&snapshot,
selection::Intent::Choose(
Target::ScenarioObject(initial),
crate::app::multiselect::Choice::Replace,
),
));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(initial)]
);
let before = app.document.snapshot();
let revision = app.document.revision();
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
let start = Point::new(projected.x.round(), projected.y.round());
let geometry = resolved(&app);
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(effective.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - effective.pan.x) / effective.scale,
(start.y - bounds.y - effective.pan.y) / effective.scale,
);
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let messages = press(&mut mounted, &mut app, start).await;
assert_eq!(
messages
.iter()
.filter(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(_))))
);
let token = app
.behavior
.scenario
.spacing_token()
.expect("first press starts saved spacing despite different selected singleton");
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let end = start + Vector::new(0., 10.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
let messages = release(&mut mounted, &mut app, end).await;
assert_eq!(
messages
.iter()
.filter(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(target)]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(target))
);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let revision = app.document.revision();
let duplicate = release(&mut mounted, &mut app, end).await;
assert!(!duplicate.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
)));
assert_eq!(app.document.revision(), revision);
assert!(Arc::ptr_eq(&after, &app.document.snapshot()));
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_blank_left_drag_clears_selection_and_pans_on_that_same_press() {
use crate::diagram_usage::Target;
for group in [false, true] {
for same_batch in [false, true] {
let (mut app, mut mounted, _) = setup(false).await;
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
for target in [Some(first), group.then_some(second)].into_iter().flatten() {
let context = app.scenario_context().unwrap();
let snapshot = app.multiselect_snapshot().unwrap();
let task = app.update(selection::message(
context,
&snapshot,
selection::Intent::Choose(
Target::ScenarioObject(target),
crate::app::multiselect::Choice::Add,
),
));
mounted.perform(&mut app, task).await;
}
mounted.settle(&mut app).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
if group { 2 } else { 1 }
);
let before = app.document.snapshot();
let context = app.scenario_context().unwrap();
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let start = bounds.position() + Vector::new(bounds.width - 25., bounds.height - 25.);
let delta = Vector::new(-25., -20.);
let end = start + delta;
let geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
assert!(
geometry.scene.hit_item(world).is_none(),
"test must start on actual blank geometry"
);
let stored = stored_camera(&app);
let messages = if same_batch {
mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await
} else {
let mut messages = press(&mut mounted, &mut app, start).await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
mounted.settle(&mut app).await;
messages.extend(move_to(&mut mounted, &mut app, end).await);
messages.extend(release(&mut mounted, &mut app, end).await);
messages
};
assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(event)) if matches!(&event.intent, selection::Intent::Clear))).count(), 1);
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
let actual = stored_camera(&app);
let difference = actual.0 - (stored.0 + delta);
assert!(
difference.x.hypot(difference.y) < 0.01,
"same-press pan lost after Clear: group={group}, same_batch={same_batch}, {stored:?} -> {actual:?}"
);
assert_eq!(actual.1, stored.1);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
@@ -234,6 +234,13 @@ impl Rendering {
.collect()
}
/// Exact painted marker subjects and centers for shared selection adapters.
pub(in crate::behavior) fn selection_markers(&self) -> impl Iterator<Item = (Id, Point)> + '_ {
self.markers
.iter()
.map(|marker| (marker.event.occurrence, marker.center))
}
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
self.markers
.iter()
+69 -4
View File
@@ -29,6 +29,7 @@ use std::{
pub const GUIDANCE_HEIGHT: f32 = 42.;
pub struct StateCanvas<'a> {
pub removal_input: &'a crate::removal_input::Lifetime,
pub project: &'a Project,
pub diagram: Id,
pub context: Context,
@@ -44,6 +45,8 @@ pub struct StateCanvas<'a> {
#[derive(Default)]
pub struct State {
removal_lease: Option<crate::removal_input::Lease>,
removal_viewport: Option<(f32, Vector, Rectangle)>,
context: Option<Context>,
tool: Option<Tool>,
pointer: Option<Point>,
@@ -877,6 +880,21 @@ impl StateCanvas<'_> {
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(selection_context(self.context))?;
if *modifiers == keyboard::Modifiers::SHIFT {
let Some(input_generation) = self.removal_input.idle() else {
return Some(Action::capture());
};
return Some(
Action::publish(Event::ModelRemoval(Box::new(
crate::app::saved_removal::Event::Begin {
input_generation,
context: self.context,
snapshot: Box::new(snapshot),
},
)))
.and_capture(),
);
}
let event = if modifiers.is_empty()
&& crate::app::multiselect::placements(&snapshot).is_some()
{
@@ -885,7 +903,7 @@ impl StateCanvas<'_> {
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
} else {
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
"Use Shift+Delete to review model deletion or Hide selected for placements."
}.into())
};
return Some(Action::publish(Event::Selection(event)).and_capture());
@@ -1511,9 +1529,8 @@ fn kind_for_tool(tool: Tool) -> Kind {
}
}
impl canvas::Program<Event> for StateCanvas<'_> {
type State = State;
fn update(
impl StateCanvas<'_> {
fn update_input(
&self,
state: &mut State,
event: &canvas::Event,
@@ -2002,6 +2019,51 @@ impl canvas::Program<Event> for StateCanvas<'_> {
_ => None,
}
}
}
impl canvas::Program<Event> for StateCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Event>> {
self.removal_input.sync(
&mut state.removal_lease,
state.press.is_some()
|| state.group.is_some()
|| state.source.is_some()
|| state.bend.is_some()
|| state.pan_from.is_some(),
);
self.removal_input.observe(event);
let viewport = (self.zoom, self.pan, bounds);
if state
.removal_viewport
.is_some_and(|previous| previous != viewport)
{
self.removal_input.revoke_idle();
}
state.removal_viewport = Some(viewport);
let action = self.update_input(state, event, bounds, cursor);
if matches!(
event,
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
) {
state.reset();
}
self.removal_input.sync(
&mut state.removal_lease,
state.press.is_some()
|| state.group.is_some()
|| state.source.is_some()
|| state.bend.is_some()
|| state.pan_from.is_some(),
);
action
}
fn draw(
&self,
state: &State,
@@ -2597,6 +2659,7 @@ mod tests {
include!("state_multiselect_canvas_tests.rs");
struct Fixture {
removal_input: crate::removal_input::Lifetime,
project: Project,
diagram: Id,
machine: Id,
@@ -2658,6 +2721,7 @@ mod tests {
}
validate_candidate(&project).unwrap();
Fixture {
removal_input: Default::default(),
project,
diagram,
machine,
@@ -2858,6 +2922,7 @@ mod tests {
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
StateCanvas {
removal_input: &f.removal_input,
project: &f.project,
diagram: f.diagram,
context: Context {
+4 -1
View File
@@ -26,6 +26,7 @@ pub enum Tool {
#[derive(Debug, Clone)]
pub enum Event {
Selection(crate::app::multiselect::Event),
ModelRemoval(Box<crate::app::saved_removal::Event>),
Remove(crate::app::diagram_removal::Intent),
Choose(Tool),
CreateView(Id),
@@ -104,7 +105,9 @@ impl Event {
match self {
Self::Remove(intent) => Some(intent.context),
// Group events carry their own exact selection envelope.
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
Self::Selection(_) | Self::ModelRemoval(_) | Self::Choose(_) | Self::CreateView(_) => {
None
}
Self::Select(c, _)
| Self::Pan(c, _)
| Self::Zoom(c, _)
@@ -328,7 +328,10 @@ fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
let mut state = State::default();
use keyboard::key::Named;
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
let Some(Event::ModelRemoval(removal)) = group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT) else {
panic!("Shift+Delete must capture the complete model-removal group");
};
assert!(matches!(*removal, crate::app::saved_removal::Event::Begin { snapshot, .. } if snapshot.targets() == selection.targets()));
let at = screen(&canvas, header(&canvas, f.a));
motion(&canvas, &mut state, at, at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
+13 -4
View File
@@ -43,6 +43,7 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
fn event(e: Event) -> Message {
match e {
Event::Selection(selection) => Message::DiagramSelection(selection),
Event::ModelRemoval(removal) => Message::SavedRemoval(removal),
other => Message::StateDiagram(other),
}
}
@@ -169,6 +170,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
);
}
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
removal_input: &app.saved_removal_input,
project: p,
diagram: id,
context,
@@ -187,7 +189,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
.into();
out = out.push(
column![
container(missing_controls).id("state-missing-picker"),
missing_controls,
selection_controls(app),
crate::diagram::focus::wrap(
canvas.map(event),
@@ -223,15 +225,22 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
));
}
controls = controls.push(
button(text("Delete from model…").size(12))
.padding([6, 8])
.on_press_maybe(
crate::app::saved_removal::begin(app).filter(|_| app.saved_removal_enabled()),
),
);
controls = controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
))
.push(
label(format!(
"Properties edit only {}",
crate::views::short(app.project().named(app.selected), 18)
"Edit only {}",
crate::views::short(app.project().named(app.selected), 12)
))
.wrapping(iced::widget::text::Wrapping::None),
);
@@ -5,7 +5,9 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
pub(crate) mod group_removal;
pub(crate) mod group_reorder;
use crate::removal_input;
mod usages;
mod view;
#[cfg(test)]
@@ -80,6 +82,7 @@ impl std::fmt::Display for ControlChoice {
}
#[derive(Default)]
pub struct State {
removal_input: removal_input::Lifetime,
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
@@ -128,6 +131,10 @@ struct Draft {
contextual: Option<contextual::Draft>,
}
impl State {
pub(crate) fn removal_generation(&self) -> Option<u64> {
self.removal_input.idle()
}
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.tool,
@@ -1104,6 +1111,7 @@ const PAINT_PASSES: [PaintPass; 5] = [
];
mod contextual_routing;
struct Graph<'a> {
removal_input: removal_input::Lifetime,
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
camera_pan: iced::Vector,
@@ -1401,6 +1409,7 @@ impl<'a> Graph<'a> {
}
Self {
project: p,
removal_input: removal_input::Lifetime::default(),
interaction: None,
camera_pan: iced::Vector::ZERO,
pending_insertion: None,
@@ -6,6 +6,7 @@ use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
pub(super) struct GroupPress {
_removal_lease: super::super::removal_input::Lease,
snapshot: Snapshot,
target: Target,
start: Point,
@@ -108,6 +109,12 @@ impl Graph<'_> {
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
if matches!(
event,
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
) {
self.removal_input.revoke_idle();
}
match event {
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
@@ -205,21 +212,46 @@ impl Graph<'_> {
return None;
}
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
if matches!(
event,
canvas::Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(Named::Delete),
repeat: false,
..
})
) && (snapshot.targets().len() > 1
|| snapshot.targets().iter().any(|t| match t {
Target::ProcessPreviewLink { .. } => true,
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
_ => false,
}))
if let canvas::Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(Named::Delete),
modifiers,
repeat,
..
}) = event
&& (snapshot.targets().len() > 1
|| snapshot.targets().iter().any(|t| match t {
Target::ProcessPreviewLink { .. } => true,
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
_ => false,
}))
{
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
if *repeat
|| tool != CanvasTool::Select
|| !(modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|| state.press.is_some()
|| state.source.is_some()
{
return Some(Action::capture());
}
if snapshot.targets().len() > 1 {
let Some(input_generation) = self.removal_input.idle() else {
return Some(Action::capture());
};
return Some(
Action::publish(Message::ProcessRemoval(Box::new(
crate::app::process_removal::Event::Begin {
input_generation,
context,
snapshot: Box::new(snapshot.clone()),
},
)))
.and_capture(),
);
}
return publish(SelectionEvent::Refuse(
snapshot.clone(),
"Open the referenced definition to remove qualified contributions.".into(),
));
}
let pointer = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
@@ -228,6 +260,9 @@ impl Graph<'_> {
if !bounds.contains(pointer) {
return None;
}
if matches!(event, canvas::Event::Mouse(mouse::Event::ButtonPressed(_))) {
self.removal_input.revoke_idle();
}
let world = Point::new(
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
@@ -310,7 +345,11 @@ impl Graph<'_> {
state.tool = tool;
state.bounds = Some(bounds);
state.pointer = Some(pointer);
let Some(lease) = self.removal_input.acquire() else {
return Some(Action::capture());
};
state.group = Some(GroupPress {
_removal_lease: lease,
snapshot: snapshot.clone(),
target,
start: pointer,
@@ -171,7 +171,7 @@ fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion()
}
#[test]
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
fn whole_mixed_set_reviews_delete_and_refuses_drag_and_connect_without_singleton_bypass() {
let f = fixture(Layer::Sa);
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
graph.selected = f.link;
@@ -187,7 +187,7 @@ fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
};
assert!(matches!(
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if snapshot.targets().len() == 2)
));
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
assert!(matches!(
@@ -401,3 +401,99 @@ fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_hand
}
}
}
#[test]
fn group_delete_modifiers_repeat_and_active_gesture_never_emit_singleton_or_confirmation() {
use iced::keyboard::{Event as KeyEvent, Modifiers, key::Named};
let f = fixture(Layer::Oa);
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
let start = point(&graph, f.nodes[0], false);
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::CTRL,
Modifiers::ALT,
] {
for repeat in [false, true] {
let iced::Event::Keyboard(mut key) =
crate::behavior::canvas_workspace::tests::key(Named::Delete)
else {
panic!("key")
};
if let KeyEvent::KeyPressed {
modifiers: value,
repeat: value_repeat,
..
} = &mut key
{
*value = modifiers;
*value_repeat = repeat;
}
let message = send(
&graph,
&mut Input::default(),
canvas::Event::Keyboard(key),
start,
);
if !repeat && (modifiers.is_empty() || modifiers == Modifiers::SHIFT) {
assert!(
matches!(message, Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if Some(&**snapshot) == graph.selection.as_ref()))
);
} else {
assert!(message.is_none());
}
}
}
let mut input = Input::default();
press(&graph, &mut input, start);
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(Named::Delete)
else {
panic!("key")
};
assert!(send(&graph, &mut input, canvas::Event::Keyboard(key), start).is_none());
assert!(input.group.is_some());
}
#[test]
fn process_removal_gesture_lease_retires_on_release_cancel_and_widget_state_drop() {
let f = fixture(Layer::Oa);
for end in ["release", "escape", "drop", "unfocus"] {
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
let before = graph.removal_input.idle().unwrap();
let at = point(&graph, f.nodes[0], false);
let mut input = Input::default();
assert!(press(&graph, &mut input, at).is_none());
assert!(input.group.is_some());
assert!(graph.removal_input.idle().is_none());
match end {
"release" => {
release(&graph, &mut input, at);
}
"escape" => {
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
iced::keyboard::key::Named::Escape,
) else {
panic!("key")
};
send(&graph, &mut input, canvas::Event::Keyboard(key), at);
}
"unfocus" => {
send(
&graph,
&mut input,
canvas::Event::Window(iced::window::Event::Unfocused),
at,
);
}
_ => {
drop(input);
}
}
assert!(graph.removal_input.idle().is_some());
assert_ne!(
graph.removal_input.idle(),
Some(before),
"{end} cannot restore an old Begin generation"
);
}
}
@@ -0,0 +1,148 @@
//! Exact local Process/FunctionalChain removal and definition-level interpretation
//! effects. Visibility, source drafts and captured UI lifetime belong to the App.
use super::*;
use crate::diagram_usage::Target;
#[cfg(test)]
use crate::removal_effects::Interpretation;
pub(crate) use crate::removal_effects::InterpretationChange;
#[derive(Debug)]
pub(crate) struct Plan {
pub(crate) command: Command,
pub(crate) removed: BTreeSet<Id>,
pub(crate) changed: BTreeSet<Id>,
pub(crate) interpretation: Vec<InterpretationChange>,
/// Definitions whose authored topology changed, including changes that do
/// not switch interpretation. Values are surviving transitive callers.
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
}
fn changed_records<T: PartialEq>(
before: &BTreeMap<Id, T>,
after: &BTreeMap<Id, T>,
changed: &mut BTreeSet<Id>,
) {
changed.extend(
before
.iter()
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
);
}
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
let element = project
.elements
.get(&owner)
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.ok_or("Choose an existing Process or Functional Chain.")?;
if targets.is_empty() {
return Err("Select at least one local contribution or maintained link to remove.".into());
}
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
let mut listed = BTreeSet::new();
for &id in element
.steps()
.ok_or("This owner has no contribution order.")?
{
if !is_contribution(project, owner, id)
|| project.elements[&id].layer() != element.layer()
|| !listed.insert(id)
{
return Err(
"Every process step must name a unique local contribution before removal.".into(),
);
}
}
let mut selected = BTreeSet::new();
let mut occurrences = BTreeSet::new();
let mut links = BTreeSet::new();
// Admit ALL original typed targets before any dependency normalization.
for target in targets {
let id = match target {
Target::ProcessOccurrence(endpoint) => {
if !endpoint.references.is_empty() {
return Err("Open the referenced definition to remove its contents.".into());
}
if !listed.contains(&endpoint.occurrence) {
return Err("Every selected contribution must be listed directly in this process.".into());
}
occurrences.insert(endpoint.occurrence);
endpoint.occurrence
}
Target::ProcessLink(id) => {
let relation = project.relations.get(id).ok_or("A selected maintained link no longer exists.")?;
let local = match &relation.body {
RelationBody::SequenceLink { source, target, .. } => listed.contains(source) && listed.contains(target),
RelationBody::ContextualLink { owner: actual, .. } => *actual == owner,
_ => false,
};
if !local {
return Err("Every selected link must be maintained locally by this process, with listed local endpoints for sequence links.".into());
}
links.insert(*id);
*id
}
_ => return Err("Remove only local process contributions and maintained links together; referenced preview links are read-only here.".into()),
};
if !selected.insert(id) {
return Err("Every selected process subject must be unique.".into());
}
}
// Contribution kinds cannot contain one another; only admitted links can be
// covered by an occurrence cascade. Ancestry alone misses incident links.
let covered: BTreeSet<_> = occurrences
.iter()
.flat_map(|id| oa_core::references::deletion_impact(project, *id))
.collect();
let command = Command::Batch(
occurrences
.into_iter()
.chain(links.into_iter().filter(|id| !covered.contains(id)))
.map(Command::Delete)
.collect(),
);
let candidate = execute_batch(project, std::slice::from_ref(&command))
.map_err(|error| error.to_string())?;
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
let retained: BTreeSet<_> = candidate
.identities()
.into_iter()
.map(|(id, _)| id)
.collect();
if !retained.is_subset(&original) {
return Err("Removal unexpectedly created model identities; no change was applied.".into());
}
let removed: BTreeSet<_> = original.difference(&retained).copied().collect();
if !selected.is_subset(&removed) {
return Err("The complete selected set could not be removed.".into());
}
let mut changed = BTreeSet::new();
changed_records(&project.elements, &candidate.elements, &mut changed);
changed_records(&project.relations, &candidate.relations, &mut changed);
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
changed_records(
&project.artifact_links,
&candidate.artifact_links,
&mut changed,
);
changed_records(&project.traces, &candidate.traces, &mut changed);
for (&id, diagram) in &project.diagrams {
if let Some(after) = candidate.diagrams.get(&id) {
changed_records(&diagram.nodes, &after.nodes, &mut changed);
}
}
let crate::removal_effects::DerivedEffects {
interpretation,
affected_uses,
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
Ok(Plan {
command,
removed,
changed,
interpretation,
affected_uses,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,909 @@
use super::*;
use crate::document::Document;
use oa_core::{MessageKind, source_project::ProjectLimits};
fn endpoint(id: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence: id,
}
}
fn local(id: Id) -> Target {
Target::ProcessOccurrence(endpoint(id))
}
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let element = occurrence(project, owner, name, body);
let id = element.id;
project.elements.insert(id, element);
project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn action(project: &mut Project, owner: Id, name: &str, work: Id) -> Id {
add(
project,
owner,
name,
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
)
}
fn exchange_step(project: &mut Project, owner: Id, name: &str, exchange: Id) -> Id {
add(
project,
owner,
name,
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
)
}
fn sequence(project: &mut Project, layer: Layer, name: &str) -> Id {
project
.add_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
name,
None,
)
.unwrap()
}
fn reuse(project: &mut Project, owner: Id, name: &str, definition: Id) -> Id {
add(
project,
owner,
name,
OccurrenceBody::SequenceReference {
sequence: Some(definition),
},
)
}
fn maintained(project: &mut Project, source: Id, target: Id) -> Id {
let Command::CreateRelation(relation) = link(project, source, target, "authored guard") else {
panic!()
};
let id = relation.id;
project.relations.insert(id, relation);
id
}
fn contextual(
project: &mut Project,
owner: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
purpose: ContextualLinkPurpose,
) -> Id {
let Command::CreateRelation(mut relation) =
link(project, source.occurrence, target.occurrence, "")
else {
panic!()
};
let id = relation.id;
relation.body = RelationBody::ContextualLink {
owner,
source,
target,
purpose,
};
project.relations.insert(id, relation);
id
}
fn precedence() -> ContextualLinkPurpose {
ContextualLinkPurpose::Precedence {
guard: "caller guard".into(),
}
}
fn qualified(references: &[Id], occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: references.to_vec(),
occurrence,
}
}
fn interpretation(
local: Option<Vec<Id>>,
expanded: Option<Vec<Id>>,
contextual: bool,
) -> Interpretation {
Interpretation {
local_order: local,
expanded_order: expanded,
contextual_analysis: contextual,
}
}
fn expected_without(project: &Project, owner: Id, elements: &[Id], relations: &[Id]) -> Project {
let mut expected = project.clone();
for id in elements {
expected.elements.remove(id);
}
for id in relations {
expected.relations.remove(id);
}
expected
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| !elements.contains(id));
expected
}
fn assert_history(project: &Project, expected: &Project, command: Command) {
let mut doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(command).unwrap();
assert_eq!(doc.project(), expected);
let after = doc.bundle().clone();
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), expected);
assert!(doc.undo());
assert_eq!(doc.project(), project);
assert_eq!(doc.bundle(), &original);
assert!(!doc.can_undo());
assert!(doc.redo());
assert_eq!(doc.project(), expected);
assert_eq!(doc.bundle(), &after);
assert!(!doc.can_redo());
}
#[test]
fn process_group_removal_mixed_cards_preserve_sources_definitions_and_other_uses() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut p = f.p;
let definition = sequence(&mut p, layer, "Reusable");
let leaf = action(&mut p, definition, "Leaf", f.work[0]);
let a = action(&mut p, f.owner, "Action", f.work[0]);
let e = exchange_step(&mut p, f.owner, "Exchange", f.exchange);
let c = add(
&mut p,
f.owner,
"Control",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let r = reuse(&mut p, f.owner, "First use", definition);
let r2 = reuse(&mut p, f.owner, "Preserved use", definition);
let plan = prepare(&p, f.owner, &[local(r), local(c), local(e), local(a)]).unwrap();
assert_eq!(plan.removed, BTreeSet::from([a, e, c, r]));
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
assert!(plan.interpretation.is_empty());
let expected = expected_without(&p, f.owner, &[a, e, c, r], &[]);
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[r2]);
assert!(expected.contains(leaf));
assert_history(&p, &expected, plan.command);
}
}
#[test]
fn process_group_removal_dependent_selected_links_are_normalized_after_whole_set_admission() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let [a, b, c] = f.nodes;
let parallel = maintained(&mut p, a, b);
let adjacent = maintained(&mut p, b, c);
let context = contextual(
&mut p,
f.owner,
endpoint(a),
endpoint(b),
ContextualLinkPurpose::Exchange {
exchange: f.exchange,
},
);
let targets = vec![
Target::ProcessLink(f.link),
local(a),
Target::ProcessLink(parallel),
local(b),
Target::ProcessLink(context),
];
let first = prepare(&p, f.owner, &targets).unwrap();
let mut reverse = targets.clone();
reverse.reverse();
let second = prepare(&p, f.owner, &reverse).unwrap();
assert_eq!(first.command, second.command);
assert_eq!(
first.removed,
BTreeSet::from([a, b, f.link, parallel, adjacent, context])
);
assert_eq!(first.changed, BTreeSet::from([f.owner]));
assert_eq!(
first.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(None, None, true),
after: interpretation(None, None, false),
callers: BTreeSet::new(),
}]
);
let Command::Batch(commands) = &first.command else {
panic!()
};
assert_eq!(
commands.len(),
2,
"All selected links are covered by admitted card roots"
);
let expected =
expected_without(&p, f.owner, &[a, b], &[f.link, parallel, adjacent, context]);
assert_history(&p, &expected, first.command);
}
}
#[test]
fn process_group_removal_refuses_every_invalid_original_typed_member() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let other = sequence(&mut p, layer, "Other");
let foreign = action(&mut p, other, "Foreign", f.work[0]);
let foreign_other = action(&mut p, other, "Other foreign", f.work[0]);
let unlisted = action(&mut p, f.owner, "Owned but unlisted", f.work[0]);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unlisted);
let unlisted_link = maintained(&mut p, unlisted, f.nodes[0]);
let foreign_context = contextual(
&mut p,
other,
endpoint(foreign),
endpoint(foreign_other),
ContextualLinkPurpose::CommonWork { work: f.work[0] },
);
// Use two separate qualified paths with the same leaf: neither can be
// silently reduced to that leaf or to an outer reference occurrence.
let r1 = reuse(&mut p, f.owner, "Use one", other);
let r2 = reuse(&mut p, f.owner, "Use two", other);
let missing = Id::new_v4();
let bad = vec![
Target::Whole,
Target::Placement(f.nodes[0]),
Target::Port {
port: f.work[0],
placement: f.nodes[0],
},
Target::Edge(f.link),
Target::StateRow {
placement: f.nodes[0],
relation: f.link,
},
Target::AllocationGrouping {
relation: f.link,
work_placement: f.nodes[0],
performer_placement: f.nodes[1],
},
Target::ScenarioObject(f.nodes[0]),
Target::ProcessPreviewLink {
relation: f.link,
source: endpoint(f.nodes[0]),
target: endpoint(f.nodes[1]),
},
Target::ProcessOccurrence(qualified(&[r1], foreign)),
Target::ProcessOccurrence(qualified(&[r2], foreign)),
local(foreign),
local(unlisted),
local(missing),
local(f.work[0]),
Target::ProcessLink(unlisted_link),
Target::ProcessLink(foreign_context),
Target::ProcessLink(f.exchange),
Target::ProcessLink(missing),
Target::ProcessLink(f.nodes[0]),
];
oa_core::session::validate_candidate(&p).unwrap();
let source = oa_core::language::format(&p);
for target in bad {
assert!(
prepare(&p, f.owner, &[local(f.nodes[0]), target.clone()]).is_err(),
"{target:?}"
);
assert!(prepare(&p, f.owner, &[target, local(f.nodes[0])]).is_err());
}
assert!(prepare(&p, f.owner, &[]).is_err());
assert!(prepare(&p, f.owner, &[local(f.nodes[0]), local(f.nodes[0])]).is_err());
assert!(
prepare(
&p,
f.owner,
&[Target::ProcessLink(f.link), Target::ProcessLink(f.link)]
)
.is_err()
);
assert!(prepare(&p, f.work[0], &[local(f.nodes[0])]).is_err());
assert!(prepare(&p, missing, &[local(f.nodes[0])]).is_err());
assert_eq!(oa_core::language::format(&p), source);
let mut duplicate_steps = p.clone();
duplicate_steps
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(f.nodes[0]);
assert!(prepare(&duplicate_steps, f.owner, &[local(f.nodes[1])]).is_err());
let mut wrong_parent = p.clone();
wrong_parent.elements.get_mut(&f.nodes[0]).unwrap().parent = Some(other);
assert!(prepare(&wrong_parent, f.owner, &[local(f.nodes[1])]).is_err());
let mut missing_step = p.clone();
missing_step
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(missing);
assert!(prepare(&missing_step, f.owner, &[local(f.nodes[1])]).is_err());
}
}
#[test]
fn process_group_removal_last_markers_and_legacy_interior_report_literal_orders() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut p = f.p;
let a = action(&mut p, f.owner, "Marker", f.work[0]);
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
let c = add(
&mut p,
f.owner,
"Control",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let e2 = exchange_step(&mut p, f.owner, "Middle", f.exchange);
let e3 = exchange_step(&mut p, f.owner, "Last", f.exchange);
let caller = sequence(&mut p, layer, "Caller");
reuse(&mut p, caller, "Reference", f.owner);
let outer = sequence(&mut p, layer, "Outer");
reuse(&mut p, outer, "Transitive reference", caller);
let plan = prepare(&p, f.owner, &[local(c), local(a)]).unwrap();
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(None, None, false),
after: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
callers: BTreeSet::from([caller, outer]),
}]
);
let expected = expected_without(&p, f.owner, &[a, c], &[]);
assert_history(&p, &expected, plan.command);
let bridge = prepare(&expected, f.owner, &[local(e2)]).unwrap();
assert_eq!(
bridge.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
after: interpretation(Some(vec![e1, e3]), Some(vec![e1, e3]), false),
callers: BTreeSet::from([caller, outer]),
}]
);
assert_eq!(bridge.removed, BTreeSet::from([e2]));
assert_history(
&expected,
&expected_without(&expected, f.owner, &[e2], &[]),
bridge.command,
);
}
}
#[test]
fn process_group_removal_last_link_and_zero_or_one_exchange_have_no_invented_objects() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut p = f.p;
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
let e2 = exchange_step(&mut p, f.owner, "Second", next);
let link = maintained(&mut p, e1, e2);
let plan = prepare(&p, f.owner, &[Target::ProcessLink(link)]).unwrap();
assert_eq!(plan.removed, BTreeSet::from([link]));
assert!(plan.changed.is_empty());
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(None, None, false),
after: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2]), false),
callers: BTreeSet::new(),
}]
);
assert_history(
&p,
&expected_without(&p, f.owner, &[], &[link]),
plan.command,
);
for remaining in [vec![], vec![e1]] {
let mut model = expected_without(&p, f.owner, &[e2], &[link]);
if remaining.is_empty() {
model = expected_without(&model, f.owner, &[e1], &[]);
}
let marker = action(&mut model, f.owner, "Marker", f.work[0]);
let plan = prepare(&model, f.owner, &[local(marker)]).unwrap();
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(None, None, false),
after: interpretation(Some(remaining.clone()), Some(remaining), false),
callers: BTreeSet::new(),
}]
);
assert_history(
&model,
&expected_without(&model, f.owner, &[marker], &[]),
plan.command,
);
}
}
}
#[test]
fn process_group_removal_global_contextual_dependencies_and_caller_modes_are_separate_from_record_deltas()
{
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let caller = sequence(&mut p, layer, "Caller");
let r1 = reuse(&mut p, caller, "Use one", f.owner);
let r2 = reuse(&mut p, caller, "Use two", f.owner);
let outer = sequence(&mut p, layer, "Outer");
let outer_use = reuse(&mut p, outer, "Outer use", caller);
let caller_link = contextual(
&mut p,
caller,
qualified(&[r1], f.nodes[0]),
qualified(&[r2], f.nodes[1]),
precedence(),
);
let outer_link = contextual(
&mut p,
outer,
qualified(&[outer_use, r1], f.nodes[0]),
qualified(&[outer_use, r2], f.nodes[1]),
precedence(),
);
let top = sequence(&mut p, layer, "Top");
reuse(&mut p, top, "Top use", outer);
let plan = prepare(&p, f.owner, &[local(f.nodes[0])]).unwrap();
assert_eq!(
plan.removed,
BTreeSet::from([f.nodes[0], f.link, caller_link, outer_link])
);
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
let by_owner: BTreeMap<_, _> = plan
.interpretation
.iter()
.map(|change| (change.owner, change))
.collect();
assert_eq!(
by_owner.keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([caller, outer, top])
);
for id in [caller, outer, top] {
assert_eq!(by_owner[&id].before, interpretation(None, None, true));
assert_eq!(by_owner[&id].after, interpretation(None, None, false));
}
assert_eq!(by_owner[&caller].callers, BTreeSet::from([outer, top]));
assert_eq!(by_owner[&outer].callers, BTreeSet::from([top]));
assert!(by_owner[&top].callers.is_empty());
assert_eq!(
plan.affected_uses,
BTreeMap::from([
(f.owner, BTreeSet::from([caller, outer, top])),
(caller, BTreeSet::from([outer, top])),
(outer, BTreeSet::from([top])),
])
);
let expected = expected_without(
&p,
f.owner,
&[f.nodes[0]],
&[f.link, caller_link, outer_link],
);
assert_history(&p, &expected, plan.command);
// Removing an outer reuse removes only that use and paths through it.
let outer_plan =
prepare(&p, caller, &[local(r1), Target::ProcessLink(caller_link)]).unwrap();
assert_eq!(
outer_plan.removed,
BTreeSet::from([r1, caller_link, outer_link])
);
let expected = expected_without(&p, caller, &[r1], &[caller_link, outer_link]);
assert_history(&p, &expected, outer_plan.command);
}
}
#[test]
fn process_group_removal_qualified_local_relations_preserve_every_purpose_source() {
for layer in [Layer::Oa, Layer::Sa] {
for purpose_kind in 0..3 {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let owner = sequence(&mut p, layer, "Assembly");
let r1 = reuse(&mut p, owner, "First", f.owner);
let r2 = reuse(&mut p, owner, "Second", f.owner);
let (target, purpose) = match purpose_kind {
0 => (f.nodes[1], precedence()),
1 => (
f.nodes[1],
ContextualLinkPurpose::Exchange {
exchange: f.exchange,
},
),
_ => (
f.nodes[0],
ContextualLinkPurpose::CommonWork { work: f.work[0] },
),
};
let relation = contextual(
&mut p,
owner,
qualified(&[r1], f.nodes[0]),
qualified(&[r2], target),
purpose,
);
let plan = prepare(&p, owner, &[Target::ProcessLink(relation)]).unwrap();
assert_eq!(plan.removed, BTreeSet::from([relation]));
assert!(plan.changed.is_empty());
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner,
before: interpretation(None, None, true),
after: interpretation(None, None, false),
callers: BTreeSet::new(),
}]
);
assert_history(
&p,
&expected_without(&p, owner, &[], &[relation]),
plan.command,
);
}
}
}
#[test]
fn process_group_removal_predicate_discriminators_include_contextual_only_and_unlisted_contributions()
{
for layer in [Layer::Oa, Layer::Sa] {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut p = f.p;
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
let e2 = exchange_step(&mut p, f.owner, "Second", f.exchange);
// Core-valid incomplete source: desktop lists only exchanges, while the
// index includes two unlisted actions and their CommonWork relation.
let a1 = action(&mut p, f.owner, "Unlisted one", f.work[0]);
let a2 = action(&mut p, f.owner, "Unlisted two", f.work[0]);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != a1 && *id != a2);
let relation = contextual(
&mut p,
f.owner,
endpoint(a1),
endpoint(a2),
ContextualLinkPurpose::CommonWork { work: f.work[0] },
);
let plan = prepare(&p, f.owner, &[Target::ProcessLink(relation)]).unwrap();
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(Some(vec![e1, e2]), None, true),
after: interpretation(Some(vec![e1, e2]), None, false),
callers: BTreeSet::new(),
}]
);
let mut p = expected_without(&p, f.owner, &[a1, a2], &[relation]);
let unlisted = action(&mut p, f.owner, "Unlisted marker", f.work[0]);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unlisted);
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(Some(vec![e1, e2]), None, false),
after: interpretation(Some(vec![e2]), None, false),
callers: BTreeSet::new(),
}]
);
assert!(prepare(&p, f.owner, &[local(unlisted), local(e1)]).is_err());
let p = expected_without(&p, f.owner, &[unlisted], &[]);
let mut p = p;
let extra = exchange_step(&mut p, f.owner, "Unlisted exchange", f.exchange);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != extra);
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
assert_eq!(
plan.interpretation,
vec![InterpretationChange {
owner: f.owner,
before: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2, extra]), false),
after: interpretation(Some(vec![e2]), Some(vec![e2, extra]), false),
callers: BTreeSet::new(),
}]
);
assert_history(&p, &expected_without(&p, f.owner, &[e1], &[]), plan.command);
}
}
#[test]
fn process_group_removal_explicit_topology_still_reports_transitive_reused_effects() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
maintained(&mut p, f.nodes[1], f.nodes[2]);
let caller = sequence(&mut p, layer, "Caller");
reuse(&mut p, caller, "First use", f.owner);
reuse(&mut p, caller, "Repeated use", f.owner);
let outer = sequence(&mut p, layer, "Outer");
reuse(&mut p, outer, "Transitive use", caller);
for target in [local(f.nodes[0]), Target::ProcessLink(f.link)] {
let plan = prepare(&p, f.owner, &[target]).unwrap();
assert!(
plan.interpretation.is_empty(),
"Explicit modes remain unchanged"
);
assert_eq!(
plan.affected_uses,
BTreeMap::from([(f.owner, BTreeSet::from([caller, outer]))])
);
assert!(!plan.changed.contains(&caller));
assert!(!plan.changed.contains(&outer));
}
}
}
#[test]
fn process_group_removal_mixed_covered_and_independent_selected_links_delete_each_once() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let [a, b, c] = f.nodes;
let independent = maintained(&mut p, b, c);
let targets = [
Target::ProcessLink(independent),
Target::ProcessLink(f.link),
local(a),
];
let plan = prepare(&p, f.owner, &targets).unwrap();
let reverse = prepare(&p, f.owner, &targets.into_iter().rev().collect::<Vec<_>>()).unwrap();
assert_eq!(plan.command, reverse.command);
assert_eq!(plan.removed, BTreeSet::from([a, f.link, independent]));
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
let Command::Batch(commands) = &plan.command else {
panic!()
};
assert_eq!(
commands,
&[Command::Delete(a), Command::Delete(independent)]
);
assert!(plan.interpretation.is_empty());
let expected = expected_without(&p, f.owner, &[a], &[f.link, independent]);
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[b, c]);
assert_history(&p, &expected, plan.command);
}
}
#[test]
fn process_group_removal_exact_attachments_traces_placements_and_diagram_impact() {
use oa_core::{ArtifactLink, ArtifactRelation, EdgeRoute, Position, Trace, TraceKind};
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let [a, b, _] = f.nodes;
let overview = p.ensure_diagram(layer);
// Overview explicitly includes Occurrence in ProjectionKind::includes;
// saved placement validation admits these same-layer semantic elements.
assert!(p.diagrams[&overview].projection.includes(Kind::Occurrence));
let node = p.place(overview, a, Position { x: 100., y: 100. });
let kept_node = p.place(overview, b, Position { x: 400., y: 100. });
p.diagrams
.get_mut(&overview)
.unwrap()
.routes
.push(EdgeRoute {
edge: f.link,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 300., y: 80. }],
label: None,
});
let other_view = Id::new_v4();
let mut view = p.diagrams[&overview].clone();
view.id = other_view;
view.alias = p.fresh_alias("second_overview", "view", other_view);
view.name = "Second overview".into();
view.nodes.clear();
view.routes.clear();
p.diagrams.insert(other_view, view);
let other_node = p.place(other_view, a, Position { x: 220., y: 160. });
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let counterpart = p
.add_element(
if opposite == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
opposite,
"Preserved counterpart",
None,
)
.unwrap();
let unrelated_view = p.ensure_diagram(opposite);
p.place(unrelated_view, counterpart, Position { x: 50., y: 75. });
let traces: [Id; 2] = [a, b].map(|subject| {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: p.fresh_alias("manual_trace", "trace", id),
name: "Manual correspondence".into(),
description: "Preserve counterpart".into(),
oa: if layer == Layer::Oa {
subject
} else {
counterpart
},
sa: if layer == Layer::Sa {
subject
} else {
counterpart
},
kind: TraceKind::Manual,
baseline: None,
},
);
id
});
// Include an attachment owned by the dependent placement: core impact
// must follow the fixed point rather than only direct semantic subjects.
let artifacts: [Id; 4] = [a, node, f.link, b].map(|owner| {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias("evidence", "artifact", id),
name: "Evidence".into(),
description: "Authored evidence".into(),
owner,
relation: ArtifactRelation::References,
uri: "evidence.txt".into(),
revision: Some("v1".into()),
fragment: Some("review".into()),
},
);
id
});
let constraint = p
.add_element(Kind::Constraint, layer, "Surviving constraint", None)
.unwrap();
*p.elements
.get_mut(&constraint)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Constraint {
expression: "review".into(),
constrained: vec![a, b],
};
oa_core::session::validate_candidate(&p).unwrap();
let plan = prepare(&p, f.owner, &[local(a)]).unwrap();
assert_eq!(
plan.removed,
BTreeSet::from([
a,
f.link,
node,
other_node,
traces[0],
artifacts[0],
artifacts[1],
artifacts[2],
])
);
assert_eq!(
plan.changed,
BTreeSet::from([f.owner, overview, other_view, constraint])
);
assert_eq!(
plan.affected_uses,
BTreeMap::from([(f.owner, BTreeSet::new())])
);
let mut expected = expected_without(&p, f.owner, &[a], &[f.link]);
expected.traces.remove(&traces[0]);
for id in &artifacts[..3] {
expected.artifact_links.remove(id);
}
expected
.diagrams
.get_mut(&overview)
.unwrap()
.nodes
.remove(&node);
expected.diagrams.get_mut(&overview).unwrap().routes.clear();
expected
.diagrams
.get_mut(&other_view)
.unwrap()
.nodes
.remove(&other_node);
let Some(SharedBody::Constraint { constrained, .. }) =
expected.elements.get_mut(&constraint).unwrap().shared_mut()
else {
panic!()
};
*constrained = vec![b];
assert_eq!(expected.elements[&counterpart], p.elements[&counterpart]);
assert_eq!(
expected.diagrams[&overview].nodes[&kept_node],
p.diagrams[&overview].nodes[&kept_node]
);
assert_eq!(
expected.diagrams[&unrelated_view],
p.diagrams[&unrelated_view]
);
assert_eq!(expected.traces[&traces[1]], p.traces[&traces[1]]);
assert_eq!(
expected.artifact_links[&artifacts[3]],
p.artifact_links[&artifacts[3]]
);
assert_history(&p, &expected, plan.command);
}
}
@@ -185,6 +185,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.contextual_expanded,
);
graph.selection = app.multiselect_snapshot();
graph.removal_input = state.removal_input.clone();
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
@@ -574,6 +575,15 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
.align_y(iced::Center)
.height(32);
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
if snapshot.targets().len() > 1 {
controls = controls.push(
button("Remove selected…").on_press_maybe(
app.process_removal_enabled()
.then(|| crate::app::process_removal::begin(app))
.flatten(),
),
);
}
controls = controls.push(button("Clear selection").on_press_maybe(
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
));
+77 -3
View File
@@ -43,6 +43,7 @@ pub use port_labels::PortLabels;
#[path = "diagram/focus.rs"]
pub(crate) mod focus;
pub(crate) mod group_gesture;
pub(crate) mod group_removal;
mod multiselect;
#[cfg(test)]
mod multiselect_tests;
@@ -52,6 +53,7 @@ pub use work_drag::WorkFeedback;
#[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> {
pub removal_input: &'a crate::removal_input::Lifetime,
pub selection: Option<&'a crate::diagram_selection::Selection>,
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
pub project: &'a Project,
@@ -71,6 +73,8 @@ pub struct DiagramCanvas<'a> {
}
#[derive(Default)]
pub struct State {
removal_lease: Option<crate::removal_input::Lease>,
removal_viewport: Option<(f32, Vector, Rectangle)>,
modifiers: iced::keyboard::Modifiers,
group: Option<multiselect::Press>,
work: Option<Arc<crate::app::allocation_drag::Press>>,
@@ -606,9 +610,8 @@ impl DiagramCanvas<'_> {
});
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
impl DiagramCanvas<'_> {
fn update_input(
&self,
state: &mut State,
event: &canvas::Event,
@@ -1243,6 +1246,67 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
_ => None,
}
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
self.removal_input.sync(
&mut state.removal_lease,
state.group.is_some()
|| state.work.is_some()
|| state.drag.is_some()
|| state.pan_start.is_some()
|| state.route_drag.is_some()
|| state.connection_press.is_some()
|| state.port_drag.is_some(),
);
self.removal_input.observe(event);
let viewport = (self.zoom, self.pan, bounds);
if state
.removal_viewport
.is_some_and(|previous| previous != viewport)
{
self.removal_input.revoke_idle();
}
state.removal_viewport = Some(viewport);
let action = self.update_input(state, event, bounds, cursor);
if matches!(
event,
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
) {
// The viewport can synthesize Unfocused when it takes camera input.
// Never leave a private drag or its lease waiting for a lost release.
state.group = None;
state.work = None;
state.drag = None;
state.preview = None;
state.pan_start = None;
state.route_drag = None;
state.attachment_viewport = None;
state.connection_press = None;
state.connection_hover = None;
state.port_drag = None;
state.pointer = None;
}
self.removal_input.sync(
&mut state.removal_lease,
state.group.is_some()
|| state.work.is_some()
|| state.drag.is_some()
|| state.pan_start.is_some()
|| state.route_drag.is_some()
|| state.connection_press.is_some()
|| state.port_drag.is_some(),
);
action
}
fn draw(
&self,
state: &State,
@@ -2328,6 +2392,7 @@ mod tests {
y: original.y - 200.,
};
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -2406,6 +2471,7 @@ mod tests {
.unwrap()
.id;
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -2535,6 +2601,7 @@ mod tests {
};
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -2711,6 +2778,7 @@ mod tests {
for zoom in [0.5, 1., 1.5] {
let (p, diagram, edge) = route_fixture();
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -2790,6 +2858,7 @@ mod tests {
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
let (p, diagram, edge) = route_fixture();
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -2890,6 +2959,7 @@ mod tests {
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
let (p, diagram, edge) = route_fixture();
let mut canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -3021,6 +3091,7 @@ mod tests {
assert!(!svg.contains("data-navigation=\"source\""));
assert!(svg.contains("data-navigation=\"target\""));
let mut canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -3078,6 +3149,7 @@ mod tests {
offset: Position { x: 0., y: -160. },
});
let mut canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -3155,6 +3227,7 @@ mod tests {
},
});
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -3191,6 +3264,7 @@ mod tests {
routed.edges.insert(duplicate, duplicate_route);
let selected = edge.max(duplicate); // UUID ordering must not override selection.
let canvas = DiagramCanvas {
removal_input: &crate::removal_input::Lifetime::default(),
selection: None,
project: &p,
view_index: None,
@@ -98,6 +98,7 @@ fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
@@ -34,6 +34,7 @@ fn fixture() -> App {
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
allocation: None,
project: app.project(),
+10
View File
@@ -206,6 +206,16 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
// The focus task runs after the native batch. Retain focus for its
// traversal count, but reject later keys in this same batch now.
state.traversing = true;
self.content.as_widget_mut().update(
&mut tree.children[0],
&InputEvent::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
shell.publish((self.traverse)(modifiers.shift()));
shell.capture_event();
return;
@@ -0,0 +1,161 @@
//! Pure, whole-selection Delete from model planning for saved architecture and
//! State views. UI lifetime, drafts and confirmation remain the caller's concern.
use crate::diagram_usage::Target;
use oa_core::{
Command, Id, Kind, Project, execute_batch, projection, references, validate_candidate,
};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug)]
pub(crate) struct Plan {
pub(crate) command: Command,
/// Semantic identities resolved from every admitted exact target.
pub(crate) selected: BTreeSet<Id>,
/// Actual identity and surviving-record differences after the complete batch.
pub(crate) removed: BTreeSet<Id>,
pub(crate) changed: BTreeSet<Id>,
pub(crate) interpretation: Vec<crate::removal_effects::InterpretationChange>,
/// Changed or deleted definitions and surviving transitive callers.
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
}
pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result<Plan, String> {
let saved = project
.diagrams
.get(&diagram)
.ok_or("The saved view no longer exists.")?;
if !saved.projection.is_architecture() && saved.projection.state_machine().is_none() {
return Err("Delete from model supports saved Architecture and State views here.".into());
}
if targets.is_empty() {
return Err("Select at least one visible model object to delete.".into());
}
validate_candidate(project).map_err(|error| error.to_string())?;
let view = projection::project_diagram(project, saved);
let mut selected = BTreeSet::new();
// Resolve every original typed identity BEFORE removing any cascade overlap.
// A stale/hidden/wrong-kind target cannot become admissible because another
// selection member would happen to delete its semantic identity.
for (index, target) in targets.iter().enumerate() {
if targets[..index].contains(target) {
return Err("Every selected exact target must be unique.".into());
}
let id = match target {
Target::Placement(id) => saved.nodes.get(id)
.filter(|node| view.nodes.iter().any(|visible| visible.id == *id && visible.element == node.element))
.map(|node| node.element)
.ok_or("A selected placement is no longer visible in this saved view.")?,
Target::Port { port, placement } => {
let owner = saved.nodes.get(placement)
.ok_or("A selected port's exact owner placement no longer exists in this view.")?;
if !view.ports.iter().any(|visible| visible.element == *port && visible.owner == *placement)
|| !project.elements.get(port).is_some_and(|element| element.kind() == Kind::Port && element.parent == Some(owner.element))
{
return Err("A selected port is no longer visible on its exact owner placement.".into());
}
*port
}
Target::Edge(id) => {
if !view.edges.iter().any(|edge| edge.id == *id)
|| !(project.relations.contains_key(id) || project.traces.contains_key(id))
{
return Err("Every selected edge must be a visible stored relation or trace; inferred edges are read-only.".into());
}
*id
}
Target::StateRow { placement, relation } => {
if !crate::behavior::state_canvas::visible_row_target(project, diagram, &view, *placement, *relation) {
return Err("A selected State row is no longer visible on its exact placement.".into());
}
*relation
}
_ => return Err("The complete selection must contain only visible placements, ports, stored edges or State rows; grouping and read-only targets cannot be deleted here.".into()),
};
selected.insert(id);
}
// Prefer cascades covering the most remaining selected subjects. Selecting a
// parent together with its child/port/incident relation must not issue Delete
// for an identity already removed by that parent. Stable ID order breaks ties.
// Core defers conditional exchange-allocation cleanup until batch completion;
// admission and the final result are therefore never singleton-validated.
let impacts: BTreeMap<_, _> = selected
.iter()
.map(|&id| (id, references::deletion_impact(project, id)))
.collect();
let mut pending = selected.clone();
let mut commands = Vec::new();
while !pending.is_empty() {
let id = *pending
.iter()
.max_by_key(|&&id| {
(
impacts[&id].intersection(&pending).count(),
std::cmp::Reverse(id),
)
})
.unwrap();
commands.push(Command::Delete(id));
pending.retain(|remaining| !impacts[&id].contains(remaining));
}
let command = Command::Batch(commands);
let candidate = execute_batch(project, std::slice::from_ref(&command))
.map_err(|error| error.to_string())?;
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
let retained: BTreeSet<_> = candidate
.identities()
.into_iter()
.map(|(id, _)| id)
.collect();
if !retained.is_subset(&original) {
return Err("Removal unexpectedly created model identities; no change was applied.".into());
}
let removed: BTreeSet<_> = original.difference(&retained).copied().collect();
if !selected.is_subset(&removed) {
return Err("The complete selected set could not be removed.".into());
}
let mut changed = BTreeSet::new();
changed_records(&project.elements, &candidate.elements, &mut changed);
changed_records(&project.relations, &candidate.relations, &mut changed);
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
changed_records(&project.traces, &candidate.traces, &mut changed);
changed_records(
&project.artifact_links,
&candidate.artifact_links,
&mut changed,
);
for (&id, diagram) in &project.diagrams {
if let Some(after) = candidate.diagrams.get(&id) {
changed_records(&diagram.nodes, &after.nodes, &mut changed);
}
}
let crate::removal_effects::DerivedEffects {
interpretation,
affected_uses,
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
Ok(Plan {
command,
selected,
removed,
changed,
interpretation,
affected_uses,
})
}
fn changed_records<T: PartialEq>(
before: &BTreeMap<Id, T>,
after: &BTreeMap<Id, T>,
changed: &mut BTreeSet<Id>,
) {
changed.extend(
before
.iter()
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
);
}
#[cfg(test)]
mod conditional_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,302 @@
//! Independent conditional-cascade checks for overlapping selected endpoint allocations.
use super::prepare;
use crate::diagram_usage::Target;
use oa_core::*;
use std::collections::BTreeSet;
struct Fixture {
project: Project,
diagram: Id,
selected_allocations: [Id; 2],
exchange_allocation: Id,
functional_exchange: Id,
}
fn relation(project: &mut Project, number: u128, alias: &str, body: RelationBody) -> Id {
let id = Id::from_u128(number);
assert!(
project
.relations
.insert(
id,
Relation {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body,
}
)
.is_none()
);
id
}
fn fixture(reverse_ids: bool, invalid_dual_mapping: bool) -> Fixture {
let mut project = Project::empty();
let performer = project
.add_element(Kind::System, Layer::Sa, "Send performer", None)
.unwrap();
let receiver = if invalid_dual_mapping {
performer
} else {
project
.add_element(Kind::SystemActor, Layer::Sa, "Receive performer", None)
.unwrap()
};
let source = project
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let target = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let retained = project.relate(RelationKind::Allocation, "Performs send", performer, source);
project.relate(
RelationKind::Allocation,
"Performs receive",
receiver,
target,
);
let functional_exchange = project.relate(
RelationKind::FunctionalExchange,
"Information",
source,
target,
);
let (output, input) = match project.relations[&functional_exchange].body {
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => (source_port, target_port),
_ => unreachable!(),
};
let mut component_ports = vec![];
for (owner, name) in [
(performer, "First component port"),
(receiver, "Second component port"),
] {
let id = Id::new_v4();
let port = make_port(
&project,
id,
owner,
name,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::InOut,
},
)
.unwrap();
project.elements.insert(id, port);
component_ports.push(id);
}
let [a, b] = [component_ports[0], component_ports[1]];
let boundary = relation(
&mut project,
300,
"boundary",
RelationBody::BoundaryExchange {
source: a,
target: b,
oriented: false,
items: vec![],
},
);
// Either endpoint allocation is required. Their overlapping conditional
// cascade must remove the exchange allocation once, after one whole batch.
let numbers = if reverse_ids { [200, 100] } else { [100, 200] };
let first = relation(
&mut project,
numbers[0],
"first_output",
RelationBody::PortAllocation {
source: a,
target: output,
},
);
let second = relation(
&mut project,
numbers[1],
"second_input",
RelationBody::PortAllocation {
source: b,
target: input,
},
);
if invalid_dual_mapping {
// Two simultaneous direct/reverse mappings would need the same actual
// performer at both ends, violating the different-boundary-owners rule.
relation(
&mut project,
400,
"first_input",
RelationBody::PortAllocation {
source: a,
target: input,
},
);
relation(
&mut project,
500,
"second_output",
RelationBody::PortAllocation {
source: b,
target: output,
},
);
}
let exchange_allocation = relation(
&mut project,
150,
"realizes_exchange",
RelationBody::ExchangeAllocation {
source: boundary,
target: functional_exchange,
},
);
let diagram = project.ensure_diagram(Layer::Sa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
for (element, x) in [
(performer, 40.),
(receiver, 1360.),
(source, 480.),
(target, 920.),
] {
project.place(diagram, element, Position { x, y: 200. });
}
project.diagrams.get_mut(&diagram).unwrap().routes = [first, retained]
.into_iter()
.map(|edge| EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 360., y: 350. }],
label: None,
})
.collect();
Fixture {
project,
diagram,
selected_allocations: [first, second],
exchange_allocation,
functional_exchange,
}
}
#[test]
fn overlapping_conditional_allocation_loss_is_reviewed_from_final_candidate() {
for reverse_ids in [false, true] {
let f = fixture(reverse_ids, false);
validate_candidate(&f.project)
.expect("Separate actual performers and required endpoint mappings are valid");
for selected in f.selected_allocations {
assert!(
references::deletion_impact(&f.project, selected).contains(&f.exchange_allocation),
"Both selected allocations overlap on the same conditional dependent"
);
let singleton = execute_batch(&f.project, &[Command::Delete(selected)]).unwrap();
assert!(!singleton.relations.contains_key(&f.exchange_allocation));
}
let targets = f.selected_allocations.map(Target::Edge);
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
let reversed = prepare(
&f.project,
f.diagram,
&[targets[1].clone(), targets[0].clone()],
)
.unwrap();
assert_eq!(
serde_json::to_value(&plan.command).unwrap(),
serde_json::to_value(&reversed.command).unwrap(),
"Click order must not change normalization"
);
let removed = BTreeSet::from([
f.selected_allocations[0],
f.selected_allocations[1],
f.exchange_allocation,
]);
assert_eq!(plan.selected, BTreeSet::from(f.selected_allocations));
assert_eq!(plan.removed, removed);
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
// Literal record oracle, independent of deletion_impact / planner output.
let mut expected = f.project.clone();
for id in removed {
expected.relations.remove(&id);
}
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.remove(0);
let actual = execute_batch(&f.project, &[plan.command]).unwrap();
assert_eq!(
actual, expected,
"Only two port allocations, their conditional exchange allocation and the deleted edge's authored route change"
);
}
}
#[test]
fn direct_exchange_cascade_and_conditional_support_loss_share_one_batch() {
for reverse_ids in [false, true] {
let f = fixture(reverse_ids, false);
validate_candidate(&f.project)
.expect("Separate actual performers and required endpoint mappings are valid");
let targets = [
Target::Edge(f.selected_allocations[0]),
Target::Edge(f.functional_exchange),
Target::Edge(f.selected_allocations[1]),
];
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
let removed = BTreeSet::from([
f.selected_allocations[0],
f.selected_allocations[1],
f.functional_exchange,
f.exchange_allocation,
]);
assert_eq!(
plan.selected,
BTreeSet::from([
f.selected_allocations[0],
f.selected_allocations[1],
f.functional_exchange
])
);
assert_eq!(plan.removed, removed);
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
let mut expected = f.project.clone();
for id in removed {
expected.relations.remove(&id);
}
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.remove(0);
assert_eq!(
execute_batch(&f.project, &[plan.command]).unwrap(),
expected
);
}
}
#[test]
fn alternative_pairing_counterexample_is_not_a_valid_saved_model() {
let f = fixture(false, true);
assert!(validation::validate(&f.project).iter().any(
|issue| issue.code == "boundary_owner" && issue.severity == validation::Severity::Error
));
let before = f.project.clone();
assert!(
prepare(
&f.project,
f.diagram,
&f.selected_allocations.map(Target::Edge)
)
.is_err()
);
assert_eq!(f.project, before);
}
File diff suppressed because it is too large Load Diff
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
+16 -1
View File
@@ -178,10 +178,25 @@ impl DiagramCanvas<'_> {
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(context(self.context))?;
if *modifiers == iced::keyboard::Modifiers::SHIFT {
let Some(input_generation) = self.removal_input.idle() else {
return Some(Action::capture());
};
return Some(
Action::publish(Message::SavedRemoval(Box::new(
crate::app::saved_removal::Event::Begin {
input_generation,
context: self.context,
snapshot: Box::new(snapshot),
},
)))
.and_capture(),
);
}
let action = if modifiers.is_empty() {
Event::Hide(snapshot)
} else {
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
Event::Refuse(snapshot, "Use plain Delete to hide placements or Shift+Delete to review deletion from the model.".into())
};
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
}
@@ -18,6 +18,7 @@ fn bounds() -> Rectangle {
}
struct Fixture {
removal_input: crate::removal_input::Lifetime,
project: Project,
diagram: Id,
a: Id,
@@ -70,6 +71,7 @@ impl Fixture {
.collect();
validate_candidate(&project).unwrap();
Self {
removal_input: Default::default(),
project,
diagram,
a,
@@ -105,6 +107,7 @@ impl Fixture {
_ => self.diagram,
};
DiagramCanvas {
removal_input: &self.removal_input,
selection: Some(selection),
project: &self.project,
view_index: None,
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
@@ -1,7 +1,13 @@
use super::*;
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
fn canvas<'a>(
project: &'a Project,
diagram: Id,
zoom: f32,
removal_input: &'a crate::removal_input::Lifetime,
) -> DiagramCanvas<'a> {
DiagramCanvas {
removal_input,
selection: None,
project,
view_index: None,
@@ -58,7 +64,8 @@ fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
..Default::default()
};
for zoom in [0.0001, 0.35, 1., 3.] {
let canvas = canvas(&project, diagram, zoom);
let removal_input = crate::removal_input::Lifetime::default();
let canvas = canvas(&project, diagram, zoom, &removal_input);
for _ in 0..2 {
for port in &view.ports {
assert_eq!(
@@ -91,7 +98,8 @@ fn coincident_ports_keep_selection_then_use_stable_identity() {
.collect(),
..Default::default()
};
let mut canvas = canvas(&project, diagram, 0.35);
let removal_input = crate::removal_input::Lifetime::default();
let mut canvas = canvas(&project, diagram, 0.35, &removal_input);
let at = local(&canvas, view.ports[0].position);
for _ in 0..2 {
canvas.selected = Id::nil();
@@ -147,7 +155,8 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
})
.collect();
validate_candidate(&project).unwrap();
let canvas = canvas(&project, diagram, 0.35);
let removal_input = crate::removal_input::Lifetime::default();
let canvas = canvas(&project, diagram, 0.35, &removal_input);
let view = project_view(&project, diagram);
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
for port in &view.ports {
@@ -177,6 +177,7 @@ mod tests {
fn program(app: &App) -> PortLabels<'_> {
PortLabels(DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: None,
project: app.project(),
view_index: None,
@@ -184,6 +184,10 @@ impl Selection {
}
impl Snapshot {
pub(crate) fn generation(&self) -> u64 {
self.generation
}
pub fn context(&self) -> Context {
self.context
}
+2
View File
@@ -26,6 +26,8 @@ mod port_edit;
mod port_preview;
mod port_workflow;
mod recovery;
mod removal_effects;
mod removal_input;
mod route_edit;
mod semantic_review;
mod source_annotations;
+228 -83
View File
@@ -19,6 +19,7 @@ pub struct Request {
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<Box<crate::behavior::topology::Context>>,
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
pub source_file: Option<String>,
}
impl Request {
@@ -34,6 +35,7 @@ impl Request {
creation_point: None,
canvas: None,
process: None,
scenario: None,
source_file: None,
}
}
@@ -55,6 +57,7 @@ pub struct Opened {
page: Page,
layer: Layer,
diagram: Option<Id>,
scenario_selection: Option<crate::diagram_selection::Snapshot>,
}
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
@@ -67,9 +70,24 @@ impl Opened {
.process
.as_deref()
.is_some_and(|c| Some(*c) != app.topology_context())
|| request
.scenario
.as_deref()
.is_some_and(|c| Some(*c) != app.scenario_context())
{
return None;
}
let scenario_selection = if request.scenario.is_some()
|| request.process.is_some()
|| (request.canvas.is_some()
&& app
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1))
{
Some(app.multiselect_snapshot()?)
} else {
None
};
Some(Self {
token,
request,
@@ -79,6 +97,7 @@ impl Opened {
page: app.page,
layer: app.layer,
diagram: current_diagram(app),
scenario_selection,
})
}
pub fn valid(&self, app: &App) -> bool {
@@ -99,6 +118,15 @@ impl Opened {
.process
.as_deref()
.is_none_or(|c| Some(*c) == app.topology_context())
&& self
.request
.scenario
.as_deref()
.is_none_or(|c| Some(*c) == app.scenario_context())
&& self
.scenario_selection
.as_ref()
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
&& self
.request
.source_file
@@ -109,9 +137,19 @@ impl Opened {
fn target_matches(app: &App, request: &Request) -> bool {
use crate::diagram_usage::Target;
if request.scenario.is_some()
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
{
return false;
}
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if let Some(context) = request.scenario.as_deref() {
return Some(*context) == app.scenario_context()
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
&& app.multiselect_target_visible(target);
}
if (request.canvas.is_none() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
@@ -159,6 +197,17 @@ fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.topology_context()
.map(|context| context.owner)
.or_else(|| {
app.scenario_context()
.map(|context| match context.representation {
crate::behavior::scenario_layout::Representation::Derived { owner } => {
owner
}
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
diagram
}
})
})
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
@@ -172,49 +221,56 @@ pub fn open(object: Id, anchor: Point) -> Message {
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
crate::context_menu::Area::left(
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
move |anchor| {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
)
.into()
}
fn actions_request(app: &App, anchor: Point) -> Request {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
request.scenario = app.scenario_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
crate::diagram_usage::Target::ScenarioObject(id) => {
request.object = *id;
None
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
request
}
pub fn popup(app: &App) -> Option<Menu<Message>> {
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
let request = &opened.request;
@@ -261,6 +317,78 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if let Some(context) = request.scenario.as_deref().copied() {
use crate::app::multiselect::Choice;
use crate::behavior::scenario::{CanvasEvent, selection};
let snapshot = opened.scenario_selection.as_ref()?;
let selection_message = |intent| {
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
context,
snapshot: snapshot.clone(),
intent,
})))
};
if let Some(target) = request.selection_target.as_deref() {
let selected = snapshot.targets().contains(target);
entries.push(item(
if selected {
"Remove from selection"
} else {
"Add to selection"
},
selection_message(selection::Intent::Choose(
target.clone(),
if selected {
Choice::Remove
} else {
Choice::Add
},
)),
true,
));
if snapshot.targets().len() > 1 {
if selected {
entries.push(item(
"Inspect this element",
selection_message(selection::Intent::Choose(
target.clone(),
Choice::Inspect,
)),
true,
));
}
entries.push(item(
"Select only this element",
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
true,
));
}
entries.push(Entry::separator());
}
// A menu on an unselected subject must not expose singleton model actions
// while the canvas still depicts a group. Selection changes are explicit.
if snapshot.targets().len() > 1 {
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(Entry::title(
"Use Reorder above the canvas to change sequence order",
));
if let Some(removal) = crate::app::scenario_removal::begin(app) {
entries.push(item("Remove selected from Scenario…", removal, true));
}
entries.push(Entry::title(
"Select one occurrence for its individual actions",
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
if (request.canvas.is_some() || request.process.is_some())
&& let Some(context) = app.multiselect_context()
{
@@ -314,22 +442,50 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
)),
true,
));
// Navigation belongs to the exact clicked preview, even when it
// is outside the current group. Keep it alongside aggregate removal
// without selecting that preview or exposing singleton mutations.
let qualified_definition = request.process.as_deref().copied().and_then(|context| {
let endpoint = match request.selection_target.as_deref() {
Some(Target::ProcessOccurrence(endpoint))
if !endpoint.references.is_empty() =>
{
Some(endpoint.clone())
}
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
_ => None,
}?;
Some((context, endpoint))
});
if let Some((context, endpoint)) = &qualified_definition {
entries.push(Entry::title("Reused contents · read-only in this process"));
entries.push(item(
"Open referenced definition",
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
*context,
endpoint.clone(),
)),
true,
));
}
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
&& selected
&& (selected || request.process.is_some() || request.canvas.is_some())
{
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(item(
"Inspect this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
Choice::Inspect,
)),
true,
));
if selected {
entries.push(item(
"Inspect this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
Choice::Inspect,
)),
true,
));
}
entries.push(item(
"Select only this element",
Message::DiagramSelection(SelectionEvent::Choose(
@@ -343,9 +499,13 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
entries.push(Entry::title(
"Drag contributions within one column to reorder",
));
entries.push(Entry::disabled(
"Hide unavailable · cards are model contributions",
));
if let Some(removal) = crate::app::process_removal::begin(app) {
entries.push(item(
"Remove selected contributions / links…",
removal,
true,
));
}
} else {
entries.push(item(
"Hide selected placements",
@@ -353,9 +513,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
crate::app::multiselect::placements(&snapshot).is_some(),
));
}
entries.push(Entry::disabled(
"Delete selected from model (not yet available)",
));
if request.process.is_none()
&& let Some(removal) = crate::app::saved_removal::begin(app)
{
entries.push(item("Delete selected from model…", removal, true));
}
return Some(Menu {
token: opened.token,
anchor: request.anchor,
@@ -364,34 +526,13 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
entries.push(Entry::separator());
if let Some(context) = request.process.as_deref().copied() {
let endpoint = match request.selection_target.as_deref() {
Some(Target::ProcessOccurrence(endpoint))
if !endpoint.references.is_empty() =>
{
Some(endpoint.clone())
}
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
_ => None,
};
if let Some(endpoint) = endpoint {
entries.push(Entry::title("Reused contents · read-only in this process"));
entries.push(item(
"Open referenced definition",
Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint,
),
),
true,
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
if qualified_definition.is_some() {
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
}
@@ -798,6 +939,10 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
}
}
#[cfg(test)]
#[path = "model_menu_scenario_tests.rs"]
mod scenario_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,319 @@
use super::*;
use crate::{
app::multiselect::Choice,
behavior::scenario::{CanvasEvent, Context, selection},
behavior::scenario_layout::Representation,
diagram_usage::Target,
};
fn saved(project: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: format!("scenario_menu_view_{}", project.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn fixture() -> (App, [Id; 3], Id, [Id; 2]) {
let (mut project, [owner, _, _], [first, second, hidden]) =
crate::behavior::scenario::direct_tests::navigation_fixture();
let mut third = project.elements[&first].clone();
third.id = Id::new_v4();
third.alias = "third_menu_reference".into();
third.name = "Third reference".into();
let third_id = third.id;
project = execute_batch(&project, &[Command::CreateElement(third)]).unwrap();
let views = [
saved(&mut project, owner, "A"),
saved(&mut project, owner, "B"),
];
let mut app = App::with_project(project);
send(&mut app, Message::Select(owner));
send(&mut app, Message::Page(Page::Behavior));
(app, [first, second, third_id], hidden, views)
}
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn choose(app: &mut App, id: Id, choice: Choice) {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
send(
app,
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event))),
);
}
fn request(app: &App, id: Id) -> Request {
let mut request = Request::object(id, Point::new(120., 80.));
request.scenario = app.scenario_context().map(Box::new);
request.selection_target = Some(Box::new(Target::ScenarioObject(id)));
request
}
fn action(app: &App, caption: &str) -> Message {
popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action { label, message, .. } if label == caption => message,
_ => None,
})
.unwrap_or_else(|| panic!("Expected enabled Scenario menu action {caption}"))
}
#[test]
fn scenario_requests_require_exact_visible_target_and_complete_exclusive_context() {
let (mut app, [first, second, _], hidden, [view, _]) = fixture();
choose(&mut app, first, Choice::Replace);
let valid = request(&app, first);
assert!(Opened::new(&app, valid.clone(), 1).is_some());
let current = app.scenario_context().unwrap();
for captured in [
Context {
document: Id::new_v4(),
..current
},
Context {
scenario: Id::new_v4(),
..current
},
Context {
representation: Representation::Saved { diagram: view },
..current
},
Context {
visit: Id::new_v4(),
..current
},
Context {
revision: current.revision + 1,
..current
},
Context {
epoch: current.epoch + 1,
..current
},
] {
let mut stale = valid.clone();
stale.scenario = Some(Box::new(captured));
assert!(Opened::new(&app, stale, 2).is_none());
}
let mut wrong = valid.clone();
wrong.object = second;
assert!(Opened::new(&app, wrong, 2).is_none());
assert!(app.project().contains(hidden));
assert!(!app.multiselect_target_visible(&Target::ScenarioObject(hidden)));
assert!(Opened::new(&app, request(&app, hidden), 2).is_none());
let mut wrong = valid.clone();
wrong.placement = Some(Id::new_v4());
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.canvas = Some(route_edit::Context {
document: current.document,
diagram: view,
revision: current.revision,
epoch: current.epoch,
});
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.process = Some(Box::new(crate::behavior::topology::Context {
document: current.document,
owner: current.scenario,
revision: current.revision,
epoch: current.epoch,
}));
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.selection_target = Some(Box::new(Target::Edge(first)));
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid;
wrong.scenario = None;
assert!(Opened::new(&app, wrong, 2).is_none());
}
#[test]
fn scenario_menu_rejects_saved_visit_roundtrip_even_with_equal_generic_snapshot() {
let (mut app, [first, second, _], _, [a, b]) = fixture();
let switch = |app: &mut App, diagram| {
let context = app.scenario_context().unwrap();
send(
app,
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
Representation::Saved { diagram },
)),
);
};
switch(&mut app, a);
choose(&mut app, first, Choice::Replace);
choose(&mut app, second, Choice::Add);
let opened = Opened::new(&app, request(&app, first), 1).unwrap();
let old_context = app.scenario_context().unwrap();
let before = app.document.snapshot();
let selected = app.diagram_selection.take().unwrap();
switch(&mut app, b);
assert!(!opened.valid(&app));
switch(&mut app, a);
assert_ne!(app.scenario_context().unwrap().visit, old_context.visit);
// Restore the same shared selection and epoch deliberately. Only the full
// Scenario visit may distinguish the old request from this returned view.
app.canvas_epoch = old_context.epoch;
app.diagram_selection = Some(selected);
app.selected = second;
assert_eq!(app.multiselect_snapshot(), opened.scenario_selection);
assert!(!opened.valid(&app));
assert!(Opened::new(&app, opened.request.clone(), 2).is_none());
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn scenario_group_menus_capture_set_and_never_expose_partial_model_actions() {
let (mut app, [first, second, third], _, _) = fixture();
choose(&mut app, first, Choice::Replace);
choose(&mut app, second, Choice::Add);
let before = app.document.snapshot();
let capture = actions_request(&app, Point::ORIGIN);
assert_eq!(capture.object, second);
assert_eq!(
capture.selection_target.as_deref(),
Some(&Target::ScenarioObject(second))
);
assert_eq!(capture.scenario.as_deref().copied(), app.scenario_context());
assert!(capture.canvas.is_none() && capture.process.is_none() && capture.placement.is_none());
for target in [first, third] {
let captured_request = request(&app, target);
send(&mut app, Message::Menu(Event::Open(captured_request)));
let snapshot = app.multiselect_snapshot().unwrap();
let menu = popup(&app).unwrap();
assert!(
menu.entries
.iter()
.any(|e| matches!(e, Entry::Title(label) if label == "2 selected"))
);
let mut enabled = 0;
let mut removals = 0;
for entry in menu.entries {
match entry {
Entry::Action {
label,
message: Some(Message::Menu(Event::Invoke(_, message))),
..
} => {
if let Message::ScenarioRemoval(event) = &*message {
let crate::app::scenario_removal::Event::Begin {
context,
snapshot: captured,
} = &**event
else {
panic!("Group menu must capture a fresh removal review");
};
assert_eq!(label, "Remove selected from Scenario…");
assert_eq!(Some(*context), app.scenario_context());
assert_eq!(captured.as_ref(), &snapshot);
removals += 1;
continue;
}
let Message::ScenarioCanvas(CanvasEvent::Selection(event)) = *message else {
panic!("Scenario group menu exposed a singleton model action");
};
assert_eq!(event.context, app.scenario_context().unwrap());
assert_eq!(event.snapshot, snapshot);
let selection::Intent::Choose(actual_target, choice) = event.intent else {
panic!("Unexpected group selection intent");
};
assert_eq!(actual_target, Target::ScenarioObject(target));
assert!(matches!(
(label.as_str(), choice),
("Add to selection", Choice::Add)
| ("Remove from selection", Choice::Remove)
| ("Inspect this element", Choice::Inspect)
| ("Select only this element", Choice::Replace)
));
enabled += 1;
}
Entry::Action {
label,
message: None,
..
} => {
assert!(!label.contains("Hide"));
}
Entry::Action {
message: Some(_), ..
} => panic!("Uncaptured menu action"),
_ => {}
}
}
assert_eq!(enabled, if target == first { 3 } else { 2 });
assert_eq!(removals, 1, "One captured whole-selection removal action");
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(app.selected, second);
}
let selected_request = request(&app, first);
send(&mut app, Message::Menu(Event::Open(selected_request)));
let inspect = action(&app, "Inspect this element");
send(&mut app, inspect);
assert_eq!(app.selected, first);
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
let remove_request = request(&app, second);
send(&mut app, Message::Menu(Event::Open(remove_request)));
let remove = action(&app, "Remove from selection");
send(&mut app, remove);
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), before.bundle);
}
#[test]
fn scenario_menu_snapshot_rejects_selection_aba_and_singleton_actions_stay_available() {
let (mut app, [first, second, _], _, _) = fixture();
choose(&mut app, first, Choice::Replace);
let captured = request(&app, first);
send(&mut app, Message::Menu(Event::Open(captured)));
let _ = action(&app, "Properties");
let _ = action(&app, "Delete from model…");
let opened = app.model_menu.take().unwrap();
let context = app.scenario_context();
// Isolate generation admission from the adapter's additional epoch defense.
let selected = app.diagram_selection.as_mut().unwrap();
selected.add(Target::ScenarioObject(second), |_| true);
selected.remove(&Target::ScenarioObject(second));
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, first);
assert!(!opened.valid(&app));
}
+230
View File
@@ -0,0 +1,230 @@
//! Definition-level Process/FunctionalChain effects of a complete removal candidate.
//! Stored record differences and unchanged interpretation modes do not establish
//! unchanged caller behavior. This bounded analysis does not expand reuse trees.
use oa_core::topology_context::TopologyIndex;
use oa_core::{Id, Kind, OccurrenceBody, Project, RelationKind, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug)]
pub(crate) struct DerivedEffects {
pub(crate) interpretation: Vec<InterpretationChange>,
/// Definitions whose records or authored topology changed, including deleted
/// definitions. Values identify surviving direct and transitive callers in
/// either project, even if their own stored records or modes did not change.
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct InterpretationChange {
pub(crate) owner: Id,
pub(crate) before: Interpretation,
pub(crate) after: Interpretation,
pub(crate) callers: BTreeSet<Id>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Interpretation {
/// None means explicit; Some includes the empty legacy order.
pub(crate) local_order: Option<Vec<Id>>,
/// Definition-level implicit order used within contextual expansion. This
/// does not claim complete expanded connectivity or boundary analysis.
pub(crate) expanded_order: Option<Vec<Id>>,
pub(crate) contextual_analysis: bool,
}
fn exchange(project: &Project, id: Id) -> bool {
matches!(
project.elements.get(&id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}
fn interpretations(project: &Project) -> BTreeMap<Id, Interpretation> {
let index = TopologyIndex::new(project);
// This is exactly TopologyIndex::with_cache_capacity's relation ownership
// rule. Its link_definitions is private; do not expand repeated reuse trees
// just to answer this finite definition-level question.
let linked: BTreeSet<_> = project
.relations
.values()
.filter_map(|relation| {
relation.contextual_owner().or_else(|| {
(relation.kind() == RelationKind::SequenceLink)
.then(|| {
project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
})
.flatten()
})
})
.collect();
project
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.map(|e| {
let steps = e.steps().expect("Sequence definitions have ordered steps");
// All-exchange steps exclude every non-exchange explicit marker, so only
// local SequenceLinks remain to test (the desktop discriminator).
// legacy_order alone loses the explicit-versus-empty distinction.
let local_order = (!project.relations.values().any(|relation| {
relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|element| element.parent)
== Some(e.id)
&& project
.elements
.get(&relation.target())
.and_then(|element| element.parent)
== Some(e.id)
}) && steps.iter().all(|id| exchange(project, *id)))
.then(|| steps.clone());
let children = index.contributions(e.id);
// Mirrors topology_context/analysis.rs Builder::expand's legacy-order
// discriminator: all indexed contributions (including unlisted ones),
// and neither SequenceLink nor ContextualLink definitions. Tests retain
// literal discriminators where this intentionally differs from desktop.
let expanded_order = (!linked.contains(&e.id)
&& children.iter().all(|id| exchange(project, *id)))
.then_some(children);
(
e.id,
Interpretation {
local_order,
expanded_order,
contextual_analysis: index.has_contextual_links(e.id),
},
)
})
.collect()
}
fn caller_edges(project: &Project, edges: &mut BTreeMap<Id, BTreeSet<Id>>) {
for element in project.elements.values() {
if let (
Some(parent),
Some(SharedBody::Occurrence {
body:
OccurrenceBody::SequenceReference {
sequence: Some(sequence),
},
..
}),
) = (element.parent, element.shared())
{
edges.entry(*sequence).or_default().insert(parent);
}
}
}
fn callers(
owner: Id,
edges: &BTreeMap<Id, BTreeSet<Id>>,
surviving: &BTreeMap<Id, Interpretation>,
) -> BTreeSet<Id> {
let mut seen = BTreeSet::from([owner]);
let mut pending = vec![owner];
while let Some(sequence) = pending.pop() {
for &caller in edges.get(&sequence).into_iter().flatten() {
if seen.insert(caller) {
pending.push(caller);
}
}
}
seen.remove(&owner);
seen.retain(|id| surviving.contains_key(id));
seen
}
fn definition_owner(project: &Project, id: Id) -> Option<Id> {
let owner = if let Some(element) = project.elements.get(&id) {
if matches!(element.kind(), Kind::Process | Kind::FunctionalChain) {
Some(id)
} else {
element.parent.filter(|_| {
matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { .. }
| OccurrenceBody::Exchange { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. },
..
})
)
})
}
} else {
project.relations.get(&id).and_then(|relation| {
relation.contextual_owner().or_else(|| {
(relation.kind() == RelationKind::SequenceLink)
.then(|| {
project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
})
.flatten()
})
})
}?;
project
.elements
.get(&owner)
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.map(|_| owner)
}
/// Analyze the actual accepted before/after projects and their complete record
/// differences. Both graphs participate: deleting a definition can clear its
/// surviving reference records, but must not erase their previous dependency.
pub(crate) fn analyze(
project: &Project,
candidate: &Project,
removed: &BTreeSet<Id>,
changed: &BTreeSet<Id>,
) -> DerivedEffects {
let before = interpretations(project);
let after = interpretations(candidate);
let mut edges = BTreeMap::new();
caller_edges(project, &mut edges);
caller_edges(candidate, &mut edges);
let affected_owners: BTreeSet<_> = removed
.iter()
.chain(changed)
.flat_map(|&id| {
[
definition_owner(project, id),
definition_owner(candidate, id),
]
.into_iter()
.flatten()
})
.collect();
let affected_uses = affected_owners
.into_iter()
.map(|owner| (owner, callers(owner, &edges, &after)))
.collect();
let interpretation = after
.iter()
.filter_map(|(&owner, next)| {
let previous = before.get(&owner)?;
(previous != next).then(|| InterpretationChange {
owner,
before: previous.clone(),
after: next.clone(),
callers: callers(owner, &edges, &after),
})
})
.collect();
DerivedEffects {
interpretation,
affected_uses,
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Transient pointer ownership shared by the canvas and its App adapter.
//!
//! Iced queues messages until the entire native event batch has reached widgets.
//! A later press must therefore revoke an earlier removal packet synchronously,
//! without relying on the ordering of two queued application messages.
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
#[derive(Clone, Default)]
pub(crate) struct Lifetime(Arc<AtomicU64>);
impl Lifetime {
/// Keep a retained canvas's private gesture ownership visible to App even
/// when its update returns early or emits no application message.
pub(crate) fn sync(&self, lease: &mut Option<Lease>, busy: bool) {
if !busy
|| lease
.as_ref()
.is_some_and(|lease| !Arc::ptr_eq(&self.0, &lease.lifetime.0))
{
*lease = None;
}
if busy && lease.is_none() {
*lease = self.acquire();
}
}
pub(crate) fn observe(&self, event: &iced::widget::canvas::Event) {
use iced::{keyboard, mouse, widget::canvas, window};
let boundary = matches!(
event,
canvas::Event::Window(window::Event::Unfocused | window::Event::Resized(_))
| canvas::Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::WheelScrolled { .. }
| mouse::Event::CursorLeft
)
) || matches!(event, canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, .. })
if *key != keyboard::Key::Named(keyboard::key::Named::Delete));
if boundary {
self.revoke_idle();
}
}
/// Even generations are idle; odd generations belong to one live gesture.
pub(crate) fn idle(&self) -> Option<u64> {
let generation = self.0.load(Ordering::SeqCst);
generation.is_multiple_of(2).then_some(generation)
}
/// Retire an idle packet at a native input boundary (including the
/// viewport's synthetic Unfocused event when it takes camera ownership).
pub(crate) fn revoke_idle(&self) {
if let Some(generation) = self.idle() {
// Keep overflow terminal and unavailable rather than reusing zero.
let next = generation.saturating_add(2);
let _ = self
.0
.compare_exchange(generation, next, Ordering::SeqCst, Ordering::SeqCst);
}
}
pub(crate) fn acquire(&self) -> Option<Lease> {
let generation = self.idle()?;
let active = generation.checked_add(1)?;
self.0
.compare_exchange(generation, active, Ordering::SeqCst, Ordering::SeqCst)
.ok()?;
Some(Lease {
lifetime: self.clone(),
active,
})
}
}
pub(crate) struct Lease {
lifetime: Lifetime,
active: u64,
}
impl Drop for Lease {
fn drop(&mut self) {
// A terminal odd generation stays unavailable on overflow. No stale
// packet can become valid again through counter wraparound.
if let Some(next) = self.active.checked_add(1) {
let _ = self.lifetime.0.compare_exchange(
self.active,
next,
Ordering::SeqCst,
Ordering::SeqCst,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removal_input_lease_revokes_idle_capture_and_drop_never_reuses_it() {
let lifetime = Lifetime::default();
let captured = lifetime.idle().unwrap();
let clone = lifetime.clone();
let lease = clone.acquire().unwrap();
assert!(lifetime.idle().is_none());
assert!(lifetime.acquire().is_none());
drop(lease);
assert!(lifetime.idle().is_some());
assert_ne!(lifetime.idle(), Some(captured));
let after_drop = lifetime.idle();
lifetime.revoke_idle();
assert_ne!(lifetime.idle(), after_drop);
}
#[test]
fn removal_input_saturation_and_replaced_owner_cannot_reuse_a_packet() {
let terminal = Lifetime(Arc::new(AtomicU64::new(u64::MAX - 1)));
terminal.revoke_idle();
assert!(terminal.idle().is_none() && terminal.acquire().is_none());
let original = Lifetime::default();
let replacement = Lifetime::default();
let captured = original.idle();
let mut lease = original.acquire();
replacement.sync(&mut lease, true);
assert_ne!(original.idle(), captured);
assert!(replacement.idle().is_none());
drop(lease);
assert!(replacement.idle().is_some());
}
}
+33 -3
View File
@@ -326,6 +326,27 @@ pub fn view(app: &App) -> View<'_, Message> {
);
}
if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) {
if target.saved.is_some() {
return modal_width(
root,
crate::app::saved_removal::view::view(app, target),
700.,
);
}
if target.process.is_some() {
return modal_width(
root,
crate::app::process_removal::view::view(app, target),
700.,
);
}
if target.scenario.is_some() {
return modal_width(
root,
crate::app::scenario_removal::view::view(app, target),
650.,
);
}
return modal(
root,
column![
@@ -468,6 +489,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
let program = DiagramCanvas {
removal_input: &app.saved_removal_input,
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
@@ -660,17 +682,25 @@ fn diagram(app: &App) -> View<'_, Message> {
)),
));
}
selection_controls = selection_controls.push(
button(text("Delete from model…").size(12))
.padding([6, 8])
.on_press_maybe(
crate::app::saved_removal::begin(app)
.filter(|_| app.saved_removal_enabled()),
),
);
selection_controls = selection_controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
snapshot.context(),
)),
))
.push(
label(format!(
"Properties edit only {}",
short(app.project().named(app.selected), 36)
"Edit only {}",
short(app.project().named(app.selected), 12)
))
.wrapping(widget::text::Wrapping::None),
);
+42
View File
@@ -2279,6 +2279,28 @@ transition row, uses confirmed relation deletion for either key. States and
referenced Activity/Function definitions remain. Hidden or collapsed rows are
not keyboard deletion targets.
For a group in a saved architecture or state view, plain Delete retains its
placement-only Hide meaning; a mixture containing ports, edges or state rows
MUST refuse Hide rather than silently delete model objects. Explicit group
model deletion, including focused Shift+Delete, resolves each exact visible
placement to its semantic element, each port to that port, each stored edge to
its relation or trace, and each state row to its maintained relation. Navigation
groupings and inferred edges are not semantic deletion subjects. Every original
typed member MUST be admitted before redundant semantic subjects or cascade
commands are reduced. A selected ancestor does not make a hidden or invalid
member admissible. Existing project validity constraints, including placement
uniqueness within a diagram, remain in force.
Saved-view aggregate review distinguishes selected visual targets, unique
semantic subjects, actual deleted records and changed surviving records. Model
deletion affects all placements of deleted elements in all views. A trace does
not cause deletion of its counterpart endpoints; an explicitly selected
counterpart remains an independent deletion subject. Removing a state action
row deletes its maintained relation, not the referenced Activity or Function.
The combined candidate and source update MUST form one atomic transaction;
neither partial primary-only deletion nor intermediate singleton history is
permitted.
Process and scenario projections have no independent occurrence placements.
Delete and Shift+Delete request confirmed removal of the selected occurrence,
participant or maintained link. A link's endpoints and outbound reusable
@@ -2292,6 +2314,26 @@ A qualified selection inside an expanded reusable process MUST NOT silently
delete the outer reference or shared definition item. The editor directs the
user to open the definition or explicitly select the outer reference.
For a group of local Process or FunctionalChain contributions and maintained
links, removal MUST admit every original typed selection member before reducing
overlapping cascade commands. A selected link already removed by a selected
occurrence's cascade remains a selected effect in the review. A mixed local and
qualified selection MUST refuse as a whole, without a primary-only fallback.
A locally owned ContextualLink is distinct from a read-only relation displayed
inside a reused definition; its explicit owner governs removal even when both
endpoint paths are qualified.
The aggregate review MUST identify actual deleted records, changed surviving
records, and affected caller definitions. Removing the last explicit topology
marker can restore an exchange-only sequence's implicit ordered meaning;
removing an interior exchange from a legacy order changes adjacent pairs.
These consequences MUST be explicit before confirmation. Reused topology and
entry/exit connections can also change without an interpretation-mode switch
or new stored relation. Caller-definition counts MUST NOT be presented as
counts of reference instances. Empty or incomplete surviving sequences retain
their ordinary diagnostics; deletion MUST NOT invent replacement controls or
links to conceal such consequences.
Confirmation identifies the captured object and removal effect. Cancel and
Escape preserve model, source and history. A confirmation token belongs to one
dialog; changes to its document, revision, owner or selected identity invalidate
+1 -7
View File
@@ -25,13 +25,7 @@ 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. 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.
recorded in the source handoff. Corrected combined execution remains pending.
Live build, final-source and publication results belong in
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
@@ -1,108 +0,0 @@
# Automatic layout investigation
Issue [32](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/32)
owns scope, ownership, milestones and integration status. This is an experiment
record above released `715036e132677dd2bc2fb37ed316d93dc9987aec`, not an accepted
replacement layout contract or a completed fix. User-owned requirements remain
in `PROJECT_GOAL.md` and the issue.
The [first executed experiment](experiments/auto-layout-20260925/results-01.md)
records nine Windows fixtures at `e7deea70`: hierarchy/label improvements,
repeat stability, and two shared-connector regressions. The candidate remains
experimental; successful execution is not acceptance of a replacement command.
The [target-join experiment](experiments/auto-layout-20260925/results-j.md)
isolates a routing score correction with fixed Projects/nodes/ports/endpoints.
Its 185 regression checks pass, but an original-scene overlap regression and
additional bipartite T contacts prevent a general readability acceptance.
## Question and existing evidence
Can explicit Auto-layout produce a readable arrangement that accounts for
connections and their labels, including removal of stale bends, while preserving
semantic endpoints, containment/allocation, source parity and reversible editing?
The current `session::layout` sizes the containment forest, sorts roots using
directed edges and alias tie breaks, places four roots per row, and translates
authored route points only when both actual endpoints move equally. Its authored
route preservation is deliberate and tested in `tests/release_routing.rs` and
`tests/state_diagrams.rs`; the user's requested automatic bend replacement needs
an explicit amendment, not a silently weakened test.
The [source map](experiments/auto-layout-20260925/source-evaluator-map.md)
identifies the actual callers and preserved input/source/API contracts. The
[primary-source research](experiments/auto-layout-20260925/research.md) compares
Sirius/Capella workflows, ELK and reuse of the existing Rust router. The
[candidate critique](experiments/auto-layout-20260925/candidate-critique.md)
records unresolved design gaps before any production choice.
The existing `routing` module already supplies bounded deterministic orthogonal
search, node/header obstacles, fixed semantic port endpoints, crossing/shared-run
costs and automatic label collision search. Automatic elbows are derived, not
persisted. The investigation should reuse this capability unless experiments
show a concrete reason to replace it. Process topology rendering is a different
path; its reported overlapping routes do not alone prove an Auto-layout defect.
## Candidate decisions, not yet accepted
Compare these materially different choices against visible route/label quality,
semantic conservation, predictable user control, runtime/dependency cost,
determinism, model size and maintainability:
1. Retain node arrangement and only reset effective visible route/label constraints.
This is a useful control, but cannot by itself optimize poor node ordering.
2. Use a hierarchy-aware layered node arrangement and the existing router, with
label-aware spacing and bounded crossing-reduction sweeps. Explicit Auto-layout
can replace effective visible pins/label offsets; ordinary drag keeps its
existing preservation rules. This is a candidate, not a chosen implementation.
3. Adopt a mature external layout engine, accounting for ports, labels, nested
containers, runtime packaging and coordinate conversion. Evidence must justify
the integration/dependency cost over reusing the current router.
Open contract questions include hidden/folded routes, authored attachment and port
anchors, undirected and cyclic graphs, nested cross-boundary edges, repeated
layout stability, and the observable treatment of exhausted search/poor results.
No option may change semantic endpoints, ownership, allocation, expressions,
relation kind or source identity to improve a drawing. Other views must remain
unchanged, and one Undo on clean input must recover the complete pre-layout
project. Existing application behavior applies an unrelated pending property
draft first; that separate Apply is not silently folded into Layout's history.
The initial experiment retains the current command as the baseline and compares
reset-only with a layered prototype isolated under `examples/auto_layout_probe`.
Neither prototype changes production behavior. Effective reset eligibility must
come from the pre-layout projection and actual endpoint identities; merely being
drawn does not make a promoted route's dormant metadata eligible. Explicit label
reset means both fraction and offset, while semantic text and endpoint/port
attachments remain fixed. Production adoption remains conditional on the
critic's hierarchy, stability, direction, measurement and global-work checks.
The first [probe recipe](experiments/auto-layout-20260925/probe-notes.md) covers
nine small SA FunctionalDataflow fixtures. Its [independent source review](
experiments/auto-layout-20260925/probe-review.md) found no blocker to that finite
diagnostic run and lists important evaluator limitations. After that review,
the parent inspected the added ten-call prototype stability loop and formatted
the examples; the loop reports every differing/error result without a quality
pass. No production file was changed. The experiment intentionally does not
establish folded/filtered, State, Allocation, source-bundle or native acceptance.
## Evaluator before implementation
Use independently specified small graphs and real project views. Separate proper
crossings, collinear shared runs, obstacle collisions, label/card collisions,
label/label collisions, routing warnings, bends, length and occupied area. A low
crossing count alone can hide coincident edges or enormous drawings. Exempt only
explicitly documented intentional containment and common endpoint geometry;
never discard a failure because it is inconvenient to the proposed algorithm.
Compare the current command and the reset-only control before choosing a design.
Include a chain longer than the current four-column row, diamond/bipartite cases,
nested cross-boundary exchanges, stale authored detours/labels, fixed ports,
cycles/self-loops and disconnected components. Check deterministic repeat calls,
idempotence, model/source round trips and complete Undo/Redo. A probe is evidence
about its named fixtures, not universal graph quality or native usability.
Actual representative Windows workflows, including meaningful window/zoom sizes,
selection and saved reopen, follow an implemented candidate. Linux remains
excluded by the current user instruction. The separately published State label
candidate in issue36 is not silently treated as integrated into this base.
@@ -1,38 +0,0 @@
# #32 candidate critique — 2026-09-25
Read-only adversarial review of `harness/auto-layout-investigation-20260925.md` in the715036e-based isolated checkout, against the source/evaluator map. This critiques candidate choices; no implementation, execution or acceptance is claimed.
## Recommendation
Use the existing explicit **Command::Layout / Auto-layout / Arrange elements** operation for the new automatic behavior. The user specifically asks that operation to replace bends and consider labels/intersections. A second command/UI choice would preserve the obsolete behavior as a competing default without demonstrated need. Deliberately amend its documented contract and focused preservation tests, retain exact Undo, and retain existing ordinary drag/Arrange-next-drag contracts. A new command becomes warranted only if concrete compatibility/user workflows require both automatic policies, not merely because a test currently asserts old behavior.
Prefer comparing candidate1 reset-only against candidate2 layered siblings plus the existing router. No external engine is justified yet. The existing router already creates derived elbows, so reset does not require writing every solver corner as a persisted hard pin. Keep generated corners derived initially; the saved node placement plus remaining explicit attachments determines their repeatable reconstruction.
Recommended explicit Layout route contract: identify eligible visible, non-promoted edges from the **pre-layout projection**, remove their stored waypoints and label override (fraction and offset together), retain source/target attachments and node port anchors. Retain unrelated/hidden/promoted route records with existing equal-delta endpoint-owner translation. Do not call broad Reset routing if that also clears attachments. Preserve stable route-list order and remove an emptied record only under an explicit canonical no-metadata rule. Layout-generated record churn is not progress.
## Blocking design gaps before accepting candidate2
1. **Eligibility must be frozen once.** Visible edges, semantic endpoint-owner placements and promoted endpoints are different sets. Compute the reset set before node movement using projection visibility plus actual owner identity, not “every stored edge” or endpoints merely present in `d.nodes`. A filtered/hidden edge can retain a valid route; a folded edge may be drawn from an ancestor while its own route is suspended. Recomputing eligibility mid-layout risks erasing metadata when visibility/promotion changes. Small experiment: parallel visible/filtered edges, one folded child endpoint, and one both-endpoints-fold-to-same-anchor edge, all with distinctive pins/labels/attachments. Require only declared reset members to change, and retained pins to translate only through the existing owner-delta rule. Unfold/unfilter must reveal the retained custom route.
2. **Two forests are needed conceptually, not two model hierarchies.** The authored projection-parent forest includes folded descendants; the rendered topology may omit them. Reordering only rendered nodes can leave hidden children outside resized parents or leave future expand geometry broken. Conversely, expanding all hidden content for outer layout wastes space and ignores the actual view. Decide visible sibling arrangement versus hidden subtree placement/reservation explicitly. Lift each rendered inter-subtree edge to the relevant direct children at its lowest common ancestor; do not flatten every edge to roots (loses nested ordering) or count the same edge at every depth as independent demand. Small experiment: nested A/B children with crossed exchanges, folded A, external sibling C and a State entry/exit boundary. Check containment/header/port-footer rules before and after expand, with semantic parents/allocations exact.
3. **Idempotence will fail if spacing compounds current sizes.** Current measure uses each authored `node.size` as a lower bound. If label clearance expands a container and the next call adds the same margin again, repeated Arrange grows indefinitely. Label spacing based on previous routed bounds can similarly oscillate. Decide whether current sizes are immutable minima, or may shrink; the latter exceeds today's behavior and needs justification. Compute desired padding from content minima with `max`, not repeated addition to an already padded size. Small experiment: ten consecutive calls on nested long-label/parallel-edge fixtures, comparing full Projects after first vs second and sizes after tenth; repeat after Save/reparse. A two-call-only test can miss alternating period-two layouts.
4. **Cycle/direction policy is not settled by “stable sweeps”.** Some projected edges are undirected; direction may be semantic or merely an endpoint representation. Use directed topology where justified, deterministic SCC treatment for cycles, and explicit undirected ordering inputs. Do not arbitrarily make a Capability/Traceability edge's serialized orientation a mandatory rank. Freeze alias/identity tie rules; crossing sweeps require a stable acceptance tie-break to avoid ping-pong. Small experiment: directed diamond+return, reversed undirected endpoint records, self-loop, parallel/reversed edges and disconnected isolates. Permute input records; independently require identical output for identical IDs and stable quality under UUID changes (not necessarily identical coordinates under reidentification).
5. **Label reset and label-aware spacing need different evidence.** Clearing authored overrides is a reasonable explicit-auto interpretation but must be announced, since issue32 says “take into account” labels, not explicitly erase them. Retain semantic text/typed references; only the target view's override changes. A stale far-away label is a concrete reset fixture; an intentionally constrained attachment/label case tests whether residual collisions are honestly reported. Base715 label widths are scalar estimates, and State title/multiline geometry is known inaccurate without36. Do not tune spacing to claim accurate measured-State acceptance on715. First isolate ordinary/association labels; use conservative declared bounds for experiments, then rerun State B/W/U once36 integrates. Font-free core/API parity forbids quietly using desktop-only font results inside Command::Layout.
6. **Bounded sweeps do not alone bound runtime.** Each complete route evaluation invokes previous-edge interactions, node obstacles and label comparisons; repeated per-sibling trial routing can multiply this cost by nodes×sweeps. Lifting topology naively can multiply edges×depth. Define global deterministic work budgets for candidate ordering/scoring, not just a fixed iteration count, and retain the best valid candidate on exhaustion. Route only a bounded number of whole candidates; cheap crossing surrogates can rank candidates but final acceptance must inspect actual geometry. Small experiment: a fixed family increasing sibling count/edge density/nesting, recording candidate count, comparison/work counters and wall time; include no-edge/isolate cases so they do not pay quadratic routing costs. No hard latency threshold should be fabricated from one machine.
## Input/source/API contract checks
- Clean whole-view Layout should create one reversible model/source transaction. `app.rs` applies a pending property draft before executing Layout, potentially as a separate transaction; do not accidentally promise one Undo reverses both an unrelated draft Apply and Layout. Use a clean-input one-step Undo oracle and a separate valid/invalid pending-draft admission test. Keep diagram UUID remapping, stale selection handling and disabled/pending form policy.
- `prepare_command`/Document/source bundle/REST dry-run must produce the same Project for the same inputs without GUI fonts, caches or selection. A frontend-only rearrangement would fail this. Roundtrip the stored record representation, check unrelated source files/comments remain exact, and recover the exact old source/model with Undo—not merely visually similar positions.
- Keep Allocation Arrange-next-drag, ordinary group translation, semantic ports, reconnect, Hide/show, collapse flags and other diagrams unchanged. A route reset belongs only to explicit Layout. A failed candidate must not clear history or leave a partially reset diagram.
- Native validation later needs visible controls, Fit/export agreement, actual label containment and route readability, plus Save/reopen/Undo. Do not extend the implementation merely to expose a new State Auto-layout control before a demonstrated workflow need; shared State initialization still needs regression coverage.
## Smallest discriminating experiment set
Freeze six hand-built cases before implementation: (A) stale dogleg+far label with fixed attachments, (B) two-container reversed sibling connections with a known zero-crossing witness, (C) nested folded/filtered route-conservation sentinel, (D) cycle/self-loop/parallel/undirected variants, (E) long ordinary/association labels with feasible compact clearance, (F) dense bounded stress. Compare existing Layout, reset-only, and a layered prototype on the same independent vector: invalid overlap/penetration, label collisions, proper crossings, positive collinear overlap, turns, path length/stretch, area and routing warnings. Include ten-call idempotence, exact other-view/semantic conservation, source roundtrip and Undo/Redo.
Candidate2 earns selection only if it materially improves at least B/E over reset-only without regressing the hard invariants or bounded workload. A tie favors the smaller candidate. A nonplanar or contradictory-fixed-port fixture may retain crossings; zero-crossing-by-overlap, giant detours, hidden edges or clipped labels is not a solution. Failure on a named fixture should revise the exact layer/policy implicated, not trigger an automatic wholesale engine dependency.
@@ -1,56 +0,0 @@
"""Compare fixed Project/node/port/endpoint geometry and report routing changes.
Usage: python compare_probe.py OLD_JSONL NEW_JSONL NEW_OUTPUT_JSON
No layout-quality pass is inferred from equal inputs or successful execution.
"""
from pathlib import Path
import hashlib
import json
import sys
old_path, new_path, out = map(Path, sys.argv[1:])
def read(path):
rows = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines()]
return {row['fixture']: row for row in rows if 'fixture' in row}
old, new = read(old_path), read(new_path)
assert old.keys() == new.keys()
def variants(row):
yield 'before', row['before_project'], row['before']
yield 'baseline', row['after_project'], row['after']
for name in ['reset_only_control', 'experimental_candidate', 'manual_planar_order_witness']:
if row[name] is not None:
yield name, row[name]['project'], row[name]['metrics']
def endpoints(metrics):
return {e['id']: [e['points'][0], e['points'][-1]] for e in metrics['routed_edges']}
keys = ['proper_crossings', 'pairwise_collinear_shared_length',
'nonterminal_pairwise_shared_length', 'unrelated_node_collisions',
'unrelated_edge_node_penetrations', 'label_node_collisions',
'label_label_collisions', 'unrelated_label_stroke_intersections',
'warning_count', 'route_bends', 'route_length', 'drawing_area']
results = []
for name in old:
lhs, rhs = list(variants(old[name])), list(variants(new[name]))
assert len(lhs) == len(rhs)
for (variant, op, om), (other, np, nm) in zip(lhs, rhs):
assert variant == other
fixed = {'project':op == np, 'nodes':om['projected_nodes'] == nm['projected_nodes'],
'ports':om['projected_ports'] == nm['projected_ports'],
'route_endpoints':endpoints(om) == endpoints(nm)}
results.append({'fixture':name, 'variant':variant, 'fixed_geometry':fixed,
'metrics':{key:{'old':om[key], 'new':nm[key]} for key in keys},
'new_point_contacts':nm.get('point_contacts'),
'new_point_contact_details':nm.get('point_contact_details')})
summary = {'old_sha256':hashlib.sha256(old_path.read_bytes()).hexdigest(),
'new_sha256':hashlib.sha256(new_path.read_bytes()).hexdigest(),
'all_projects_nodes_ports_endpoints_exact':all(all(r['fixed_geometry'].values()) for r in results),
'scope':'Fixed-geometry comparison; quality vector retained, no aggregate quality pass.',
'results':results}
with out.open('x',encoding='utf-8') as f:
json.dump(summary,f,indent=2)
f.write('\n')
print(json.dumps({k:v for k,v in summary.items() if k != 'results'}))
for row in results:
if row['variant'] == 'experimental_candidate':
print(json.dumps({'fixture':row['fixture'], 'fixed_geometry':row['fixed_geometry'],
'changed_metrics':{k:v for k,v in row['metrics'].items() if v['old'] != v['new']},
'point_contacts':row['new_point_contacts']}))
@@ -1,47 +0,0 @@
# #32 bounded automatic-layout probe
Prepared `crates/oa-core/examples/auto_layout_probe.rs` in `.local/auto-layout-20260925` against reference source `715036e132677dd2bc2fb37ed316d93dc9987aec`. No production edits, compiler, Cargo, GUI or Gitea operation by this evaluator. Rustfmt parsed/formatted only the owned main example (`skip_children=true`). The separately owned `auto_layout_probe/layered.rs` is imported through its agreed `candidate(&Project, Id) -> Result<Project, ModelError>` interface; the evaluator does not use candidate internals to define fixtures, geometry metrics or expected conservation.
## Execution and output
From the worktree, in the parent's configured exclusive Windows toolchain environment:
```powershell
cargo run --locked --offline -p oa-core --example auto_layout_probe > auto-layout-probe.jsonl
```
The parent has `.local/run-auto-layout-check-20260925.py` for pinned receipt-based execution. Prefer that for recorded evidence; its source hash/commit must identify the actual build. The example's `reference_source` metadata is the investigation baseline, not a runtime attestation of checkout identity. Stdout is JSON Lines: one metadata/self-check record and nine fixture records. No output files, network calls or UI operations are performed by the example. Errors/unsupported candidates are explicit fields, not quality passes. There is no aggregate score or threshold; a zero metric is not a readability or hard-safety certification.
Each fixture emits the original and current `Command::Layout` Projects and real projected/routed geometry. It also emits the same observations for a **reset-only control applied after existing Layout**, and for the separately implemented experimental candidate invoked on the original Project. The control clears only waypoints and label placement on visible routed edges whose geometry is not suspended/promoted, retaining endpoint attachments, hidden/promoted records and empty route records. The candidate does not determine this control. Ten successive existing Layout calls and ten successive candidate calls are each compared with their preceding and first result, stopping with an explicit error if needed. Candidate identical-input repeat and one further application are also compared separately. Reset eligibility is measured after baseline Layout; the candidate freezes it before Layout. These all-visible fixtures do not change visibility, so broader eligibility-equivalence claims are deferred.
## Declared fixtures
1. Four-node flat chain, initially left to right.
2. Seven-node chain, initially left to right, exposing four-column row wrapping if present.
3. Diamond with branch/join semantics and declared two-level positions.
4. Crossed bipartite ordering, including both straight and crossed pairings.
5. Parent Function with two child Functions, internal exchange and exchange to an outside Function.
6. Two-node authored high detour with a far label offset; reset-only isolates stale constraint cost.
7. Two-node exchange with explicit source-Left0.3 and target-Right0.7 port anchors.
8. Two parent Functions, each with two children, and reversed inter-parent pairings. A separate predeclared witness swaps only right-child y positions, aligning each source with its target. Its real routing is measured; no zero-crossing result is assumed.
9. Three Functions with long ordinary exchange names, including a bypass; the actual existing summary/name excerpt bounds are observed.
Project, diagram, Function, placement, relation and automatically created port UUIDs are remapped to stable IDs in creation order. Port aliases are reset explicitly. Names and other aliases are unique per fixture, avoiding random-ID tie dependence. The fixture Projects in output allow independent repetition and offline visualization; projected node rectangles/ports and per-edge routed point sequences/label rectangles are also included.
## Metric definitions and exclusions
- **Proper crossings:** unique intersection locations per distinct edge pair, interior to both straight segments. Locations deduplicate at0.0001 world units. Segment endpoints, bend contacts, T junctions and collinear overlaps are excluded from this count. Raw crossings remain the primary count. A separate nonterminal count subtracts only contacts within `PORT_STUB` of a physically equal endpoint with the same projected owner, where both tested segments are terminal. Entire compacted first/last segments are never exempt.
- **Shared runs:** summed pairwise collinear overlap length, reported separately from crossings. Raw length is primary. Common exact endpoint terminal overlap gets a separately reported exemption bounded by `PORT_STUB` per common endpoint, capped by the overlap. Three overlapping edges contribute three pairwise overlaps; retraced segments may contribute multiplicity. This is not union length.
- **Unrelated node collisions:** positive rectangle intersection area for distinct nodes; ancestor/descendant pairs are exempt from this count. No inferred intent is attached to a reported collision.
- **Label/node collisions:** positive intersection of real routed label bounds with projected node rectangles, including endpoint nodes. A label wholly inside an endpoint ancestor's *body below the header* is exempt; overlap with its header remains reported. Ordinary header height is64; dedicated State height uses the core helper. Border crossings are retained. These conservative boxes are not glyph measurements.
- **Edge/node penetration:** per edge/node pair, positive clipped segment length in unrelated node interiors. Endpoint-owner nodes are exempt; endpoint ancestors contribute only their header obstacles, so intentional passage through container bodies/boundaries is not counted. This does not detect re-entry into an edge's own endpoint node.
- **Label/stroke intersection:** positive clipped length of another edge's stroke inside a label rectangle, one record per label-edge/stroke-edge pair. Own-edge strokes are excluded; an intentional anchor/label association is not declared a defect. Stroke thickness, arrowheads and antialiasing are unmodeled.
- **Other observations:** actual routing warnings and details; routed total length; direction-changing bends including reversal; total drawing bounding-box area (including labels/nodes); real node/port/edge geometry. Area is scale-sensitive and is not minimized at the expense of crossings or readability.
Tiny analytic assertions check proper crossing versus endpoint contact, collinear overlap versus separated lines, rectangle contact, and line/interior clipping. They will run with the example, but have not been executed by this author. Geometry tolerance is0.0001 world units, while proper-intersection parameter/parallel checks use1e-9.
## Conservation and limits
The current command is exercised through `Session`, with exact one-step Undo/Redo and history/no-op checks. Full JSON decode and formatted single-source parse round trips are compared to the complete Project before/after; these do not prove original multi-file comments/byte preservation. Semantic records, target-view nongeometry fields, other views, per-node identity/visibility/port anchors and authored routes are reported separately. Reset/candidate route equality may intentionally be false and must be assessed against their stated reset policy, not silently treated as semantic loss.
Folded/filter/hidden-route preservation is implemented in the reset eligibility check but not independently falsified by these nine fixtures. Dedicated State #36 native typography, dense stress, cycles, disconnected large sets, non-planar crossing lower bounds, self-crossings, self-loops, full stroke geometry and interactive cognitive load are absent. The candidate may explicitly reject unsupported cases. No algorithm can promise crossing-free output for arbitrary non-planar graphs; the paired-container witness only establishes a concrete alternative ordering to examine. Use vector observations and the output geometry to diagnose mechanisms before selecting a candidate; this is a bounded experiment, not a production layout-quality framework or native acceptance.
@@ -1,27 +0,0 @@
# #32 first diagnostic probe review
**No blocking finding for compiling/running the current finite experiment.** This is a source/evaluator-validity review, not production acceptance or a claim the candidate improves layout. Nine small deterministic FunctionalDataflow fixtures support a bounded comparison; later scope gaps should not delay this run. No compiler, GUI, helper, source edit or Gitea operation performed.
Reviewed snapshot SHA256: auto_layout_probe.rs `72d850fdd9aad65788004949380c28cba48a0c2be3dc28f3aef3900c7f8724c7`; auto_layout_probe/layered.rs `bda9a59968b4b0e284e8e1393eb86c90cdf0d856d5f9830ae3c6fc0043df839a`. The evaluator worker is separately finalizing candidate repetition reporting; later edits require a scoped diff check, not relabeling this reviewed snapshot.
## Evaluator validity
Raw strict-interior segment crossings and pairwise collinear shared length are primary outputs, with crossing coordinates deduplicated per edge pair. Endpoint sharing is not exempted wholesale: secondary figures subtract only bounded common physical tip lengths. Node/header, label/node, label/label and unrelated stroke/label measurements remain separate. Analytic primitive checks cover a proper crossing, endpoint touch, collinear overlap, disjoint parallel segments, rectangle touch and inside-length boundary. Complete before/after models and routed points are emitted, permitting independent examination beyond scalar totals. No quality threshold or automatic overall-pass field promotes a poor drawing to acceptance.
Fixture IDs are normalized before use; semantic port IDs/aliases are also stabilized. The manual reversed-child witness is separately authored and measured, not generated from candidate output. It must still be read as a witness with its reported validation/geometry, not an asserted universal optimum. Standard parse/JSON roundtrip and old-Layout history checks are meaningful within their named scope.
## Actionable limitations to retain when interpreting this run
1. **Reset control eligibility:** reset_effective_routes receives the already arranged Project, whereas layered candidate freezes its eligible set before Layout. For these all-visible, uncollapsed fixtures, Layout does not change membership/promotion, so this does not invalidate the current comparison. Freeze the original eligible IDs for future folded/filtered cases; do not generalize current equivalence.
2. **Conservation granularity:** semantic/other-view/node-metadata checks are independent and useful. target_view_nongeometry_exact restores all routes and layout before comparing; authored_routes_exact will intentionally become false. Those fields do not separately prove attachment preservation, invisible-route retention or a no-change layout field. Add targeted comparisons before making those claims on future fixtures. The current prototype source copies original route records and only clears eligible waypoints/labels, then translates retained routes, so no observed source-level violation was found here.
3. **History scope:** undo_exact/one_step_undo/redo_exact exercise existing Command::Layout through Session only. The layered candidate is a pure Project transformer, not an integrated command and has no tested operation/history lifecycle. Its repeat-same-input and second-call comparison are useful; ten-call candidate stability was pending in this snapshot. Do not describe baseline ten_layout_calls as candidate stability.
4. **Metric coverage:** strict proper crossings deliberately exclude T contacts and intersections at segment bends; pairwise shared length counts segment-pair traversal multiplicity, not geometric union length. No candidate should be selected solely by one scalar if it shifts crossings into uncounted touches. Endpoint-owner interiors are excluded from unrelated-edge penetration, and label/stroke excludes its own edge. These are declared definitions, not proof of complete route validity. Association-end labels, actual glyph ink, State multiline measured bounds and port labels are not covered by current primary label metrics. Current nine fixtures contain functional exchanges, so these omissions do not corrupt their narrow comparison.
5. **Fixture coverage:** all current fixture families are small SA FunctionalDataflow cases. Other-view exactness is vacuous where only one view exists; folding, filtering, omitted-node, State, Allocation, Scenario, undirected cycles, dense scaling, UI admission and multi-file source conservation are not established. Preserve these gaps explicitly rather than broadening the initial run unnecessarily.
## Prototype source risks the experiment should expose
The candidate preserves semantic records and route-list order, retains attachments/port anchors, rejects folded/omitted topology explicitly, and uses stable alias/ID order, deterministic cycle fallback, a graph limit and counted ordering work. Derived corners are not persisted as hard pins. No product file is replaced.
Three passes use frozen per-call minima, but those minima come from baseline Layout using the input's existing sizes; cross-call size ratcheting remains possible and should appear in repeated full-Project comparison. Global maximum label width controls every sibling gap, so one long label may balloon unrelated layout area. Incoming-neighbor ordering can improve the reversed-container fixture without proving general crossing reduction; undirected edges do not constrain ranks/order. The work budget excludes existing projection/routing work and several collections, so it bounds selected prototype loops, not total CPU complexity. With current small fixtures these are diagnostic candidate weaknesses, not reasons to prevent the run.
Interpret a failed invariant, growing repeated layout, worse collisions or huge area as evidence against this prototype. Preserve errors and unsupported statuses; do not use successful process exit as layout-quality acceptance. Decide further work from the produced geometry and independent metric vector. Production integration, complete command/source/API/history behavior, native readability and broader issue32 acceptance remain separate.
@@ -1,75 +0,0 @@
"""Render recorded probe geometry for inspection; this is not a desktop screenshot.
Usage: python render_probe.py PROBE_STDOUT_JSONL NEW_OUTPUT_DIRECTORY
Uses one scale across every result for each fixture; input geometry is unchanged.
"""
from pathlib import Path
import hashlib
import json
import sys
from PIL import Image, ImageDraw, ImageFont
def geometries(value, name=""):
if not isinstance(value, dict):
return
if "routed_edges" in value and "projected_nodes" in value:
yield name, value
return
for key, child in value.items():
yield from geometries(child, f"{name}.{key}".strip("."))
source, output = map(Path, sys.argv[1:])
output.mkdir(parents=True, exist_ok=False)
font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", 13)
files = []
for line in source.read_text(encoding="utf-8").splitlines():
item = json.loads(line)
fixture = item.get("fixture")
if not fixture:
continue
variants = list(geometries(item))
if not variants:
continue
largest_width = max(g["drawing_bounds"]["size"]["width"] for _, g in variants)
largest_height = max(g["drawing_bounds"]["size"]["height"] for _, g in variants)
scale = min(1.0, 1140 / max(1, largest_width), 690 / max(1, largest_height))
for name, geometry in variants:
canvas = Image.new("RGB", (1200, 800), "white")
draw = ImageDraw.Draw(canvas)
origin = geometry["drawing_bounds"]["position"]
def point(position):
return (30 + (position["x"] - origin["x"]) * scale,
70 + (position["y"] - origin["y"]) * scale)
def rectangle(position, size):
x, y = point(position)
return x, y, x + size["width"] * scale, y + size["height"] * scale
draw.text((20, 12), f"{fixture} / {name} | geometry only, not native UI", font=font, fill="black")
draw.text((20, 32), f"Fixed fixture scale {scale:.4f}; text below identifies bounds, not glyph measurements", font=font, fill="black")
for node in sorted(geometry["projected_nodes"], key=lambda n: (n["depth"], n["id"])):
box = rectangle(node["position"], node["size"])
draw.rectangle(box, fill="#eef2f6", outline="#687787", width=1)
draw.text((box[0] + 3, box[1] + 3), node["id"][-5:], font=font, fill="#334155")
for edge in geometry["routed_edges"]:
points = [point(p) for p in edge["points"]]
if len(points) > 1:
draw.line(points, fill="#245c88", width=2)
label = edge["label"]
if label["text"]:
box = rectangle(label["position"], label["size"])
draw.rectangle(box, outline="#ad5715", width=1)
target = output / f"{fixture}-{name.replace('.', '-')}.png"
canvas.save(target)
files.append({"name": target.name, "bytes": target.stat().st_size,
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(), "scale": scale})
(output / "manifest.json").write_text(json.dumps({
"input_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"renderer_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"scope": "Offline schematic of recorded core geometry; no native or font-rendering acceptance.",
"images": files}, indent=2) + "\n", encoding="utf-8")
print(f"Rendered {len(files)} geometry records without modifying the probe output.")
@@ -1,66 +0,0 @@
# #32 automatic arrangement: bounded research
Prepared 2026-09-25; local research, not an accepted design or implementation review. No compiler, GUI, helper, or remote mutation was run. Primary sources were read online. Local source spot-check: clean `.local/auto-layout-20260925`, commit `715036e132677dd2bc2fb37ed316d93dc9987aec`. Parent owns current implementation mapping and contract selection.
The user needs Arrange to generate/remove bends, reduce connection intersections, and account for connection labels, while retaining meaningful nested containment/deployment. A useful solution therefore needs coordinated node ordering, space allocation, routing, and label placement. Merely sorting boxes or clearing existing waypoints cannot establish success.
## Verified precedents and their limits
**Capella workflow.** Capella 7 release notes document Layout Children, temporary movement of pinned elements, and renaming Arrange All/Selection to Layout All/Selection. This supports making operation scope and preservation of manual organization explicit. It does **not** establish that Capella uses ELK as its default engine. [Official Capella 7 notes](https://github.com/eclipse-capella/capella/wiki/Release-Notes-7.0.0).
**Sirius workflow.** Its user guide describes avoiding overlap, fitting containers around contents, attempting fewer edge crossings, and preserving pinned locations. Layout Children operates at the immediate level. A selected container can have its contents arranged while its location remains fixed. The guide also describes simplifying an unpinned edge between pinned endpoints. However, its pinning section and later toolbar section disagree about edge pinning support; do not copy that ambiguity into ARCADIA's contract. [Sirius user guide](https://eclipse.dev/sirius/doc/user/diagrams/Diagrams.html). Sirius documents configurable default/composite and ordered-tree layouts, with ELK as an additional integration described as experimental. Availability of that integration is not evidence of a particular Capella installation's behavior. [Sirius layout configuration](https://eclipse.dev/sirius/doc/specifier/diagrams/Diagrams.html).
**ELK capabilities.** ELK Layered explicitly supports orthogonal routing, ports, labels, compound graphs, and cross-hierarchy edges with suitable configuration. [Algorithm reference](https://eclipse.dev/elk/reference/algorithms/org-eclipse-elk-layered.html). `INCLUDE_CHILDREN` allows a joint run through descendants; the root default otherwise separates children. Independent child layout is therefore not equivalent to globally considering connections across nested containers. [Hierarchy handling](https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html). Port constraints distinguish free placement, fixed side/order/ratio, and fixed position. [Port constraints](https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html).
The authors describe a Sugiyama-family layered algorithm, constrained barycenter crossing minimization, size/port-aware coordinate assignment, and separate support for compound graphs and disconnected components. These are heuristics with configurable priorities, not a guarantee of globally minimal crossings. [ELK authors, GD 2024](https://drops.dagstuhl.de/storage/00lipics/lipics-vol320-gd2024/LIPIcs.GD.2024.56/LIPIcs.GD.2024.56.pdf).
**Routing is a distinct problem.** Wybrow, Marriott, and Stuckey describe orthogonal visibility graphs, A* route search, and ordering/nudging of shared segments. Their bend/length optimality statement concerns individual routes under their model, not joint optimal placement of all nodes, labels, and connectors. [Orthogonal Connector Routing, GD 2009](https://people.eng.unimelb.edu.au/pstuckey/papers/gd09.pdf). libavoid exposes penalties for crossings, segments, cluster crossings and shared paths, plus endpoint direction constraints. It supplies routing rather than a complete node arrangement solution. [libavoid API](https://www.adaptagrams.org/documentation/namespaceAvoid.html).
## Materially different approaches
| Candidate | Practical proposal for this project | Main advantage | Main risk / decisive falsifier |
| --- | --- | --- | --- |
| Native Rust layered arrangement + existing router | Build a bounded, deterministic placement pipeline over the projected hierarchy; reserve measured label space and improve routes after placement. | Best match to Rust preference, existing model/history, and deployment; reuse current geometry. | Nested cross-boundary routes and label congestion make a supposedly small implementation grow. Reject if the finite compound fixtures require ad hoc per-diagram exceptions or cannot reduce known avoidable crossings. |
| ELK Layered through elkjs | Translate one immutable projected graph to ELK JSON, run a pinned bundled JS engine off the UI thread, validate results, apply a presentation transaction. | Mature joint treatment of ports, labels, layering and hierarchy. | Adds runtime/distribution/adapter cost. Reject if exact port semantics, nested coordinates, measured label boxes, bounded cancellation, or Windows/Linux packaging cannot be demonstrated. |
| Preserve nodes; improve routes and label locations | Use existing Rust routing plus bounded rerouting/order search, or investigate libavoid through FFI. | Least disruption to the user's spatial organization; useful separate Reroute command. | Cannot untangle crossings imposed by node/port ordering. Insufficient as the sole Arrange implementation if an order swap solves a fixture it cannot improve. |
elkjs is the official JavaScript form of ELK's layout algorithms, consumes/returns graph geometry, and supports worker execution. Using the distributed JavaScript does not require a Java runtime. A Rust embedding still needs an explicitly selected JS runtime or packaged process; compatibility, footprint, cancellation and licensing must be checked in a bounded spike. It is not a Rust library or a ready Iced integration. [elkjs repository and API](https://github.com/kieler/elkjs).
Graphviz is a possible comparison baseline, not my preferred default: its current official `splines=ortho` documentation explicitly says this routing does not handle ports or, for dot, edge labels. That limitation directly intersects this task. A separate router/label solver could repair the integration, but removes much of its simplicity. [Graphviz splines](https://graphviz.org/docs/attrs/splines/). Force/stress layout is another possible overview style, but I would not spend the first experiment on it: engineering flow direction, nesting and endpoint constraints still need substantial machinery afterward. This is a fit judgment, not a claim those algorithms cannot be extended.
## Proposed minimum contract, independent of engine
1. **Operate on diagram presentation.** Preserve semantic identities, relation direction, containment/deployment, port identity and connectivity, node/edge membership, and label text. Layout cycle breaking must never reverse the actual model relation. Projected parentage, not an independently guessed semantic tree, defines nesting. Hidden/collapsed content must have a declared policy; do not silently expose it.
2. **Define scope before implementation.** Initial whole-diagram Arrange is simpler than arbitrary selection. If selection is included, decide whether a container means its immediate children or its full subtree, whether it can grow, and how boundary-crossing edges update. Outside nodes remain fixed. Do not infer support for selection from a whole-graph solver.
3. **Define replaceable geometry.** Explicit Arrange may replace the in-scope route waypoints, creating useful bends and removing redundant ones. Decide independently whether authored endpoint attachments and authored label placement are preserved or reset. Recommended initial rule: preserve semantic/fixed attachments; authorize replacement of bend geometry; treat preserved label boxes as constraints. Do not retain stale absolute label offsets accidentally after wholesale rearrangement. Parent must settle this user-visible policy.
4. **Measure before arranging.** Supply visible node dimensions, headers, port/row areas, label rectangles and clearance in world units. Use the same prepared text and measurement as painting, including multiline labels and fallback glyphs. Feed label occupancy into spacing/routing, then validate the final rendered geometry. Fitting labels only after arranging boxes can recreate collisions.
5. **Atomic application.** Return a candidate presentation delta, validate it, then create one Undo entry. Failure/cancellation leaves the model unchanged. Undo restores complete prior geometry; Redo and save/reopen reproduce the result. A stale asynchronous result must not overwrite edits made since its input snapshot.
6. **Quality, not an impossible universal promise.** Require no invalid geometry, prohibited node/header penetration, or lost constraints. Minimize avoidable crossings, label collisions and shared-path ambiguity before compactness and bend count. Preserve a satisfactory baseline when candidates are worse. Hard constraints may make a zero-collision drawing impossible; report the residual rather than claiming success from a cost scalar.
The local spot-check explains one important seam: `crates/oa-core/src/session.rs::layout` already obtains `projection::parents`, recursively sizes nested nodes and rejects Scenario geometry. `routing.rs` explicitly distinguishes persisted authored pins from bounded derived routes. Generated bends need not all become persisted manual pins: persist the chosen presentation change according to a declared regeneration policy. The #36 successor's measured State labels must be reconciled when integrating; this base predates it. No claim about uninspected callers or final API follows from this spot-check.
## Native Rust candidate: finite algorithm experiment
This is a proposal, not an implementation prescription. Build a temporary layout graph with stable IDs, measured boxes, containment, real port constraints and typed edges. Determine feedback edges for cycles without modifying semantics; assign layers; insert temporary long-edge/label placeholders; perform a fixed number of alternating barycenter/median sweeps with adjacent swaps accepted only when crossing cost improves; assign non-overlapping coordinates. Lay out children with explicit boundary connection ordering and reserve parent headers/port areas. Bottom-up subtree packing alone is a baseline, not proof of cross-hierarchy quality.
Route orthogonally around the resulting obstacles, with penalties for bends, crossings and excessive shared paths. Place measured labels in reserved corridors; if conflicts remain, permit a bounded spacing/rerouting pass. Remove duplicate and collinear intermediate vertices. Keep separate metrics for edge crossings, shared-path length, label collisions, bends, area and displacement so a shorter route cannot disguise a newly unreadable diagram. Stable tie-breaking, bounded passes and retaining the best valid candidate limit oscillation and cost. Compare this candidate against the same finite fixtures processed by elkjs before investing in a general framework.
## Falsifiers and finite milestones
**First: freeze the evaluator and geometry policy.** Six small fixtures are enough to expose foundational errors: (a) a two-layer order swap with a known crossing-free solution; (b) a diamond plus cycle/self-loop; (c) a two-level OA containment graph with internal and cross-container connections; (d) SA deployment nesting with fixed semantic ports and high-degree fan-out; (e) long/multiline labels in a tight corridor; (f) disconnected components, collapsed content and an already tidy layout. Include one route needing added bends and one needing removed bends. If scope excludes any fixture feature, state that before scoring.
Count proper interior intersections between distinct routed edges separately from collinear overlaps. Exclude common endpoint contact, but do not exempt edges merely because they share a source: an intersection elsewhere still counts. Count label-label and label-unrelated-edge/node intersections; intentional own-edge label contact and container background containment need specific exemptions. Check allowed container boundary passages rather than counting every containment-border crossing as an error. Record scores on final routes, not only straight-line layer proxies.
**Second: choose using measured results.** For the known solvable fixtures, demand the known crossing/collision target, semantic equality, correct port attachment and legal containment. Test deterministic repetition, a second Arrange reaching a stable result, and small perturbations not causing unbounded growth. Include invalid inputs/work-budget exhaustion; preserve the input on failure. Set an interactive-time budget against a declared representative model size and hardware before timing, rather than inventing a universal threshold here.
**Third: verify the real workflow.** Parent performs Arrange, inspects routes and labels at normal reading zoom, Undo, Redo, save and fresh reopen on OA and SA models. Check that connections remain traceable and nesting still conveys the intended relationship; aggregate crossing counts alone do not establish readability. No such execution or acceptance is claimed by this research.
Recommended next decision: settle reset/preservation of bends, endpoint attachments and label anchors, then compare the native layered candidate and elkjs on the same compound, port-and-label fixtures. Route-only improvement is a useful fallback capability but should not silently redefine the user's Arrange request.
## Parent's current code findings: implication for the first experiment
Parent reports that the existing router already has deterministic bounded orthogonal search, obstacle exclusion, crossing/shared-run penalties and automatic label collision sampling; current Arrange uses a four-per-row root placement after topological/alias ordering. These are parent-provided inspection findings, not independent runtime verification here. They materially favor **reuse of existing routing plus improved node arrangement** as the first candidate, rather than reimplementing routing or immediately adopting another engine. Feed its final-route metrics back into a bounded choice among placement candidates; label sampling alone still cannot create missing corridor space.
Parent's proposed policy—preserve semantic ports, authored port anchors and endpoint attachment preferences; reset only visible effective bends/label overrides during explicit full Auto-layout; retain hidden/promoted metadata; leave automatic elbows derived—is a coherent candidate. Its key falsifier is visibility scope: a folded/proxy edge must not cause deletion of dormant metadata belonging to a hidden original endpoint/route. Define “visible effective” by exact original edge ID plus unsuspended geometry, not just membership in the rendered edge list. Test folded then expanded diagrams, ancestor-promoted endpoints, partially visible authored routes and a visible label whose anchor was inherited. If retained dormant metadata becomes effective on expansion, the restored manual shape is expected under this policy and should not be advertised as newly automatically arranged. Explicit full Auto-layout can reasonably reset visible label overrides because its stated purpose authorizes rearrangement, but ordinary redraw, drag and routing must not acquire that reset behavior.
Compare reuse against elkjs only after scoring this bounded candidate. A successful existing-router experiment removes much of the justification for another runtime; persistent compound/port-order failures would supply concrete reasons to reopen engine adoption. The newly observed untracked `harness/auto-layout-investigation-20260925.md` in the worktree is parent work; this research did not modify it or product source.
@@ -1,51 +0,0 @@
# #32 residual routes after terminal-join candidate J
Read-only diagnosis of run03 `command/output.log`, `.local/auto-layout-j-comparison-20260925.json`, the geometry-only crossed-bipartite PNG, and current `routing.rs` / `projection/port_layout.rs`. No edits, builds or native GUI execution. J is an experimental cost correction, not an accepted readability improvement for every scene.
## The remaining187 units are not coincident distinct ports
The four functional exchanges deliberately reuse four semantic ports:
| Flow | Source port | Target port |
| --- | --- | --- |
| 0 (`009`) | `010` | `014` |
| 1 (`00c`) | `013` | `011` |
| 2 (`00f`) | `010` | `011` |
| 3 (`012`) | `013` | `014` |
Projected source ports are `(264,106)` and `(264,286)`; target ports `(346,106)` and `(346,286)`. Each port is a single real semantic identity serving two edges. There are no authored port anchors in this fixture. The route endpoints and ports were unchanged by J. Splitting a common port into several displayed ports or changing relation port IDs would change the contract/semantics and is not a routing remedy.
Of187 pairwise shared units,72 are four legitimate common-port18-unit terminal stubs. The remaining115 are four avoidable interior overlaps:
- Flow0/Flow2: `y=106, x=282..328`, length46.
- Flow1/Flow2: `y=106, x=305..328`, length23.
- Flow1/Flow3: `y=286, x=282..305`, length23.
- Flow0/Flow1: `y=106, x=305..328`, length23. These routes have different endpoints, so this is not a common-port exemption.
These sum115. The two diagonals now have separate vertical channels (`x=328` and `x=305`); horizontal tails still merge different routes. The geometry PNG confirms that a user sees shared horizontal lines with branch contacts, not four independent readable connections. It is not native/glyph evidence.
`port_layout::layout` chooses automatic sides from peer centers, semantic fallback and clear exits; explicit anchors override the resulting side/offset. It sorts multiple automatic ports on a side by peer-axis coordinate and ID, then allocates distinct positions around fixed anchors. Here each node has only one relevant port, so improving distinct-port separation would not address this fixture. Moving a common automatic port could alter geometry but still leaves all its incident edges sharing the same physical endpoint. Fixed anchors must remain exact.
## J trades one objective against another
J improves layered diamond overlap82→36 (nonterminal0) and layered bipartite344→187 (nonterminal115). But the original unarranged bipartite regresses730→872 shared units (nonterminal658→800), while bends5→4 and length2076→2064. The current original routes use a320-unit top horizontal trunk and a160-unit second-diagonal tail; later straight edges overlap those trunks. This is consistent with a length/bend-biased objective, not evidence that J universally improves routing.
`crossing_cost` charges only0.12 per shared unit, capped80 per prior segment; a turn costs24. J now compares more complete turn costs, so it can rationally prefer a cheaper route with more overlap. The router evaluates edges in stable-ID order and does not jointly revise earlier paths. Terminal fairness alone cannot correct an objective that undervalues ambiguous shared strokes. Public/ordinary scenes must remain in the comparison; do not judge only Arrange-generated geometry.
## Next bounded experiment: penalty versus available lanes
Recommend a tiny factorial comparison with node positions, ports, anchors and edge order frozen: J; J+P; J+L; J+P+L. Keep the original unarranged bipartite as a named regression sentinel alongside layered diamond/bipartite and the existing corpus. This separates missing candidate geometry from cost preference; no placement change initially.
**P:** use one predeclared experimental overlap coefficient, e.g.3.0 instead of0.12, retaining the80 per-segment cap and proper-crossing24 initially. This is a falsifier, not a proposed final default. A12-unit dogleg around a straight shared span can cost24 additional length plus96 for four full-path turns. The cheap existing coefficient cannot compensate for that; even3.0 with the80 cap may still reject it. Record this failure explicitly rather than repeatedly tuning. A second experiment removing/changing the cap would require separate justification; the cap and segment subdivision make the objective unlike a simple global shared-length weight.
**L:** offer a bounded number of parallel candidate coordinates at `CLEARANCE=12` on either side of occupied horizontal/vertical segments, subject to current obstacle clearance and fixed terminal stubs. These are derived path candidates, never authored waypoints. Put them in fast-path candidate space and, if needed, the grid space consistently, with a deterministic global cap. The current obstacle-boundary/midpoint coordinate set does not systematically offer these close parallel lanes. More space between boxes alone cannot supply the missing candidate coordinate or change its cost preference.
A smaller staging of this comparison is to run P first and inspect actual chosen alternatives, then add L only if the recorded candidates lack the desired lane. Do not silently combine penalty, cap, edge ordering and node spacing changes. If P merely creates crossings/long exterior detours, that disproves it as a standalone improvement. If L offers legal lanes but they are never selected, that isolates the objective. The result may justify a different explicit priority or bounded shared-segment nudge, but not before this evidence.
**Alignment control:** an additional fixed24-unit inter-layer gap or stagger is lower priority. It changes the diagram and automatic peer positions, complicates attribution, and can lengthen shared trunks. Alignment may be useful for a particular placement heuristic, but it is not a direct explanation of identical horizontal tails between the same common ports. Port-placement changes are outside the identified dependency scope.
## Acceptance constraints for the experiment
Keep exact semantic graph, port identities, attachments, authored anchors and generated-versus-authored route distinction. Report raw overlap, legitimate terminal overlap, nonterminal overlap, proper crossings AND branch/bend contacts, labels, obstacles, length, turns, warnings and workload. Current proper-crossing zero excludes contacts at bends; trading coincident strokes for distinct, obvious crossings can improve traceability even if that one metric rises. Conversely, tiny offsets that appear merged at normal zoom are not success.
Use shared-port fan-out/fan-in and distinct-port controls, fixed-anchor controls, narrow corridor, authored-pin and opposite-direction routes. Compare original, reset-only and layered scenes, plus a fixed edge-ID permutation to expose priority sensitivity. Parent should inspect actual route tracing at normal zoom before adopting any weight/lanes policy. No quality acceptance or port-placement defect is asserted here.
@@ -1,106 +0,0 @@
# First automatic-layout experiment
## Decision
Retain the hierarchy/label-spacing prototype for further experiments; do not
adopt it as production Auto-layout. It improves several named fixtures beyond
resetting old bends, but regresses shared connector length in two fixtures.
Shorter routes and zero proper crossings do not establish readable connections.
The next experiment should isolate route selection at fixed node positions,
before adding more node spacing or replacing the existing routing engine.
## Method and receipt
Tested source `e7deea7039bcf31675cffbb462cfeb19d62a7e20`, based on released
`715036e132677dd2bc2fb37ed316d93dc9987aec`. No production file changed in that
commit. Windows x86_64-pc-windows-gnullvm, Rust 1.98.1, LLVM mingw, one Cargo job,
incremental compilation disabled; Linux excluded. Command:
```text
cargo run --locked --offline -p oa-core --example auto_layout_probe
```
The recorded runner completed at 2026-09-25T00:42:06Z, exit 0, unchanged inputs,
no recorded failure. stderr contains only compile/run progress. This means the
diagnostic process completed, not that layout quality passed. The JSONL has one
metadata record and nine fixture records, with full input/result Projects and
core route geometry. Its SHA256 is
`21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1`.
[Evidence archive](run-01/probe-e7deea70.zip), 900995 bytes, SHA256
`c52f82d4782cd6a7f8cbe67498c8b2371e99204a65976aa8bcbbd0a86f835069`,
contains original receipts, source inventories, output, runners and 37 offline
geometry PNGs. [Manifest](run-01/manifest.json) binds every member and the
post-run executable identity. All archive members were byte-compared to the
local originals. Original absolute paths describe this machine; reproduce using
the source commit and command above, with a suitable Windows Rust toolchain.
## Observations
Values below compare existing Layout with the layered prototype. Reset-only
matches existing Layout except for the authored-detour fixture, where it already
reduces length to 240, bends to zero and area to 90816. Shared length is pairwise
segment overlap, not the union of occupied space; area uses recorded bounds.
| Fixture | Shared length | Label/node collisions | Route length | Bends | Area |
| --- | ---: | ---: | ---: | ---: | ---: |
| Four-node chain | 0 → 0 | 0 → 0 | 720 → 246 | 0 → 0 | 213312 → 150744 |
| Seven-node chain | 0 → 0 | 0 → 0 | 2560 → 492 | 8 → 0 | 523584 → 271920 |
| Diamond | 36 → 82 | 0 → 0 | 2200 → 688 | 8 → 4 | 300576 → 260832 |
| Crossed bipartite | 276 → 344 | 0 → 0 | 3284 → 688 | 12 → 4 | 300576 → 165360 |
| Nested cross-boundary | 0 → 0 | 1 → 0 | 1076 → 364 | 6 → 2 | 284096 → 366528 |
| Authored detour/label | 0 → 0 | 0 → 0 | 2348 → 187 | 6 → 0 | 628040 → 83820 |
| Fixed ports | 0 → 0 | 0 → 0 | 916 → 863 | 4 → 4 | 125976 → 116754 |
| Reversed children in two containers | 148 → 0 | 1 → 0 | 872 → 372 | 4 → 0 | 288512 → 272800 |
| Long ordinary labels | 36 → 36 | 2 → 0 | 1340 → 1436 | 4 → 4 | 200448 → 208800 |
Proper interior crossings, unrelated node overlaps, unrelated edge/node
penetrations, label/label collisions, unrelated label/stroke intersections and
route warnings are zero in the baseline, reset and candidate outputs. This is
not proof of crossing elimination: the crossing definition excludes bend and
T contacts, and overlapping strokes can make endpoint pairing ambiguous.
The parent inspected offline geometry for the reversed-container baseline and
candidate, crossed-bipartite candidate and long-label candidate. Reordering the
right container's children separates the two routes; the bipartite result has
a visually ambiguous shared central trunk. The long-label boxes occupy the
available corridors. These images render core boxes/lines, not actual label
glyphs or the desktop application, and therefore are not native UI acceptance.
Every candidate reports exact same-input determinism, idempotence and equality
to its first result for calls 2–10. Reported semantic, node identity/metadata and
nongeometry conservation hold. JSON and canonical single-source parse/format
round trips hold for all nine before/baseline/reset/candidate Projects. The
authored-detour route changes intentionally. These assertions do not separately
establish hidden-route or attachment lifecycle policy. Other-view conservation
is vacuous in these single-view fixtures.
Existing Layout's Session operation passes one-step Undo/Redo and history
checks. The pure candidate function has no integrated command/history evidence.
The [source review](probe-review.md) defines remaining metric and fixture gaps.
Folded/filtered topology, State/Allocation diagrams, cycles/undirected graphs,
large models, multifile sources, application/API admission and native editing
remain outside this run. Issue36's multiline State candidate is not in this base.
An [independent results audit](results-audit-01.md) checked receipt bindings,
complete Projects, all 37 image hashes and eight offline images. It independently
recomputed both shared-route regressions and confirmed the scoped conclusions.
## Implications
Resetting obsolete bends is useful but insufficient. Hierarchy ordering and
label clearance have distinct value, with measurable area/length tradeoffs.
Increasing a global gap cannot be assumed to fix shared-route ambiguity.
Inspect the existing router's candidate scoring and compare complete endpoint
join costs, overlap penalties and available lane candidates using the same
geometry. Preserve the full quality vector and endpoint/obstacle invariants;
reject a change that merely converts overlap into uncounted contacts.
The [routing inspection](routing-experiment-plan.md) identified a concrete
asymmetry: a locally selected route leg scores the source join but omits the
target-stub join until after choosing that leg. Test that small correction first,
at unchanged node positions, before the auditor's proposed alignment/spacing
alternatives. This is a falsifiable source-based hypothesis, not a conclusion
that it explains all shared routes. A separate committed candidate will preserve
this first control; no overlap weight or node placement should change in that
comparison.
@@ -1,56 +0,0 @@
# #32 first experiment — independent results audit
Source e7deea7039bcf31675cffbb462cfeb19d62a7e20, production baseline715036e132677dd2bc2fb37ed316d93dc9987aec. Command: `cargo run --locked --offline -p oa-core --example auto_layout_probe`; Windows x86_64-pc-windows-gnullvm/dev. Recorded exit0, failure=null, source inputs unchanged. Independently checked receipt-bound log hashes and equal before/after source inventories. No rerun, compiler, GUI or model helper executed.
**Conclusion: useful evidence for continued experimentation, not a production winner.** All nine finite fixtures completed; candidate conservation/repeat/roundtrip checks pass. Two shared-run regressions are real, while nested/reversed-container/long-label improvements are also supported. There are no predefined quality thresholds, so exit0 means the probe ran, not that layout quality passed.
## Verified results
Exactly one metadata row plus nine fixture rows. Every candidate is supported and valid as recorded, repeats exactly from identical input, equals its second-call result, and remains equal to its first result through calls2–10. Existing Layout likewise has passing repeat/history checks. Every before/after/reset/candidate canonical JSON and single-source roundtrip is recorded exact. Candidate semantic/node-identity/visibility/port-anchor/nongeometry booleans are true on all nine. I independently compared complete output Projects after restoring only diagrams, and checked target layout fields and route attachment fields against input: no difference. Most attachment lists are empty, so this is not broad attachment coverage. Other-view checks are vacuous for single-view fixtures.
The authored_detour_and_label candidate intentionally has authored_routes_exact=false because its three pins and label override are removed. That is the experimental reset policy, not an unexplained conservation failure. Reset-only already removes the detour; layered must earn its additional complexity elsewhere. Actual Undo/Redo was executed only for existing Command::Layout. Layered is a pure transformer with no command/UI integration or history evidence.
Values below compare **baseline Layout plus reset-only control → layered candidate**, not the original hand-placed input:
| Fixture | Shared length | Label/node pairs | Bends | Route length | Drawing area |
| --- | --- | --- | --- | --- | --- |
| flat_chain_4 | 0 → 0 | 0 → 0 | 0 → 0 | 720 → 246 | 213312 → 150744 |
| chain_7_row_wrap | 0 → 0 | 0 → 0 | 8 → 0 | 2560 → 492 | 523584 → 271920 |
| diamond | 36 → 82 | 0 → 0 | 8 → 4 | 2200 → 688 | 300576 → 260832 |
| crossed_bipartite | 276 → 344 | 0 → 0 | 12 → 4 | 3284 → 688 | 300576 → 165360 |
| nested_cross_boundary | 0 → 0 | 1 → 0 | 6 → 2 | 1076 → 364 | 284096 → 366528 |
| authored_detour_and_label | 0 → 0 | 0 → 0 | 0 → 0 | 240 → 187 | 90816 → 83820 |
| fixed_ports | 0 → 0 | 0 → 0 | 4 → 4 | 916 → 863 | 125976 → 116754 |
| two_containers_reversed_children | 148 → 0 | 1 → 0 | 4 → 0 | 872 → 372 | 288512 → 272800 |
| long_ordinary_labels | 36 → 36 | 2 → 0 | 4 → 4 | 1340 → 1436 | 200448 → 208800 |
All these control/candidate outputs report zero strict proper crossings, unrelated-node collisions, unrelated edge/node penetrations, label/label pairs, unrelated label/stroke pairs and routing warnings. Therefore this run provides **no discriminating evidence of proper-crossing reduction**. Zero warnings is not proof of unambiguous independent routes.
## Independently checked regressions and improvements
- **diamond:** shared length36→82. Control has two18-unit common terminal runs. Candidate retains those and adds one46-unit horizontal shared run for edge pair0009/000c. Nonterminal shared length0→46; this is not just a terminal-exemption artifact. Bends8→4 and length2200→688 improve, but they partially come with merged routing.
- **crossed_bipartite:** shared276→344, nonterminal204→272. Control has four18-unit common-tip runs plus204 horizontal. Candidate has those same72 terminal units, two additional46 horizontal overlaps, and a180-unit vertical overlap between distinct edge pairs0009/000c. Independent axis-interval sums match the emitted values exactly. A more compact drawing and fewer turns do not erase this distinguishability regression.
- **nested_cross_boundary:** reported label/node collision1→0 and route length1076→364; area284096→366528 increases about29%. The geometry PNG shows the parent widened to place its children horizontally. This is a real tradeoff, not universal improvement.
- **two_containers_reversed_children:** shared148→0, label/node1→0, bends4→0, length872→372 and area288512→272800. The hand-authored alternate child order exists independently; the candidate's straight separated connections support a meaningful ordering improvement over reset-only.
- **long_ordinary_labels:** label/node2→0, shared36 unchanged, but length1340→1436 and area200448→208800 grow. This is supported clearance-for-space tradeoff using approximate core rectangles, not native glyph evidence.
Viewed eight offline PNGs: baseline/candidate diamond, baseline/candidate crossed_bipartite, baseline/candidate nested_cross_boundary, candidate two_containers_reversed_children and candidate long_ordinary_labels. They corroborate route sharing and spacing changes. All37 image identities and their manifest binding to the original output log were checked. Images are schematic rectangles/polylines with fixed scale per fixture, not application renders or text measurements.
## Method limits versus actual failures
Proper-crossing definition excludes T contacts and bend intersections; shared length counts pairwise traversal multiplicity. I recomputed the two reported regressions independently from axis-aligned intervals, so those particular increases are actual geometry outcomes, not metric bugs. An unconditional dominance claim fails on those results, but the experiment has not predeclared a total preference ordering across compactness and sharing. Retain the whole metric vector.
Reset eligibility is post-baseline-Layout versus pre-layout in the candidate; equivalent for current unchanged-visibility fixtures only. No folded/filter/omitted-node/State/Allocation/undirected-cycle/dense scaling/UI admission/multi-file source lifecycle acceptance follows. The source work budget is partial and runtime complexity was not established here. No native GUI, universal label-ink correctness, candidate command Undo, or issue32 completion is claimed. Base715 lacks the separate36 measured State label follow-up.
## Next falsifiable experiment
Keep these nine frozen inputs and add one explicit orthogonal proper-crossing witness plus a non-incident overlap/touch counterexample to verify the evaluator distinguishes them. Then isolate diamond/bipartite routing interaction with a tiny predeclared set of node-placement alternatives (current top alignment, centered rank, deterministic stagger, conservative wider gap), keeping the router unchanged. Record actual routed metrics for every alternative; do not silently choose whichever plot looks best or optimize only bend count.
Hypothesis: a bounded alignment/spacing change can remove the candidate's excess nonterminal sharing without surrendering reversed-container and long-label gains. Falsifier: no alternative reaches control-or-better shared length while retaining no new obstacle/label collisions and predeclared reasonable area/length bounds. Use controls' reported geometry as a fixed comparator and preserve every alternative; if it fails, investigate router channel/parallel-edge handling rather than repeatedly widening all spacing or introducing a whole external engine. This next probe remains diagnostic; attachment/hidden-route sentinels and real command/history/native validation follow before production selection.
Evidence SHA256:
- 01-probe/command/result.json: `119179803e8c26b960ca4280e0429bb537a6f9ff5666267e49bfab989f48dc73`
- 01-probe/command/output.log: `21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1`
- 01-probe/command/inputs-before.json: `2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe`
- 01-probe/command/inputs-after.json: `2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe`
- geometry/manifest.json: `022fa0f307f9fb92fccbbc139637c62f40d1dcc3a4ea555a35c6b08d82bdb3b5`
@@ -1,92 +0,0 @@
# Target-join scoring experiment
**Keep experimental.** Accounting for the target turn during route-leg selection
confirms the suspected source/target scoring asymmetry, but does not establish
uniform readability improvement. The original bipartite scene becomes more
overlapped. Do not integrate this branch as a completed Arrange fix.
Production routing/test source is `497c1ad8b39788148c5f03bc8542d6d8e28dbc01`.
Successor `08656b18bf8934cf264469cf68fc6dea97c15cbb` only resolves three strict
Clippy findings in the diagnostic observer. Probe runs at both commits emitted
the same 371192 bytes, SHA256
`f9ac7c1320b1cf026259bfcaf13a39e968ae317463712d8dc793375d9a4a541f`.
The [independent source review](terminal-join-source-review.md) and
[independent results audit](terminal-join-results-audit.md) retain the scoped
claims and limitations. The latter predates the completed contact audit below.
The [evidence archive](run-j/terminal-join-08656b18.zip) contains original runs
02–06, including the failed Clippy run, source inventories, full Projects and
geometry, comparison JSON, independent contact audit/script, runners and 37
offline geometry images. It is 3178939 bytes, SHA256
`bd2146237353af05b8667dc7bd728007143a2f1b24ad9f8728fdbc50ffb3368e`.
[Manifest](run-j/manifest.json) records every member; every archived byte was
compared with its original. Paths in original receipts identify the builder's
machine, not a dependency on a peer's filesystem.
## Checks and comparison
Windows Rust 1.98.1 gnullvm, locked/offline, one job, incremental disabled;
Linux excluded. All receipts close with unchanged inputs and no runner failure.
```text
cargo test --locked --offline -p oa-core --lib --test release_routing --test routing_geometry --test routing_labels --test state_diagrams --test group_movement
cargo run --locked --offline -p oa-core --example auto_layout_probe
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
```
Tests: 185 passed, zero failed/ignored (132 library, 7 group movement, 13 release
routing, 18 routing geometry, 2 routing labels, 13 State). Three new private tests
exercise fast/grid terminal scoring in four orientations, absent continuation,
straight/reversal and zero-length legs. Existing authored-pin/attachment/history
checks pass. Initial Clippy rejected two manual-range expressions and one nested
if in the observer; after that observer-only cleanup, strict core/all-target
Clippy passes and the rerun output is byte-identical. No whole-workspace/native
acceptance is implied.
Across all 37 before/baseline/reset/layered/manual-witness variants, complete
Projects, projected nodes, projected ports and exact route endpoints are
unchanged versus the first experiment. This isolates routing/label placement;
it is stronger than merely reusing fixture input. All nine candidate invariants,
round trips, deterministic repeats and calls 2–10 remain exact. The candidate
still has no integrated command Undo/Redo evidence.
- Layered diamond: shared length 82→36, nonterminal 46→0; all other original
metrics unchanged. Remaining 36 units are common terminal stubs.
- Layered bipartite: shared length 344→187, nonterminal 272→115; routed drawing
area 165360→175960. Labels, bends, length and other recorded metrics unchanged.
Seven other layered metric vectors are unchanged.
- **Original bipartite regression:** sharing 730→872, nonterminal 658→800;
bends 5→4 and length 2076→2064. The scoring change can favor fewer bends over
distinguishable routes. This is not a monotonic quality improvement.
- Original diamond sharing 136→36; original two-container sharing 200→54;
existing Layout/reset-only two-container sharing 148→18. The independent
audit lists the complete changed vector; 30 of 37 variants are unchanged.
The separate Python contact audit uses orthogonal interval reasoning to
reconstruct BOTH old and new contacts. Primitive witnesses pass; all 37 new
contact/proper counts match Rust, and all 37 old proper counts match v1. Layered
diamond contacts decrease 3→2. Layered bipartite contacts increase 8→9: eight bend
contacts become six bend plus three T contacts. Proper crossings remain zero.
Thus decreased coincident length partly trades into branch contacts. Details,
edge IDs, coordinates and definitions are in the archive's
`support/auto-layout-contact-audit-result-20260925.json`; script is adjacent.
There is no inferred aggregate quality pass.
The parent viewed diamond/bipartite offline images: diamond branches separate,
but bipartite horizontal tails remain visually joined despite separate vertical
channels. These are schematics of core bounds, not native application or glyph
evidence. The [residual diagnosis](residual-routing.md) confirms four genuine
shared semantic ports; distinct-port placement is not the defect. Of 187 shared
units, 72 are bounded common-port stubs and 115 are avoidable interior overlap.
## Next discriminating comparison
Retain this J result as a frozen control. Test one predeclared overlap coefficient
3.0 instead of 0.12, with the existing per-segment cap 80, crossing cost 24,
node/port geometry and edge ordering unchanged. This P variant is an isolated
experiment, not a proposed default or a release-ready source change. Preserve
the original bipartite regression as a sentinel and the entire quality vector.
If stronger penalties cannot choose useful existing alternatives, inspect
bounded parallel lane candidates separately; do not silently combine scoring,
spacing, caps and ordering changes. A candidate that shifts overlap into hard-to-
follow contacts, label collisions or large detours has not solved the user task.
@@ -1,53 +0,0 @@
# #32 next routing experiment: distinguish shared terminals from ambiguous trunks
Local source/receipt diagnosis only; no builds, formatters, GUI or product edits. Input: completed `e7deea70` probe `.local/auto-layout-checks-20260925/01-probe/command/output.log`; implementation read from `.local/auto-layout-20260925/crates/oa-core/src/routing.rs`. The probe uses **core estimated label bounds**, not renderer glyph measurement. No new candidate module is justified until the required private routing hook is agreed.
## Exact observed geometry
The layered diamond has 82 units of pairwise shared length: 36 classified as common terminal stubs, 46 nonterminal. Edges ending `009` and `00c` share the physical output `(264,106)`, its 18-unit stub to `(282,106)`, then an avoidable extra horizontal run to `(328,106)`. Edge `00c` then turns down to `(328,286)` and enters `(346,286)`. A candidate branching immediately at `(282,106)`, going to `(282,286)`, then right to the target would separate that extra 46-unit trunk while preserving the port and stub. This is a geometric proposal, not a verified collision-free final result; the same obstacle and label checks must evaluate it.
The layered crossed-bipartite case has 344 shared units: 72 common-terminal, 272 nonterminal. Its opposite diagonal routes both use `x=328, y=106..286`, an exact 180-unit coincident vertical run. Top and bottom horizontal portions add 92 nonterminal shared units. The source stubs are at `x=282`; target stubs at `x=328`. Proper crossings are reported as zero, but these coincident routes do not provide unambiguous independent traces. Do not treat the zero as a solved crossing problem.
## Source evidence: a local scoring asymmetry precedes penalty tuning
`crossing_cost` charges `min(shared_length * 0.12, 80)` per previously occupied segment, multiplied by segment count. A proper interior crossing costs24. `BEND_COST` is24. `RoutingSpace::new` uses obstacle boundary coordinates; fast-path candidates add the start/end midpoint, and grid coordinates add start/end. Neither systematically adds lanes offset from previously occupied trunks.
More importantly, `fast_path(start, end, space, previous, incoming)` includes the turn from the preceding source-side segment, but has no outgoing direction parameter. `route_edge` chooses this local path and only later appends the final target attachment point `b.point`; complete `path_cost` is then compared across endpoint choices, not across the discarded alternative paths for that same endpoint pair. With fixed semantic ports there may be no alternate endpoint choice to recover the discarded route. `grid_path` is only a fallback when `fast_path` returns None, not a better-cost search after a valid fast path.
For the diamond, horizontal-then-vertical has one internal bend and no incoming bend; its 46-unit shared portion costs5.52. Vertical-then-horizontal has one internal bend plus an incoming bend, costing24 extra locally. But appending the target segment adds the missing final bend to horizontal-then-vertical. Complete paths have equal bend counts; the local comparison prefers the shared route because it omitted one terminal join. The 180-unit bipartite overlap costs21.6, also below one turn. This explains a plausible bias, not proof that correcting it resolves every route.
## Minimal discriminating comparison (proposal to parent)
Freeze layered node positions, labels' text, attachments, port identities/anchors, edge order and work budgets. Compare a small fixed sequence, not an open parameter search:
1. **J: symmetric terminal-join cost.** Supply an optional outgoing point/direction to fast-path scoring. For the final segment to `b.stub`, use `b.point`; include `turn_cost(penultimate, end, outgoing)` both in actual candidate score and admissible pruning estimates where applicable. Intermediate authored pins have no predetermined outgoing direction; do not invent one. If grid routing supports this experiment too, target state selection must account for the terminal turn before returning the best result. Preserve baseline behavior for absent outgoing context. This isolates the suspected bias without stronger penalties or enlarged diagrams.
2. **JP: J plus stronger shared-run penalty.** One declared test coefficient, e.g.1.0 per unit with the existing80 cap, compared with0.12. Keep the proper crossing cost24 unchanged so the comparison exposes whether penalizing overlap merely substitutes crossings. Do not choose this variant solely by shared length. Preserve all hard endpoint constraints.
3. **JPL: JP plus bounded lane candidates.** For occupied horizontal/vertical trunks, offer parallel offsets of `CLEARANCE` on either side where applicable; validate obstacle clearance and fixed terminal stubs. Use a strict deterministic global cap on added coordinates/candidates and reuse existing work exhaustion handling. This tests whether absent geometric alternatives, rather than insufficient penalty, is the blocker. No persisted waypoint authoring and no unconstrained port movement.
4. **Spacing control.** Only if J/JP fail, compare the unchanged router with one fixed additional inter-layer gap (e.g.24 world units). This is a control, not the assumed remedy: larger gaps may lengthen shared trunks and enlarge diagrams while retaining identical topological ambiguity. Do not mix spacing and router changes in the first comparison.
The first J variant is the smallest informative next experiment. If it removes diamond's extra trunk while retaining invariants, stop and inspect results before implementing JP/JPL. A clean shared terminal fan-out need not eliminate its unavoidable initial18-unit stub. Conversely, sharing a node identity does not exempt arbitrary later overlap, and different physical ports on the same node must not be collapsed into one endpoint exemption.
## Required hook and ownership boundary
The candidate module can call public `route_diagram`, but cannot change private `crossing_cost`, `fast_path`, `grid_path` or candidate generation. Returning a changed Project with temporary authored waypoints would test a different policy and would violate the generated-elbows-remain-derived experiment. Copying the entire router into an example introduces unnecessary divergence.
Therefore propose a parent-owned isolated routing experiment hook, with no default production behavior change, or a separate disposable source variant controlled by the existing runner. Evaluator needs an optional routing provider returning `RoutedView` for a fixed Project, rather than only `candidate -> Project`. It must reuse that provider for every metric, capture, and repeat check and label the variant explicitly. Until parent approves that hook, no module/interface changes were made. The current original/reset/layered comparisons remain reproducible.
## Falsifiers and risks
Evaluate both raw and terminal-excluded shared length, proper crossings, T/bend contacts, route lengths/turns, unrelated-node/header penetration, label collisions, warnings and runtime/work counts. A positive overlap moved onto a bend is not a successful reduction in intersections. The crossed K2,2 fixture with fixed port ordering may require a genuine visible crossing or an outer detour; do not demand zero overlap AND zero crossings without a fixed-attachment feasible witness. Separate forced terminal contacts from independently traceable route interiors.
Keep diamond, crossed-bipartite, chain and long-label fixtures; add a fixed shared-output fan-out, two distinct ports on one node, opposite-direction connections, and a narrow obstacle corridor. Verify exact endpoint coordinates and metadata; preserve ordinary authored pins and preview/settled agreement if the routing change advances beyond this isolated experiment. Stable-ID order can privilege earlier routes, so rerun the same graph under a fixed edge-ID permutation and disclose sensitivity instead of relabeling edges to improve the score.
Labels are placed after paths settle; lane changes can worsen label collisions even when node positions stay fixed. Parent's eventual GUI inspection should trace each diagonal from its source label/port through the corridor to its intended target at normal zoom. No geometry metric alone establishes that user outcome. This note selects a falsifiable experiment, not an accepted product fix.
## Exact J patch plan, held for parent's separate candidate pin
No edits to `routing.rs` are authorized yet. After parent publishes the first experiment evidence, the narrowly proposed change is:
- Add `outgoing: Option<Position>` beside `incoming` in private `fast_path` and `grid_path` only. At `route_edge`'s loop over authored pins followed by `b.stub`, pass `Some(b.point)` only when `index == pins.len()`; pass `None` for every intermediate waypoint. The preceding settled path still supplies `incoming` exactly as now. No public API or metadata change.
- In `fast_path`, extend its shared `join(path)` closure with the terminal turn from `path[path.len()-2]` through `end` to `outgoing`, when two path points exist. Both `estimate(path)` and actual scoring already call `join`; change both consistently through that closure. Keep the two-bend lower-bound early exit: Manhattan distance plus two bend costs remains a lower bound for two-bend alternatives because terminal penalties are nonnegative. Keep final whole-path `path_cost` unchanged; local scoring does not persist a cost, so it does not double-charge the returned path.
- In `grid_path`, add the terminal turn **during relaxation when `next == last`**, using `turn_cost(a, b, outgoing)`. Include it in `costs[state]` and the queue priority before the target state can be popped. Do not merely add a penalty after returning the first reached target: that would fail to compare alternative arrival directions. The existing Manhattan heuristic remains admissible because the new terminal penalty is nonnegative. The initial incoming-turn logic remains unchanged.
- `start == end` has no locally selectable segment. Keep the degenerate result, and rely on whole-path scoring for the junction between preceding/following points. Do not fabricate a direction from an absent penultimate point. Waypoint positions/order remain exact; this patch does not claim globally optimal look-ahead across several hard-pin legs.
Focused tests before comparing full fixtures: (1) fast-path diamond geometry with one occupied horizontal trunk, fixed start/target stubs, asserting the equal-complete-bend alternative branches earlier; (2) directly exercise grid routing with two clear arrivals where adding the target join changes the preferred total, compare complete cost to an enumerated feasible alternative; (3) mirrored/rotated terminal geometry, including straight continuation and reversal costs; (4) outgoing=None preserves established intermediate-waypoint behavior; (5) full route with one/multiple authored pins retains exact pins and terminal attachments, plus start=end leg; (6) preview versus settled geometry remains consistent under the same baseline/budget. Use private unit tests for fast/grid so a passing public route cannot accidentally avoid the grid test. Then rerun the unchanged probe corpus and report the complete quality vector, especially any newly visible crossings. No expected overall improvement is asserted before execution.
@@ -1 +0,0 @@
* -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
@@ -1,217 +0,0 @@
{
"source_commit": "e7deea7039bcf31675cffbb462cfeb19d62a7e20",
"baseline_commit": "715036e132677dd2bc2fb37ed316d93dc9987aec",
"scope": "Nine small SA FunctionalDataflow diagnostic fixtures; Windows core geometry, not native UI or production acceptance.",
"binary_after_completed_run": {
"path": "target/debug/examples/auto_layout_probe.exe",
"bytes": 24039424,
"sha256": "a95736170f58d25c60a16f2683b961aad8b31f97e4357c3e0d2f7e81d21176bd"
},
"members": {
"run/command/changes.patch": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"run/command/context.json": {
"bytes": 3488,
"sha256": "4dd4e602c2b33fd229a7241e1a4d6a2de399df197b5613af4b3126bfd357c476"
},
"run/command/inputs-after.json": {
"bytes": 1722092,
"sha256": "2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe"
},
"run/command/inputs-before.json": {
"bytes": 1722092,
"sha256": "2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe"
},
"run/command/output.log": {
"bytes": 331975,
"sha256": "21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1"
},
"run/command/result.json": {
"bytes": 4124,
"sha256": "119179803e8c26b960ca4280e0429bb537a6f9ff5666267e49bfab989f48dc73"
},
"run/command/stderr.log": {
"bytes": 279,
"sha256": "492e4346b5daf9d9eed2aa96c89c5104b9d8d7f830074b1346c9f5aaf053d662"
},
"run/environment.json": {
"bytes": 9463,
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
},
"run/orchestration.log": {
"bytes": 165,
"sha256": "e4bf56f792c8e8fc9288fc08ef5f0159941b8026e435a427722b59ccc4e34831"
},
"geometry/authored_detour_and_label-after.png": {
"bytes": 12492,
"sha256": "44e30b94ef11b176155ab3df90fe5b52788c07442a42a355d3458fa904c147bd"
},
"geometry/authored_detour_and_label-before.png": {
"bytes": 12468,
"sha256": "097588fae4fd6253c25ab4b60be1ad685ead3a3012b206f382cec0be1341e700"
},
"geometry/authored_detour_and_label-experimental_candidate-metrics.png": {
"bytes": 13162,
"sha256": "82a07de168cda7dacdad2bd532dae408172466b2776acf8cfaabef6711c0539b"
},
"geometry/authored_detour_and_label-reset_only_control-metrics.png": {
"bytes": 12777,
"sha256": "e676edc49ec6ebb2fe29e976c36cba589f76330e7f9eb27b8835415a2ed5d225"
},
"geometry/chain_7_row_wrap-after.png": {
"bytes": 14271,
"sha256": "a020c1535e3d6e48cb5e6d7ae8403a256767c68ce26876fd773f3b626c61e365"
},
"geometry/chain_7_row_wrap-before.png": {
"bytes": 13771,
"sha256": "0b282cbd29eb3e29fa7d3bb9c3c9746caa14c77372b3191d78fa1a60743d0b11"
},
"geometry/chain_7_row_wrap-experimental_candidate-metrics.png": {
"bytes": 14396,
"sha256": "3ca042155d918083c5f4135ae059205b29b8ee5944819a6040a22eeb572f5f04"
},
"geometry/chain_7_row_wrap-reset_only_control-metrics.png": {
"bytes": 14549,
"sha256": "8828f289159deb0c2bedd130495197639775cd8edeb561f0de5501e2980d1e1d"
},
"geometry/crossed_bipartite-after.png": {
"bytes": 13144,
"sha256": "54a1b5e983bc9b6f619e67c13173d0ff5000e30f080aaf6c0a9064f59036f4b7"
},
"geometry/crossed_bipartite-before.png": {
"bytes": 13413,
"sha256": "8622726dfdfc01e93fcd2d9d22c6d082e5a169b9dcd59f788e876954431214fd"
},
"geometry/crossed_bipartite-experimental_candidate-metrics.png": {
"bytes": 13848,
"sha256": "de238d7e904a050b93cc63926f9b4b4723d4b98e6741971fc98f81d771a8a52e"
},
"geometry/crossed_bipartite-reset_only_control-metrics.png": {
"bytes": 13349,
"sha256": "0b2a8c49368258b23c15a8e06bf9b881f0e53fed8b5fd7d6f216a09837634abc"
},
"geometry/diamond-after.png": {
"bytes": 12556,
"sha256": "6afcff1e5c10758e5eab3b803ec858d0af3da953be4af93df75257544a7252b2"
},
"geometry/diamond-before.png": {
"bytes": 13255,
"sha256": "30d514155ec0e88a68e62089cd50b7747924a81095c957aa67b1bf5a70ac18f8"
},
"geometry/diamond-experimental_candidate-metrics.png": {
"bytes": 13581,
"sha256": "14dc1304822e74f8c7e0146a02af69ea39dc90d0d655a75b97383644c04b8900"
},
"geometry/diamond-reset_only_control-metrics.png": {
"bytes": 12982,
"sha256": "48414d7a859c774f2cef51d0fc1512b1c9fe1540c540f96fac7f9531583d8731"
},
"geometry/fixed_ports-after.png": {
"bytes": 11889,
"sha256": "574cb0249c3bcc3140b9d61906332295a8462e32fbcd6b9526d8b05e202e38a0"
},
"geometry/fixed_ports-before.png": {
"bytes": 12013,
"sha256": "d07ef2105aa63f3aaacb19e9a7ca4960239b01f05f734baa6270657a465b4ab0"
},
"geometry/fixed_ports-experimental_candidate-metrics.png": {
"bytes": 12394,
"sha256": "d7fdcd7d4a1a014dd34243400dcd74a7596388fc5747853e5a6629535ca3083e"
},
"geometry/fixed_ports-reset_only_control-metrics.png": {
"bytes": 12231,
"sha256": "237e181db92baf49c24554ec4325137b014413603b62cb08dee63e6e68fa3e8d"
},
"geometry/flat_chain_4-after.png": {
"bytes": 12582,
"sha256": "7326cd482a61bea4cb787770449253a55f41e102794c8fc6a8500d9244c20cc7"
},
"geometry/flat_chain_4-before.png": {
"bytes": 12723,
"sha256": "b086e2b23d4b68cb43366725a26fbb39fdc2e87d18cd7109c9b3ec950ab665ba"
},
"geometry/flat_chain_4-experimental_candidate-metrics.png": {
"bytes": 13405,
"sha256": "3bb352dd5066a3b2cc5bb298c9f47a02416186421df4841b96c944e523d11104"
},
"geometry/flat_chain_4-reset_only_control-metrics.png": {
"bytes": 12918,
"sha256": "eba3eede6771a1ebfc7b773b48b599f5ca49613ad7444ab26acfea7d20dde515"
},
"geometry/long_ordinary_labels-after.png": {
"bytes": 12643,
"sha256": "f4f0c360e9ce44d631dd1d9f7c202954be943fdbede6288edbaddf9b890fcaec"
},
"geometry/long_ordinary_labels-before.png": {
"bytes": 12661,
"sha256": "3c17c1195337cda7cd4141a15428b66cac1475421600b6ecffc3db57dbfd9f94"
},
"geometry/long_ordinary_labels-experimental_candidate-metrics.png": {
"bytes": 13235,
"sha256": "d2931b576901fec68b23afd1a54d4c301eda4a75ba30184344749e0add277bc0"
},
"geometry/long_ordinary_labels-reset_only_control-metrics.png": {
"bytes": 12969,
"sha256": "68aacc7196a4995ff8a884a15ec91a31bd386bdd2d232fd2c27696a7f83cf791"
},
"geometry/manifest.json": {
"bytes": 8099,
"sha256": "022fa0f307f9fb92fccbbc139637c62f40d1dcc3a4ea555a35c6b08d82bdb3b5"
},
"geometry/nested_cross_boundary-after.png": {
"bytes": 13344,
"sha256": "51db6785ab89f5d92c61288604ae3c76aee467e291738b54466db5704c0eec8b"
},
"geometry/nested_cross_boundary-before.png": {
"bytes": 13547,
"sha256": "3b3963f5ef5a42106a006f1825ae5d85b722aa3bf163452ba986ef3369b57cc2"
},
"geometry/nested_cross_boundary-experimental_candidate-metrics.png": {
"bytes": 13535,
"sha256": "0ba365785753e748dc7c86c3f1eb5767f9ccb87c0ae0e18085e6a9d32b79f60d"
},
"geometry/nested_cross_boundary-reset_only_control-metrics.png": {
"bytes": 13549,
"sha256": "fbe21b57616459ecdbbd083dccd73bd120f5b7ad59a8ad80ea38cbbf9c3f49c1"
},
"geometry/two_containers_reversed_children-after.png": {
"bytes": 13950,
"sha256": "1536fa9da0fa89a64b12e7faf6d0dab1c3fa0177517e39fd1adaeaee3f2214fe"
},
"geometry/two_containers_reversed_children-before.png": {
"bytes": 14076,
"sha256": "2f3a2d55d30ff206d9daf9b8f84ba0e960332a02275289c66ab2e104da2e890e"
},
"geometry/two_containers_reversed_children-experimental_candidate-metrics.png": {
"bytes": 14500,
"sha256": "8a0744cf04d305a0431468395b3676604e03991e577f9f9d2f45e8497b1ef9ca"
},
"geometry/two_containers_reversed_children-manual_planar_order_witness-metrics.png": {
"bytes": 14531,
"sha256": "60623d358d9cb930c2c2c4995c9286c653e597c411b8879b77ac099fe9b0406b"
},
"geometry/two_containers_reversed_children-reset_only_control-metrics.png": {
"bytes": 14126,
"sha256": "2483bf404953ce9b3da38f2adca10c4e02e34638e2999acae7beecb5fe936a83"
},
"tooling/run-auto-layout-check-20260925.py": {
"bytes": 2771,
"sha256": "d652c4df47f13aefc723bc54362d7b61308b8983c8d39ae7721c012519358ea2"
},
"tooling/run.py": {
"bytes": 13814,
"sha256": "698ed7182726c77fa4a1c85fc6dd08187ed20689941f57e749e3f0bc845f3252"
},
"tooling/auto-layout-format-precommit-20260925.json": {
"bytes": 364,
"sha256": "29c93825b415e04204d3fe23a85eef0798560bbb5a1a169689326602f65a9f35"
}
},
"archive": {
"name": "probe-e7deea70.zip",
"bytes": 900995,
"sha256": "c52f82d4782cd6a7f8cbe67498c8b2371e99204a65976aa8bcbbd0a86f835069"
}
}

Some files were not shown because too many files have changed in this diff Show More