From 695685a2b141ef0dd260c2df2a28913657239e21 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 20:52:51 -0700 Subject: [PATCH] Integrate typed Scenario activations before saved geometry resolution --- crates/oa-desktop/src/behavior.rs | 297 +++--- .../src/behavior/scenario_activation.rs | 492 ++++++++++ .../src/behavior/scenario_activation/tests.rs | 842 ++++++++++++++++++ .../src/behavior/scenario_lifecycle/render.rs | 15 + .../scenario_lifecycle/render_tests.rs | 22 +- .../scenario_lifecycle/union_tests.rs | 489 ++++++++++ 6 files changed, 1958 insertions(+), 199 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/scenario_activation.rs create mode 100644 crates/oa-desktop/src/behavior/scenario_activation/tests.rs create mode 100644 crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index f929457b..48cfb57d 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -20,6 +20,7 @@ mod contextual_source_tests; pub(crate) mod creation; mod form_session; pub(crate) mod scenario; +mod scenario_activation; mod scenario_lifecycle; pub(crate) mod source_draft; pub(crate) mod state_canvas; @@ -1179,9 +1180,10 @@ struct Scene { lanes: BTreeMap, slots: Vec, rows: BTreeMap, + activations: Vec, + lifecycle: scenario_lifecycle::render::Rendering, open_calls: Vec, sequence_bounds: BTreeMap, - lifecycle: scenario_lifecycle::render::Rendering, } struct SequenceSlot { slot: scenario::Slot, @@ -1238,9 +1240,10 @@ impl Scene { lanes: BTreeMap::new(), slots: vec![], rows: BTreeMap::new(), + activations: vec![], + lifecycle: scenario_lifecycle::render::Rendering::default(), open_calls: vec![], sequence_bounds: BTreeMap::new(), - lifecycle: scenario_lifecycle::render::Rendering::default(), }; let mut lanes = p .children(owner) @@ -1338,29 +1341,7 @@ impl Scene { scene.size.height, ); scene.lifecycle.align_messages(&mut scene.lines); - let calls: Vec<_> = scene - .rows - .keys() - .filter_map(|&id| { - if let Some(SharedBody::Occurrence { - body: - OccurrenceBody::Exchange { - message_kind: MessageKind::Call, - receiver: Some(receiver), - .. - }, - .. - }) = p.elements[&id].shared() - { - Some((id, *lane_x.get(receiver)?)) - } else { - None - } - }) - .collect(); - for (call, x) in calls { - scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None); - } + scene.layout_activations(p, &lane_x); } for (id, name, detail) in lanes { let x = lane_x[&id]; @@ -1427,14 +1408,16 @@ impl Scene { }; if scenario && matches!(body, OccurrenceBody::Exchange { - sender: Some(sender), receiver: Some(receiver), - message_kind: MessageKind::Create | MessageKind::Delete, .. - } if sender == receiver) + sender: Some(sender), receiver: Some(receiver), + message_kind: MessageKind::Create | MessageKind::Delete, .. + } if sender == receiver) { - // Self lifecycle elbows and their caption need clearance from - // the preceding action/state/reference box, before recording rows. + // Record the lifecycle receive row only after making room for + // the self-message elbow and caption above its marker. *y += 16.; } + #[cfg(test)] + scenario_lifecycle::render::apply_test_row_offset(*id, y); if ordered { let slot = scenario::Slot { parent: owner, @@ -1654,148 +1637,6 @@ impl Scene { }); } } - fn activation_segment( - &mut self, - call: Id, - scope: Id, - receiver: Id, - x: f32, - top: f32, - bottom: f32, - ) { - for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) { - self.boxes.push(BoxItem { - id: call, - rect: Rectangle::new( - Point::new(x - 4., span.start), - Size::new(8., span.end - span.start), - ), - title: String::new(), - detail: String::new(), - marker: None, - }); - } - } - /// Follow a single correlation through the ordered tree. Branches are drawn - /// as independent segments, never as one chronological stretch through ALT - /// operands. The result says whether a path can leave this sequence open. - fn activation_sequence( - &mut self, - p: &Project, - owner: Id, - call: Id, - x: f32, - mut start: Option, - ) -> bool { - let Some(SharedBody::Occurrence { - body: - OccurrenceBody::Exchange { - receiver: Some(receiver), - .. - }, - .. - }) = p.elements[&call].shared() - else { - return false; - }; - let receiver = *receiver; - let entered_open = start.is_some(); - let mut split = false; - for id in p.elements[&owner].steps().into_iter().flatten() { - let Some(&(_, y)) = self.rows.get(id) else { - continue; - }; - if *id == call { - start = Some(y); - } - let Some(top) = start else { - continue; - }; - match p.elements[id].shared() { - Some(SharedBody::Occurrence { - body: - OccurrenceBody::Exchange { - message_kind: MessageKind::Delete, - receiver: Some(deleted), - .. - }, - .. - }) if *deleted == receiver => { - // This path cannot show activation beyond its local death. - // Keep the unresolved-call cue; a Delete is not a Reply. - self.activation_segment(call, owner, receiver, x, top, y); - self.open_calls.push(Point::new(x + 12., y - 16.)); - start = None; - } - Some(SharedBody::Occurrence { - body: - OccurrenceBody::Exchange { - message_kind: MessageKind::Reply, - reply_to: Some(correlation), - .. - }, - .. - }) if *correlation == call => { - self.activation_segment(call, owner, receiver, x, top, y); - for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) { - self.boxes.push(BoxItem { - id: call, - rect: Rectangle::new( - Point::new(x - 7., span.start), - Size::new(14., span.end - span.start), - ), - title: String::new(), - detail: String::new(), - marker: None, - }); - } - start = None; - } - Some(SharedBody::Occurrence { - body: OccurrenceBody::Fragment { operator, branches }, - .. - }) => { - split = true; - self.activation_segment(call, owner, receiver, x, top, y); - let bottom = self - .boxes - .iter() - .find(|b| b.id == *id && !b.title.is_empty()) - .map(|b| b.rect.y + b.rect.height) - .unwrap_or(y); - let outcomes: Vec<_> = branches - .iter() - .filter_map(|branch| { - let &(top, _) = self.sequence_bounds.get(branch)?; - Some(self.activation_sequence(p, *branch, call, x, Some(top))) - }) - .collect(); - let open = match operator { - FragmentOperator::Alternative => { - outcomes.is_empty() || outcomes.iter().any(|open| *open) - } - FragmentOperator::Parallel => outcomes.iter().all(|open| *open), - // Optional/repeated bodies do not guarantee execution. - FragmentOperator::Optional | FragmentOperator::Loop => true, - }; - start = open.then_some(bottom); - } - _ => {} - } - } - if let Some(top) = start { - let bottom = if entered_open || split { - self.sequence_bounds - .get(&owner) - .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) - } else { - top + 26. - }; - self.activation_segment(call, owner, receiver, x, top, bottom); - self.open_calls.push(Point::new(x + 8., bottom - 7.)); - } - start.is_some() - } fn slot_at(&self, point: Point, parent: Option) -> Option<&SequenceSlot> { self.slots .iter() @@ -1842,17 +1683,40 @@ impl Scene { .or_else(|| { self.lines .iter() - .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) + .find(|line| { + !line.label.is_empty() + && line.points.windows(2).any(|p| { + crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5 + }) + }) .map(|line| line.id) }) - .or_else(|| self.near_sequence_item(point)) .or_else(|| { - self.boxes + self.activations .iter() .rev() - .find(|b| b.rect.contains(point) && b.title.is_empty()) - .map(|b| b.id) + .find(|b| b.rect.contains(point)) + .map(|b| b.call) }) + .or_else(|| { + self.lines + .iter() + .find(|line| { + !line.label.is_empty() + && Rectangle::new( + line.label_at, + Size::new( + (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 25., + ), + ) + .contains(point) + }) + .map(|line| line.id) + }) + // The generous message tolerance must not steal the exposed + // shoulder of another call's execution when the view is zoomed out. + .or_else(|| self.near_sequence_item(point)) .or_else(|| self.lifecycle.lane_at(point, self.scale)) .or_else(|| self.lane_at(point)) .or_else(|| { @@ -1885,9 +1749,8 @@ impl Scene { .fold(f32::INFINITY, f32::min); (line.id, distance) }); - // One distance comparison for both kinds of screen-sized hit tolerance. - // Dense minimum-Fit rows must not give the first message or last marker - // a larger hit priority than the visibly nearer item. UUID breaks ties. + // Compare broad message and lifecycle-marker tolerance together after + // exact shapes, so dense minimum-Fit rows choose the nearer item. self.lifecycle .nearest_marker(point) .into_iter() @@ -2158,6 +2021,55 @@ impl canvas::Program for Scene { f.translate(self.camera_pan); f.scale(self.scale); self.lifecycle.draw_lanes(&mut f, self.selected); + // Guides belong behind execution fills and cards. In particular, a + // lifeline must not cut through the middle of its activation rectangle. + for guide in self.lines.iter().filter(|line| line.label.is_empty()) { + f.stroke( + &Path::new(|path| { + path.move_to(guide.points[0]); + for point in &guide.points[1..] { + path.line_to(*point); + } + }), + Stroke::default() + .with_color(if guide.id == self.selected { + style::TEAL + } else { + style::MUTED + }) + .with_width(if guide.id == self.selected { 2.5 } else { 1.2 }), + ); + } + // Label backplates clear guides, but must not erase execution borders + // or message geometry. Their text remains in the foreground below. + for line in self.lines.iter().filter(|line| !line.label.is_empty()) { + f.fill_rectangle( + line.label_at, + Size::new( + (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 22., + ), + Color::WHITE, + ); + } + for activation in &self.activations { + let path = Path::rectangle(activation.rect.position(), activation.rect.size()); + f.fill(&path, Color::from_rgb8(243, 248, 248)); + f.stroke( + &path, + Stroke::default() + .with_color(if activation.call == self.selected { + style::TEAL + } else { + style::LINE + }) + .with_width(if activation.call == self.selected { + 2.5 + } else { + 1. + }), + ); + } for b in &self.boxes { let outline = matches!( b.marker, @@ -2184,7 +2096,7 @@ impl canvas::Program for Scene { ); } } - for l in &self.lines { + for l in self.lines.iter().filter(|line| !line.label.is_empty()) { let color = if l.id == self.selected { style::TEAL } else { @@ -2221,11 +2133,6 @@ impl canvas::Program for Scene { }), Stroke::default().with_color(color), ); - f.fill_rectangle( - l.label_at, - Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), - Color::WHITE, - ); f.fill_text(Text { content: short(&l.label, 32), position: l.label_at, @@ -2237,8 +2144,8 @@ impl canvas::Program for Scene { } for b in &self.boxes { if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) { - // Clipped child frames start with an identity transform. Match - // the card's pan and scale, as for lifecycle Create labels. + // WGPU child clips start with an identity transform, as in + // the lifecycle capsule label painter. f.with_clip(clip, |label| { label.translate(self.camera_pan); label.scale(self.scale); @@ -3025,9 +2932,9 @@ mod direct_canvas_tests { p = execute_batch(&p, &commands).unwrap(); let scene = Scene::sequence(&p, f.owner, f.call); let bars: Vec<_> = scene - .boxes + .activations .iter() - .filter(|b| b.id == f.call && b.rect.width == 8.) + .filter(|b| b.call == f.call) .map(|b| b.rect) .collect(); for reply in &replies { @@ -3065,11 +2972,7 @@ mod direct_canvas_tests { #[test] fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() { let (f, scene) = scene(); - let bar = scene - .boxes - .iter() - .find(|b| b.id == f.call && b.rect.width == 8.) - .unwrap(); + let bar = scene.activations.iter().find(|b| b.call == f.call).unwrap(); assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1); assert!(scene.open_calls.is_empty()); let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); @@ -3077,9 +2980,9 @@ mod direct_canvas_tests { assert_eq!(scene.open_calls.len(), 1); assert_eq!( scene - .boxes + .activations .iter() - .find(|b| b.id == f.call && b.rect.width == 8.) + .find(|b| b.call == f.call) .unwrap() .rect .height, diff --git a/crates/oa-desktop/src/behavior/scenario_activation.rs b/crates/oa-desktop/src/behavior/scenario_activation.rs new file mode 100644 index 00000000..88fa4d6e --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_activation.rs @@ -0,0 +1,492 @@ +//! Derived call geometry. Correlation and branch ownership come from the model; +//! columns separate overlapping drawings and do not assert an execution thread. +use super::*; + +const WIDTH: f32 = 12.; +const OFFSET: f32 = 6.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Finish { + Reply(Id), + Boundary, + /// Still open at this scope boundary; the visible span is not an isolated stub. + Continuation, + Open, + /// The receiver was deleted without answering this call. + Terminated(Id), + /// Lifetime clipping ended this visible piece before its logical terminal. + Clipped, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Segment { + pub call: Id, + pub receiver: Id, + pub scope: Id, + pub rect: Rectangle, + pub column: usize, + pub finish: Finish, +} +impl Segment { + fn bottom(&self) -> f32 { + self.rect.y + self.rect.height + } + fn overlaps(&self, other: &Self) -> bool { + self.receiver == other.receiver + && self.scope == other.scope + && self.rect.y < other.bottom() + && other.rect.y < self.bottom() + } + fn edge(&self, right: bool) -> f32 { + self.rect.x + if right { self.rect.width } else { 0. } + } +} + +impl Scene { + pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap) { + // Logical endpoints precede clipping and remain available even when a + // lifetime hides a whole execution or terminates it before its Reply. + let correlations = logical_correlations(p, &self.rows, &self.lines); + let mut calls: Vec<_> = self + .rows + .iter() + .filter_map(|(&call, &(scope, y))| { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + receiver: Some(receiver), + .. + }, + .. + }) = p.elements.get(&call).and_then(|e| e.shared()) + else { + return None; + }; + Some((call, *receiver, scope, y, *lanes.get(receiver)?)) + }) + .collect(); + // Rows are laid out in semantic order. UUID order must not choose which + // rectangle obscures another (especially after source reidentification). + calls.sort_by(|a, b| a.3.total_cmp(&b.3).then_with(|| a.0.cmp(&b.0))); + for &(call, receiver, scope, _, x) in &calls { + let first = self.activations.len(); + let first_cue = self.open_calls.len(); + self.activation_sequence(p, scope, call, receiver, x, None); + let column = self.activations[first..] + .iter() + .flat_map(|segment| { + self.activations[..first] + .iter() + .filter(move |other| segment.overlaps(other)) + }) + .map(|other| other.column + 1) + .max() + .unwrap_or(0); + for segment in &mut self.activations[first..] { + segment.column = column; + segment.rect.x += column as f32 * OFFSET; + } + for cue in &mut self.open_calls[first_cue..] { + cue.x += column as f32 * OFFSET; + } + } + // Shallow executions paint first; columns cannot collide within a scope. + self.activations.sort_by(|a, b| { + a.column + .cmp(&b.column) + .then_with(|| a.rect.y.total_cmp(&b.rect.y)) + .then_with(|| a.call.cmp(&b.call)) + }); + self.anchor_activation_messages(p, lanes, &correlations); + } + + fn activation_segment( + &mut self, + call: Id, + receiver: Id, + scope: Id, + x: f32, + span: std::ops::Range, + finish: Finish, + ) { + // Cues describe unanswered logical paths, including wholly invisible + // ones. Never derive them only from the surviving paint rectangles. + if matches!( + finish, + Finish::Open | Finish::Continuation | Finish::Terminated(_) + ) { + self.open_calls + .push(Point::new(x + WIDTH / 2. + 4., span.end - 7.)); + } + for alive in self + .lifecycle + .alive_spans(receiver, scope, span.start, span.end) + { + self.activations.push(Segment { + call, + receiver, + scope, + rect: Rectangle::new( + Point::new(x - WIDTH / 2., alive.start), + Size::new(WIDTH, alive.end - alive.start), + ), + column: 0, + finish: if alive.end == span.end { + finish + } else { + Finish::Clipped + }, + }); + } + } + + /// Follow one exact correlation. A segment belongs to one ordered scope; + /// vertically stacked operands must not become a single chronological span. + fn activation_sequence( + &mut self, + p: &Project, + owner: Id, + call: Id, + receiver: Id, + x: f32, + mut start: Option, + ) -> Outcome { + let entered_open = start.is_some(); + let mut split = false; + let mut may_be_terminated = false; + for id in p.elements[&owner].steps().into_iter().flatten() { + let Some(&(_, y)) = self.rows.get(id) else { + continue; + }; + if *id == call { + // A reflective call is received at the lower end of its loop. + start = Some( + self.lines + .iter() + .find(|line| line.id == call) + .and_then(|line| line.points.last()) + .map_or(y, |point| point.y), + ); + } + let Some(top) = start else { + continue; + }; + match p.elements[id].shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Delete, + receiver: Some(deleted), + .. + }, + .. + }) if *deleted == receiver => { + self.activation_segment( + call, + receiver, + owner, + x, + top..y, + Finish::Terminated(*id), + ); + start = None; + may_be_terminated = true; + } + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(correlation), + .. + }, + .. + }) if *correlation == call => { + // Execution ends when its reply is sent, including a self reply. + self.activation_segment(call, receiver, owner, x, top..y, Finish::Reply(*id)); + start = None; + } + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) => { + split = true; + self.activation_segment(call, receiver, owner, x, top..y, Finish::Boundary); + let bottom = self + .boxes + .iter() + .find(|b| b.id == *id && !b.title.is_empty()) + .map(|b| b.rect.y + b.rect.height) + .unwrap_or(y); + let outcomes: Vec<_> = branches + .iter() + .filter_map(|branch| { + let &(top, _) = self.sequence_bounds.get(branch)?; + Some(self.activation_sequence(p, *branch, call, receiver, x, Some(top))) + }) + .collect(); + let open = match operator { + FragmentOperator::Alternative => { + outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open) + } + FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open), + FragmentOperator::Optional | FragmentOperator::Loop => true, + }; + if outcomes.iter().any(|outcome| outcome.may_be_terminated) { + // Sibling bars retain their own entry paths, but a + // conditional death cannot become a definite parent + // execution again after a later Create. + self.open_calls + .push(Point::new(x + WIDTH / 2. + 4., bottom - 7.)); + may_be_terminated = true; + start = None; + } else { + start = open.then_some(bottom); + } + } + _ => {} + } + } + if let Some(top) = start { + let bottom = if entered_open || split { + self.sequence_bounds + .get(&owner) + .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) + } else { + top + 26. + }; + let finish = if entered_open || split { + Finish::Continuation + } else { + Finish::Open + }; + self.activation_segment(call, receiver, owner, x, top..bottom, finish); + } + Outcome { + open: start.is_some(), + may_be_terminated, + } + } + + fn anchor_activation_messages( + &mut self, + p: &Project, + lanes: &BTreeMap, + correlations: &BTreeMap, + ) { + let segments = &self.activations; + for line in &mut self.lines { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + sender: Some(sender), + receiver: Some(receiver), + message_kind, + reply_to, + .. + }, + .. + }) = p.elements.get(&line.id).and_then(|e| e.shared()) + else { + continue; + }; + if !matches!( + message_kind, + MessageKind::Call | MessageKind::Reply | MessageKind::Asynchronous + ) { + continue; + } + let (Some(&from), Some(&to), Some(&(scope, _))) = ( + lanes.get(sender), + lanes.get(receiver), + self.rows.get(&line.id), + ) else { + continue; + }; + let send_y = line.points[0].y; + let receive_y = line.points.last().unwrap().y; + let reflective = sender == receiver; + let source = if *message_kind == MessageKind::Reply { + reply_to.and_then(|call| { + segments.iter().find(|a| { + a.call == call + && a.scope == scope + && a.finish == Finish::Reply(line.id) + && a.bottom() == send_y + }) + }) + } else { + active_segment( + p, + segments, + correlations, + *sender, + scope, + send_y, + Some(line.id), + ) + }; + let target = if *message_kind == MessageKind::Call { + segments + .iter() + .find(|a| a.call == line.id && a.scope == scope && a.rect.y == receive_y) + } else { + active_segment( + p, + segments, + correlations, + *receiver, + scope, + receive_y, + *reply_to, + ) + }; + let start = source.map_or(from, |a| a.edge(reflective || to > from)); + let end = target.map_or(to, |a| a.edge(reflective || from > to)); + if reflective { + let outside = start.max(end) + 60.; + line.points = vec![ + Point::new(start, send_y), + Point::new(outside, send_y), + Point::new(outside, receive_y), + Point::new(end, receive_y), + ]; + } else { + line.points = vec![Point::new(start, send_y), Point::new(end, receive_y)]; + } + line.label_at.x = start.min(end) + 12.; + } + } +} + +struct Outcome { + open: bool, + may_be_terminated: bool, +} + +fn logical_correlations( + p: &Project, + rows: &BTreeMap, + lines: &[Line], +) -> BTreeMap { + let mut result = BTreeMap::new(); + let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect(); + for (&id, &(_, y)) in rows { + if matches!( + p.elements.get(&id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + .. + }, + .. + }) + ) { + result.insert( + id, + Correlation { + start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y), + replies: vec![], + }, + ); + } + } + for (&id, &(scope, y)) in rows { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(call), + .. + }, + .. + }) = p.elements.get(&id).and_then(|e| e.shared()) + && let Some(correlation) = result.get_mut(call) + { + let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y); + correlation.replies.push((scope, sent)); + } + } + result +} + +/// A caller has no implicit execution just because it sent a message. Only an +/// actually drawn, known-duration execution can supply its anchor. Crossed +/// intervals provide no unambiguous innermost execution, so fall back to the lane. +fn active_segment<'a>( + p: &Project, + segments: &'a [Segment], + correlations: &BTreeMap, + receiver: Id, + scope: Id, + y: f32, + exclude: Option, +) -> Option<&'a Segment> { + let candidates: Vec<_> = segments + .iter() + .filter(|a| { + a.receiver == receiver + && a.scope == scope + && Some(a.call) != exclude + && a.rect.y <= y + && y < a.bottom() + && a.finish != Finish::Open + }) + .collect(); + let inner = candidates.iter().max_by_key(|a| a.column).copied()?; + candidates + .iter() + .all(|a| { + a.call == inner.call + || correlations[&a.call].contains(p, scope, &correlations[&inner.call]) + }) + .then_some(inner) +} + +struct Correlation { + start: f32, + replies: Vec<(Id, f32)>, +} +impl Correlation { + fn contains(&self, p: &Project, scope: Id, inner: &Self) -> bool { + // Segment clipping at a fragment can make crossed calls appear nested. + // Compare actual correlated replies on compatible scope paths instead. + let relevant = |&(reply_scope, _): &(Id, f32)| { + ancestor(p, scope, reply_scope) || ancestor(p, reply_scope, scope) + }; + let mut compared = false; + for &(inner_scope, inner_end) in inner.replies.iter().filter(|reply| relevant(reply)) { + let mut matched = false; + for &(outer_scope, outer_end) in self.replies.iter().filter(|reply| relevant(reply)) { + if ancestor(p, inner_scope, outer_scope) || ancestor(p, outer_scope, inner_scope) { + // Alternative operands are not chronological. Compare only + // returns that can belong to the same enclosing branch path. + if outer_end < inner_end { + return false; + } + matched = true; + } + } + if !matched { + return false; + } + compared = true; + } + self.start <= inner.start && compared + } +} +fn ancestor(p: &Project, expected: Id, mut scope: Id) -> bool { + for _ in 0..=p.elements.len() { + if scope == expected { + return true; + } + let Some(parent) = p.elements.get(&scope).and_then(|e| e.parent) else { + return false; + }; + scope = parent; + } + false +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/scenario_activation/tests.rs b/crates/oa-desktop/src/behavior/scenario_activation/tests.rs new file mode 100644 index 00000000..f4ed75c3 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_activation/tests.rs @@ -0,0 +1,842 @@ +use super::*; + +fn id(n: u128) -> Id { + Id::from_u128(0x21ca1100000040008000000000000000 + n) +} +struct Fixture { + project: Project, + owner: Id, + caller: Id, + callee: Id, + call: Id, + inner: Id, + reply: Id, + inner_reply: Id, +} +fn seed(p: &mut Project, layer: Layer, n: u128, kind: Kind, parent: Option) -> Id { + let mut e = p + .make_element(kind, layer, &format!("Element {n}"), parent) + .unwrap(); + e.id = id(n); + e.alias = format!("element_{n}"); + p.elements.insert(e.id, e); + id(n) +} +fn message( + p: &mut Project, + layer: Layer, + n: u128, + parent: Id, + endpoints: (Id, Id), + reply: Option, +) -> Id { + let e = seed(p, layer, n, Kind::Occurrence, Some(parent)); + *p.elements.get_mut(&e).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: None, + exchange: None, + sender: Some(endpoints.0), + receiver: Some(endpoints.1), + message_kind: if reply.is_some() { + MessageKind::Reply + } else { + MessageKind::Call + }, + reply_to: reply, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + e +} +fn fixture(layer: Layer, reverse_uuid_order: bool) -> Fixture { + let mut p = Project::empty(); + let work = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let a = seed(&mut p, layer, 1, work, None); + let b = seed(&mut p, layer, 2, work, None); + let owner = seed(&mut p, layer, 3, Kind::Scenario, None); + if let SharedBody::Scenario { viewpoint, .. } = + p.elements.get_mut(&owner).unwrap().shared_mut().unwrap() + { + *viewpoint = if layer == Layer::Oa { + ScenarioViewpoint::OperationalActivity + } else { + ScenarioViewpoint::Functional + }; + } + let caller = seed(&mut p, layer, 4, Kind::Lifeline, Some(owner)); + let callee = seed(&mut p, layer, 5, Kind::Lifeline, Some(owner)); + for (line, represented) in [(caller, a), (callee, b)] { + *p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + } + let (first, second) = if reverse_uuid_order { + (40, 30) + } else { + (30, 40) + }; + let call = message(&mut p, layer, first, owner, (caller, callee), None); + let inner = message(&mut p, layer, second, owner, (caller, callee), None); + let reply = message(&mut p, layer, 50, owner, (callee, caller), Some(call)); + let inner_reply = message(&mut p, layer, 60, owner, (callee, caller), Some(inner)); + *p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = + vec![call, inner, inner_reply, reply]; + Fixture { + project: p, + owner, + caller, + callee, + call, + inner, + reply, + inner_reply, + } +} +fn scene(f: &Fixture) -> Scene { + let errors: Vec<_> = validation::validate(&f.project) + .into_iter() + .filter(|i| i.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "Actual core fixture: {errors:#?}"); + let source = language::format(&f.project); + let reparsed = language::parse(&source).unwrap(); + assert_eq!(language::format(&reparsed), source); + let result = Scene::sequence(&reparsed, f.owner, f.owner); + assert_eq!( + language::format(&reparsed), + source, + "Projection must not write geometry into the model" + ); + result +} +fn bar(scene: &Scene, call: Id) -> &Segment { + scene.activations.iter().find(|a| a.call == call).unwrap() +} + +fn long_labels(f: &mut Fixture) { + for occurrence in [f.call, f.inner, f.inner_reply, f.reply] { + f.project.elements.get_mut(&occurrence).unwrap().name = + "Pressure verification with a descriptive operation label".into(); + } +} + +#[test] +fn activation_shoulders_under_label_backplates_select_their_call() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer, true); + long_labels(&mut f); + let scene = scene(&f); + let outer = bar(&scene, f.call); + for occurrence in [f.inner, f.inner_reply, f.reply] { + let message = line(&scene, occurrence); + let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.); + assert!(outer.rect.contains(shoulder)); + assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder)); + assert!(message.label.chars().count() > 32); + assert!(message.points.windows(2).all(|p| { + crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale + })); + assert_eq!(scene.hit(shoulder), Some(f.call)); + assert_eq!( + scene.hit(message.label_at + iced::Vector::new(4., 4.)), + Some(occurrence), + "Label text outside activations remains selectable" + ); + } + for occurrence in [f.call, f.inner, f.inner_reply, f.reply] { + let message = line(&scene, occurrence); + for endpoint in [message.points[0], *message.points.last().unwrap()] { + assert_eq!(scene.hit(endpoint), Some(occurrence)); + } + } + } +} + +#[tokio::test] +async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() { + use iced::advanced::renderer::Headless; + use iced_runtime::user_interface::{Cache, UserInterface}; + + let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await; + let mut f = fixture(Layer::Oa, true); + long_labels(&mut f); + let mut scene = scene(&f); + scene.selected = f.call; + let border_x = bar(&scene, f.call).rect.x; + let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply] + .into_iter() + .map(|id| { + let message = line(&scene, id); + assert!( + Rectangle::new(message.label_at, Size::new(225., 22.)) + .contains(Point::new(border_x, message.label_at.y + 11.)) + ); + (message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize + }) + .collect(); + let dimensions = Size::new(800., 700.); + let content: Element<'_, Message> = canvas::Canvas::new(scene) + .width(dimensions.width) + .height(dimensions.height) + .into(); + let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer); + ui.draw( + &mut mounted.renderer, + &style::theme(), + &iced::advanced::renderer::Style { + text_color: style::INK, + }, + mouse::Cursor::Unavailable, + ); + let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE); + let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.); + for span in label_spans { + for y in span { + assert!( + ((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| { + let offset = (y * 800 + x) * 4; + pixels[offset..offset + 3] + .iter() + .zip(expected) + .all(|(actual, target)| (*actual as f32 - target).abs() < 30.) + }), + "Selected activation border was erased at y={y}" + ); + } + } +} +fn line(scene: &Scene, occurrence: Id) -> &super::super::Line { + scene.lines.iter().find(|a| a.id == occurrence).unwrap() +} +fn endpoints(p: &mut Project, occurrence: Id, from: Id, to: Id) { + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, receiver, .. + }, + .. + } = p + .elements + .get_mut(&occurrence) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = Some(from); + *receiver = Some(to); + } +} + +#[test] +fn nested_calls_expose_both_bars_and_end_at_their_exact_replies_in_both_directions() { + for layer in [Layer::Oa, Layer::Sa] { + for reverse in [false, true] { + let mut f = fixture(layer, true); + if reverse { + for occurrence in [f.call, f.inner] { + endpoints(&mut f.project, occurrence, f.callee, f.caller); + } + for occurrence in [f.reply, f.inner_reply] { + endpoints(&mut f.project, occurrence, f.caller, f.callee); + } + } + let scene = scene(&f); + let outer = bar(&scene, f.call); + let inner = bar(&scene, f.inner); + assert!(outer.rect.y < inner.rect.y && outer.bottom() > inner.bottom()); + assert_eq!(outer.column, 0); + assert_eq!(inner.column, 1); + assert!(outer.rect.x < inner.rect.x && outer.edge(true) < inner.edge(true)); + for (call, reply) in [(f.call, f.reply), (f.inner, f.inner_reply)] { + let execution = bar(&scene, call); + let received = *line(&scene, call).points.last().unwrap(); + let returned = line(&scene, reply).points[0]; + assert_eq!(execution.rect.y, received.y); + assert_eq!(execution.bottom(), returned.y); + assert_eq!(received.x, execution.edge(reverse)); + assert_eq!(returned.x, execution.edge(reverse)); + assert_eq!(execution.finish, Finish::Reply(reply)); + } + assert!(scene.open_calls.is_empty()); + } + } +} + +#[test] +fn call_uuid_order_does_not_choose_activation_columns_or_message_anchors() { + let f = fixture(Layer::Oa, false); + let g = fixture(Layer::Oa, true); + let a = scene(&f); + let b = scene(&g); + for (x, y) in [ + (f.call, g.call), + (f.inner, g.inner), + (f.reply, g.reply), + (f.inner_reply, g.inner_reply), + ] { + assert_eq!(line(&a, x).points, line(&b, y).points); + } + for (x, y) in [(f.call, g.call), (f.inner, g.inner)] { + assert_eq!(bar(&a, x).rect, bar(&b, y).rect); + } +} + +#[test] +fn sequential_calls_reuse_column_and_crossed_replies_keep_their_correlations() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer, true); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, f.reply, f.inner, f.inner_reply]; + let sequential = scene(&f); + assert_eq!( + bar(&sequential, f.call).rect.x, + bar(&sequential, f.inner).rect.x + ); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, f.inner, f.reply, f.inner_reply]; + let crossed = scene(&f); + assert!(bar(&crossed, f.call).bottom() < bar(&crossed, f.inner).bottom()); + assert!(bar(&crossed, f.call).rect.x < bar(&crossed, f.inner).rect.x); + assert_eq!( + line(&crossed, f.reply).points[0].x, + bar(&crossed, f.call).edge(false) + ); + assert_eq!( + line(&crossed, f.inner_reply).points[0].x, + bar(&crossed, f.inner).edge(false) + ); + } +} + +#[test] +fn self_call_receives_at_lower_loop_end_and_self_reply_returns_to_the_parent() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer, true); + endpoints(&mut f.project, f.inner, f.callee, f.callee); + endpoints(&mut f.project, f.inner_reply, f.callee, f.callee); + let scene = scene(&f); + let parent = bar(&scene, f.call); + let child = bar(&scene, f.inner); + let call = line(&scene, f.inner); + let reply = line(&scene, f.inner_reply); + assert_eq!(call.points[0].x, parent.edge(true)); + assert_eq!(call.points[3], Point::new(child.edge(true), child.rect.y)); + assert_eq!(child.rect.y, scene.rows[&f.inner].1 + 24.); + assert_eq!( + reply.points[0], + Point::new(child.edge(true), child.bottom()) + ); + assert_eq!(reply.points[3].x, parent.edge(true)); + assert!(call.points[1].x > child.edge(true)); + assert!(reply.points[1].x > child.edge(true)); + } +} + +#[test] +fn an_inactive_self_call_has_no_fabricated_parent_and_three_nested_calls_expose_three_columns() { + let mut f = fixture(Layer::Oa, true); + endpoints(&mut f.project, f.inner, f.callee, f.callee); + endpoints(&mut f.project, f.inner_reply, f.callee, f.callee); + f.project.elements.remove(&f.call); + f.project.elements.remove(&f.reply); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.inner, f.inner_reply]; + let isolated = scene(&f); + assert_eq!(isolated.activations.len(), 1); + assert_eq!(bar(&isolated, f.inner).column, 0); + assert_eq!( + line(&isolated, f.inner).points[0].x, + isolated.lanes[&f.callee] + ); + assert_eq!( + line(&isolated, f.inner_reply).points[3].x, + isolated.lanes[&f.callee] + ); + assert_eq!( + bar(&isolated, f.inner).rect.y, + line(&isolated, f.inner).points[3].y + ); + + let mut f = fixture(Layer::Oa, true); + let third = message( + &mut f.project, + Layer::Oa, + 20, + f.owner, + (f.caller, f.callee), + None, + ); + let third_reply = message( + &mut f.project, + Layer::Oa, + 21, + f.owner, + (f.callee, f.caller), + Some(third), + ); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, f.inner, third, third_reply, f.inner_reply, f.reply]; + let scene = scene(&f); + let calls: Vec<_> = scene.activations.iter().map(|a| a.call).collect(); + assert_eq!( + calls, + vec![f.call, f.inner, third], + "Paint order is shallow to deep" + ); + for (column, call) in [f.call, f.inner, third].into_iter().enumerate() { + let execution = bar(&scene, call); + assert_eq!(execution.column, column); + let y = (bar(&scene, third).rect.y + bar(&scene, third).bottom()) / 2.; + assert_eq!( + scene.hit(Point::new(execution.rect.x + OFFSET / 2., y)), + Some(call) + ); + } +} + +#[test] +fn reentrant_call_uses_the_other_receiver_without_inventing_a_caller_execution() { + let mut f = fixture(Layer::Oa, true); + endpoints(&mut f.project, f.inner, f.callee, f.caller); + endpoints(&mut f.project, f.inner_reply, f.caller, f.callee); + let scene = scene(&f); + assert_eq!(bar(&scene, f.call).column, 0); + assert_eq!(bar(&scene, f.inner).column, 0); + assert_eq!(bar(&scene, f.inner).receiver, f.caller); + assert_eq!(line(&scene, f.call).points[0].x, scene.lanes[&f.caller]); + assert_eq!( + line(&scene, f.inner).points[0].x, + bar(&scene, f.call).edge(false) + ); +} + +#[test] +fn actual_canvas_pointer_selects_each_exposed_shoulder_under_zoom_and_pan() { + let f = fixture(Layer::Oa, true); + let mut scene = scene(&f); + let context = scenario::direct_tests::context(f.owner); + scene.interaction = Some((context, scenario::CanvasTool::Select, true)); + let y = (bar(&scene, f.inner).rect.y + bar(&scene, f.inner).bottom()) / 2.; + let outer = Point::new(bar(&scene, f.call).rect.x + OFFSET / 2., y); + let inner = Point::new(bar(&scene, f.inner).edge(true) - OFFSET / 2., y); + for scale in [1., 0.25, 0.05] { + scene.scale = scale; + scene.camera_pan = iced::Vector::new(50., 25.); + let bounds = Rectangle::new(Point::new(30., 40.), Size::new(1000., 700.)); + for (point, call) in [(outer, f.call), (inner, f.inner)] { + assert_eq!(scene.hit(point), Some(call)); + let cursor = bounds.position() + + scene.camera_pan + + iced::Vector::new(point.x * scale, point.y * scale); + let mut state = SequenceInput::default(); + let _ = canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(cursor), + ); + let action = canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(cursor), + ) + .unwrap(); + assert!(matches!(action.into_inner().0, + Some(Message::ScenarioCanvas(scenario::CanvasEvent::Select(_, selected))) if selected == call)); + } + } +} + +#[test] +fn missing_reply_is_a_short_explicit_open_span_and_an_uncorrelated_reply_closes_nothing() { + let mut f = fixture(Layer::Oa, true); + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { reply_to, .. }, + .. + } = f + .project + .elements + .get_mut(&f.reply) + .unwrap() + .shared_mut() + .unwrap() + { + *reply_to = None; + } + let scene = scene(&f); + assert_eq!(bar(&scene, f.call).finish, Finish::Open); + assert_eq!(bar(&scene, f.call).rect.height, 26.); + assert_eq!(scene.open_calls.len(), 1); + assert_eq!( + bar(&scene, f.inner).column, + 0, + "Unknown duration does not prove later nesting" + ); + assert_eq!(bar(&scene, f.inner).finish, Finish::Reply(f.inner_reply)); +} + +#[test] +fn alternative_and_parallel_operand_local_calls_do_not_nest_from_screen_order() { + for layer in [Layer::Oa, Layer::Sa] { + for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] { + let mut f = fixture(layer, true); + let fragment = seed(&mut f.project, layer, 70, Kind::Occurrence, Some(f.owner)); + let branches = [id(71), id(72)]; + *f.project + .elements + .get_mut(&fragment) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator, + branches: branches.to_vec(), + }, + precondition: String::new(), + postcondition: String::new(), + }; + for (index, occurrences) in [vec![f.call, f.reply], vec![f.inner, f.inner_reply]] + .into_iter() + .enumerate() + { + let branch = seed( + &mut f.project, + layer, + 71 + index as u128, + Kind::Occurrence, + Some(fragment), + ); + for occurrence in &occurrences { + f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch); + } + *f.project + .elements + .get_mut(&branch) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: format!("condition_{index}"), + occurrences, + }, + precondition: String::new(), + postcondition: String::new(), + }; + } + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![fragment]; + let scene = scene(&f); + assert_eq!(bar(&scene, f.call).column, 0); + assert_eq!(bar(&scene, f.inner).column, 0); + assert_ne!(bar(&scene, f.call).scope, bar(&scene, f.inner).scope); + assert!(scene.open_calls.is_empty()); + } + } +} + +#[test] +fn inherited_execution_anchors_a_self_call_inside_an_operand_before_returning_after_the_fragment() { + let mut f = fixture(Layer::Oa, true); + endpoints(&mut f.project, f.inner, f.callee, f.callee); + endpoints(&mut f.project, f.inner_reply, f.callee, f.callee); + let fragment = seed( + &mut f.project, + Layer::Oa, + 70, + Kind::Occurrence, + Some(f.owner), + ); + *f.project + .elements + .get_mut(&fragment) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![id(71), id(72)], + }, + precondition: String::new(), + postcondition: String::new(), + }; + for (index, occurrences) in [vec![f.inner, f.inner_reply], vec![]] + .into_iter() + .enumerate() + { + let branch = seed( + &mut f.project, + Layer::Oa, + 71 + index as u128, + Kind::Occurrence, + Some(fragment), + ); + for occurrence in &occurrences { + f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch); + } + *f.project + .elements + .get_mut(&branch) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: format!("condition_{index}"), + occurrences, + }, + precondition: String::new(), + postcondition: String::new(), + }; + } + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, fragment, f.reply]; + let scene = scene(&f); + let parent = scene + .activations + .iter() + .find(|a| a.call == f.call && a.scope == id(71)) + .unwrap(); + assert_eq!(parent.finish, Finish::Continuation); + assert_eq!(bar(&scene, f.inner).column, parent.column + 1); + assert_eq!(line(&scene, f.inner).points[0].x, parent.edge(true)); + assert_eq!(line(&scene, f.inner_reply).points[3].x, parent.edge(true)); + assert!( + scene + .activations + .iter() + .any(|a| a.call == f.call && a.finish == Finish::Reply(f.reply)) + ); +} + +#[test] +fn a_fragment_cannot_make_crossed_calls_an_unambiguous_caller_execution() { + for placement in ["plain", "before_fragment", "in_operand"] { + let mut f = fixture(Layer::Oa, true); + let outgoing = message( + &mut f.project, + Layer::Oa, + 80, + f.owner, + (f.callee, f.caller), + None, + ); + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { message_kind, .. }, + .. + } = f + .project + .elements + .get_mut(&outgoing) + .unwrap() + .shared_mut() + .unwrap() + { + *message_kind = MessageKind::Asynchronous; + } + let mut ordered = vec![f.call, f.inner]; + if placement != "in_operand" { + ordered.push(outgoing); + } + if placement != "plain" { + let fragment = seed( + &mut f.project, + Layer::Oa, + 70, + Kind::Occurrence, + Some(f.owner), + ); + *f.project + .elements + .get_mut(&fragment) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![id(71), id(72)], + }, + precondition: String::new(), + postcondition: String::new(), + }; + for index in 0..2u128 { + let branch = seed( + &mut f.project, + Layer::Oa, + 71 + index, + Kind::Occurrence, + Some(fragment), + ); + let occurrences = if index == 0 && placement == "in_operand" { + f.project.elements.get_mut(&outgoing).unwrap().parent = Some(branch); + vec![outgoing] + } else { + vec![] + }; + *f.project + .elements + .get_mut(&branch) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: format!("condition_{index}"), + occurrences, + }, + precondition: String::new(), + postcondition: String::new(), + }; + } + ordered.push(fragment); + } + ordered.extend([f.reply, f.inner_reply]); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = ordered; + let scene = scene(&f); + assert_eq!( + line(&scene, outgoing).points[0].x, + scene.lanes[&f.callee], + "{placement}" + ); + } +} + +#[test] +fn nested_correlations_returning_in_alternative_operands_remain_nested_before_the_fragment() { + let mut f = fixture(Layer::Oa, true); + let outgoing = message( + &mut f.project, + Layer::Oa, + 80, + f.owner, + (f.callee, f.caller), + None, + ); + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { message_kind, .. }, + .. + } = f + .project + .elements + .get_mut(&outgoing) + .unwrap() + .shared_mut() + .unwrap() + { + *message_kind = MessageKind::Asynchronous; + } + let fragment = seed( + &mut f.project, + Layer::Oa, + 70, + Kind::Occurrence, + Some(f.owner), + ); + *f.project + .elements + .get_mut(&fragment) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![id(71), id(72)], + }, + precondition: String::new(), + postcondition: String::new(), + }; + for index in 0..2u128 { + let branch = seed( + &mut f.project, + Layer::Oa, + 71 + index, + Kind::Occurrence, + Some(fragment), + ); + let occurrences = if index == 0 { + for reply in [f.inner_reply, f.reply] { + f.project.elements.get_mut(&reply).unwrap().parent = Some(branch); + } + vec![f.inner_reply, f.reply] + } else { + vec![ + message( + &mut f.project, + Layer::Oa, + 90, + branch, + (f.callee, f.caller), + Some(f.inner), + ), + message( + &mut f.project, + Layer::Oa, + 91, + branch, + (f.callee, f.caller), + Some(f.call), + ), + ] + }; + *f.project + .elements + .get_mut(&branch) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: format!("condition_{index}"), + occurrences, + }, + precondition: String::new(), + postcondition: String::new(), + }; + } + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, f.inner, outgoing, fragment]; + let scene = scene(&f); + let inner = scene + .activations + .iter() + .find(|a| a.call == f.inner && a.scope == f.owner) + .unwrap(); + assert_eq!(line(&scene, outgoing).points[0].x, inner.edge(false)); +} diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs index a066d967..989fb2d5 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs @@ -8,6 +8,21 @@ use iced::{ use oa_core::{Id, OccurrenceBody, Project, SharedBody}; use std::collections::BTreeMap; +// Test-only geometry input, deliberately upstream of rows, scopes, lifecycle +// and activations. This is not an authored placement or drag policy. +#[cfg(test)] +thread_local! { + static TEST_ROW_OFFSETS: std::cell::RefCell> = Default::default(); +} +#[cfg(test)] +pub(in crate::behavior) fn apply_test_row_offset(id: Id, y: &mut f32) { + TEST_ROW_OFFSETS.with(|offsets| { + if let Some(offset) = offsets.borrow().get(&id) { + *y += offset; + } + }); +} + #[derive(Debug, Clone, PartialEq)] struct EventMarker { event: Marker, diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs index 02673f06..c89ae3ad 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs @@ -3,6 +3,9 @@ use crate::{behavior::Scene, document::Document}; use iced::Size; use oa_core::*; +#[path = "union_tests.rs"] +mod union_tests; + struct Fixture { project: Project, owner: Id, @@ -100,6 +103,21 @@ impl Fixture { }, ) } + fn reply(&mut self, parent: Id, call: Id) -> Id { + self.occurrence( + parent, + "Reply", + OccurrenceBody::Exchange { + exchange: Some(self.exchange), + sender: Some(self.receiver), + receiver: Some(self.sender), + message_kind: MessageKind::Reply, + operation: None, + reply_to: Some(call), + values: vec![], + }, + ) + } fn fragment(&mut self, parent: Id, operator: FragmentOperator) -> Id { self.occurrence( parent, @@ -488,9 +506,9 @@ fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_ let (left_start, left_end) = scene.sequence_bounds[&left]; let (right_start, _) = scene.sequence_bounds[&right]; let bars: Vec<_> = scene - .boxes + .activations .iter() - .filter(|b| b.id == call && b.rect.width == 8.) + .filter(|b| b.call == call && b.receiver == f.receiver) .map(|b| b.rect) .collect(); assert!( diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs new file mode 100644 index 00000000..86fa0cce --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs @@ -0,0 +1,489 @@ +use super::*; +use crate::behavior::scenario_activation::{Finish, Segment}; + +fn line(scene: &Scene, id: Id) -> &crate::behavior::Line { + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn segments(scene: &Scene, call: Id) -> Vec<&Segment> { + scene + .activations + .iter() + .filter(|a| a.call == call) + .collect() +} +fn assert_visible_lifetime(scene: &Scene) { + for a in &scene.activations { + assert_eq!( + scene + .lifecycle + .alive_spans(a.receiver, a.scope, a.rect.y, a.rect.y + a.rect.height), + vec![Span { + start: a.rect.y, + end: a.rect.y + a.rect.height + }] + ); + if let Finish::Reply(reply) = a.finish { + assert_eq!(a.rect.y + a.rect.height, line(scene, reply).points[0].y); + } + } +} + +#[test] +fn admitted_delayed_create_nested_calls_replies_delete_share_one_geometry_pipeline() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let birth = f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + let r2 = f.reply(f.owner, c2); + let r1 = f.reply(f.owner, c1); + let death = f.message(f.owner, MessageKind::Delete); + let scene = f.scene(); + assert_eq!(scene.activations.len(), 2); + for (call, reply, column) in [(c1, r1, 0), (c2, r2, 1)] { + let a = segments(&scene, call)[0]; + assert_eq!( + (a.receiver, a.scope, a.column, a.finish), + (f.receiver, f.owner, column, Finish::Reply(reply)) + ); + let received = *line(&scene, call).points.last().unwrap(); + let sent = line(&scene, reply).points[0]; + assert_eq!((a.rect.y, a.rect.y + a.rect.height), (received.y, sent.y)); + assert_eq!(received.x, sent.x); + assert!( + a.rect.y > scene.rows[&birth].1 && a.rect.y + a.rect.height < scene.rows[&death].1 + ); + } + assert_visible_lifetime(&scene); + assert!(scene.open_calls.is_empty()); + for event in [birth, death] { + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)), + Some(event) + ); + } + } +} + +#[test] +fn admitted_death_and_incomplete_later_reply_do_not_fabricate_an_answered_execution() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let death = f.message(f.owner, MessageKind::Delete); + let reply = f.reply(f.owner, call); + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + } = f + .project + .elements + .get_mut(&reply) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = None; + *exchange = None; + } + let scene = f.scene(); + let a = segments(&scene, call)[0]; + assert_eq!(a.finish, Finish::Terminated(death)); + assert_eq!(a.rect.y + a.rect.height, scene.rows[&death].1); + assert!( + scene.boxes.iter().any(|b| b.id == reply), + "The incomplete reply remains a repairable card" + ); + assert_eq!(scene.open_calls.len(), 1); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn admitted_self_lifecycle_and_self_call_keep_distinct_send_receive_rows() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let action = f.occurrence( + f.owner, + "Sender preparation", + OccurrenceBody::Action { + action: None, + lifeline: Some(f.sender), + }, + ); + let birth = f.message(f.owner, MessageKind::Create); + let call = f.message(f.owner, MessageKind::Call); + let reply = f.reply(f.owner, call); + let death = f.message(f.owner, MessageKind::Delete); + for event in [birth, call, reply, death] { + if let SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + sender, + receiver, + exchange, + .. + }, + .. + } = f + .project + .elements + .get_mut(&event) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = Some(f.receiver); + *receiver = Some(f.receiver); + *exchange = None; + } + } + let scene = f.scene(); + let preparation = scene.boxes.iter().find(|b| b.id == action).unwrap(); + assert!(line(&scene, birth).label_at.y > preparation.rect.y + preparation.rect.height); + for event in [birth, death] { + let message = line(&scene, event); + assert_eq!(message.points[0].y, scene.rows[&event].1 - 24.); + assert_eq!(message.points.last().unwrap().y, scene.rows[&event].1); + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)), + Some(event) + ); + } + let activation = segments(&scene, call)[0]; + assert_eq!(activation.rect.y, scene.rows[&call].1 + 24.); + assert_eq!( + activation.rect.y, + line(&scene, call).points.last().unwrap().y + ); + assert_eq!( + activation.rect.y + activation.rect.height, + line(&scene, reply).points[0].y + ); + assert_eq!(activation.finish, Finish::Reply(reply)); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn admitted_unresolved_operand_keeps_unanswered_cue_without_any_visible_segment() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + f.message(left, MessageKind::Create); + let right = f.branch(fragment); + let call = f.message(right, MessageKind::Call); + let scene = f.scene(); + assert!(segments(&scene, call).is_empty()); + assert!( + intervals(&scene, f.receiver, right) + .iter() + .all(|i| i.2 == Life::Unresolved) + ); + assert_eq!(scene.open_calls.len(), 1); + assert!(scene.open_calls[0].y > scene.rows[&call].1); + assert_eq!( + line(&scene, call).points.last().unwrap().x, + scene.lanes[&f.receiver] + ); + } +} + +#[test] +fn admitted_calls_without_sender_polylines_still_have_logical_correlation_metadata() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + for call in [c1, c2] { + if let SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + } = f + .project + .elements + .get_mut(&call) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = None; + *exchange = None; + } + } + let outgoing = f.occurrence( + f.owner, + "Outgoing", + OccurrenceBody::Exchange { + exchange: None, + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + ); + f.reply(f.owner, c2); + f.reply(f.owner, c1); + let scene = f.scene(); + assert!(scene.boxes.iter().any(|b| b.id == c1)); + assert!(scene.boxes.iter().any(|b| b.id == c2)); + let inner = segments(&scene, c2)[0]; + let expected = if scene.lanes[&f.sender] > scene.lanes[&f.receiver] { + inner.rect.x + inner.rect.width + } else { + inner.rect.x + }; + assert_eq!(line(&scene, outgoing).points[0].x, expected); + } +} + +fn lifetime_rejected(f: &Fixture) { + assert!(validate_candidate(&f.project).is_err()); + assert!( + validation::validate(&f.project) + .iter() + .any(|issue| issue.severity == validation::Severity::Error + && issue.code == "lifeline_lifetime") + ); +} + +#[test] +fn invalid_prebirth_call_and_postdeath_reply_remain_rejected_with_defensive_scene_clipping() { + for layer in [Layer::Oa, Layer::Sa] { + for later_birth in [false, true] { + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let death = f.message(f.owner, MessageKind::Delete); + if later_birth { + f.message(f.owner, MessageKind::Create); + } + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + // Deliberately bypass Document only for defensive renderer coverage + // of rejected data. This is not an admitted editing workflow. + let scene = Scene::sequence(&f.project, f.owner, f.owner); + assert!( + scene + .activations + .iter() + .all(|a| a.rect.y + a.rect.height <= scene.rows[&death].1) + ); + assert!( + scene + .activations + .iter() + .all(|a| a.finish != Finish::Reply(reply)) + ); + assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]); + assert!(!scene.open_calls.is_empty()); + assert_visible_lifetime(&scene); + } + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let birth = f.message(f.owner, MessageKind::Create); + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + assert_eq!(segments(&scene, call)[0].rect.y, scene.rows[&birth].1); + assert_eq!(segments(&scene, call)[0].finish, Finish::Reply(reply)); + assert_eq!( + line(&scene, call).points.last().unwrap().x, + scene.lanes[&f.receiver] + ); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn conditional_death_then_recreate_never_revives_call_but_keeps_open_sibling() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + f.message(f.owner, MessageKind::Create); + let call = f.message(f.owner, MessageKind::Call); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + let death = f.message(left, MessageKind::Delete); + let right = f.branch(fragment); + let rebirth = f.message(f.owner, MessageKind::Create); + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + let bars = segments(&scene, call); + assert!( + bars.iter() + .any(|a| a.scope == left && a.finish == Finish::Terminated(death)) + ); + assert!( + bars.iter().any(|a| a.scope == right + && a.rect.height == 26. + && a.finish == Finish::Continuation) + ); + assert!( + bars.iter() + .all(|a| a.rect.y + a.rect.height < scene.rows[&rebirth].1) + ); + assert!(!bars.iter().any(|a| a.finish == Finish::Reply(reply))); + assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]); + assert_eq!( + scene.open_calls.len(), + 3, + "Terminated, open sibling and uncertain parent cues survive" + ); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn clipped_operand_preserves_original_nested_or_crossed_correlation_distinction() { + for layer in [Layer::Oa, Layer::Sa] { + for nested in [false, true] { + let mut f = Fixture::new(layer); + f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + f.message(left, MessageKind::Delete); + let right = f.branch(fragment); + let outgoing = f.occurrence( + right, + "Outgoing", + OccurrenceBody::Exchange { + exchange: None, + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + ); + let r1 = f.reply(f.owner, if nested { c2 } else { c1 }); + let r2 = f.reply(f.owner, if nested { c1 } else { c2 }); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + let send = line(&scene, outgoing).points[0]; + let expected = if nested { + let inner = segments(&scene, c2) + .into_iter() + .find(|a| a.scope == right) + .unwrap(); + if scene.lanes[&f.sender] > scene.lanes[&f.receiver] { + inner.rect.x + inner.rect.width + } else { + inner.rect.x + } + } else { + scene.lanes[&f.receiver] + }; + assert_eq!(send.x, expected); + for call in [c1, c2] { + assert!(segments(&scene, call).iter().any(|a| a.scope == right + && a.rect.y <= send.y + && send.y < a.rect.y + a.rect.height)); + } + assert!(scene.rows[&r1].1 < scene.rows[&r2].1); + assert_visible_lifetime(&scene); + } + } +} + +fn with_offset(id: Id, offset: f32, f: impl FnOnce() -> T) -> T { + struct Restore(BTreeMap); + impl Drop for Restore { + fn drop(&mut self) { + TEST_ROW_OFFSETS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0)); + } + } + let _restore = + Restore(TEST_ROW_OFFSETS.with(|current| current.replace(BTreeMap::from([(id, offset)])))); + f() +} +fn snapshot(scene: &Scene) -> String { + format!( + "{:?}", + ( + &scene.lifecycle, + &scene.activations, + &scene.open_calls, + &scene.rows, + &scene.sequence_bounds, + scene + .lines + .iter() + .map(|l| (l.id, &l.points, &l.label, l.label_at, l.dashed)) + .collect::>(), + scene + .boxes + .iter() + .map(|b| (b.id, b.rect, &b.title, &b.detail, b.marker)) + .collect::>(), + scene + .slots + .iter() + .map(|s| (s.slot, s.y, &s.label)) + .collect::>(), + scene.size, + ) + ) +} + +#[test] +fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without_authoring() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let birth = f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + f.reply(f.owner, c2); + f.reply(f.owner, c1); + let death = f.message(f.owner, MessageKind::Delete); + let document = f.document(); + let before = document.snapshot(); + let baseline = Scene::sequence(document.project(), f.owner, c1); + let moved = with_offset(birth, 60., || { + Scene::sequence(document.project(), f.owner, c1) + }); + assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.); + assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.); + for (a, b) in baseline.activations.iter().zip(&moved.activations) { + assert_eq!( + (a.call, a.receiver, a.scope, a.column, a.finish), + (b.call, b.receiver, b.scope, b.column, b.finish) + ); + assert_eq!( + b.rect, + Rectangle::new( + a.rect.position() + iced::Vector::new(0., 60.), + a.rect.size() + ) + ); + } + assert_eq!(baseline.activations.len(), moved.activations.len()); + assert_visible_lifetime(&moved); + let reparsed = language::parse(&language::format(document.project())).unwrap(); + assert_eq!(reparsed, *document.project()); + assert_eq!( + snapshot(&moved), + with_offset(birth, 60., || snapshot(&Scene::sequence( + &reparsed, f.owner, c1 + ))) + ); + assert_eq!( + snapshot(&baseline), + snapshot(&Scene::sequence(document.project(), f.owner, c1)) + ); + assert_eq!(document.project(), &before.decoded.project); + assert_eq!(document.bundle(), &before.bundle); + assert_eq!(document.revision(), 0); + assert!(!document.dirty() && !document.can_undo() && !document.can_redo()); + } +}