Files
ARCADIA-Studio/harness/experiments/nested-lifecycle-union-20260912/patches/d61f1f3-to-f6595eb.patch
T

2468 lines
91 KiB
Diff

diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
index ea70799e17a7e23533e4c4a9aa52bd72b713c824..0a0c4df479ff1c46f935735e508144ca2ec3052f 100644
--- a/crates/oa-desktop/src/behavior.rs
+++ b/crates/oa-desktop/src/behavior.rs
@@ -21,8 +21,6 @@ pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
-// Projection acceptance precedes the coordinated scenario renderer integration.
-#[cfg(test)]
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -1127,6 +1125,31 @@ struct BoxItem {
detail: String,
marker: Option<Kind>,
}
+fn box_text_clip(
+ pan: iced::Vector,
+ rect: Rectangle,
+ scale: f32,
+ viewport: Size,
+) -> Option<Rectangle> {
+ if !pan.x.is_finite()
+ || !pan.y.is_finite()
+ || !scale.is_finite()
+ || scale <= 0.
+ || rect.width <= 20.
+ || rect.height <= 16.
+ {
+ return None;
+ }
+ Rectangle::new(
+ Point::new(
+ pan.x + (rect.x + 10.) * scale,
+ pan.y + (rect.y + 8.) * scale,
+ ),
+ Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
+ )
+ .intersection(&Rectangle::new(Point::ORIGIN, viewport))
+}
+
struct Line {
id: Id,
points: Vec<Point>,
@@ -1147,6 +1170,7 @@ struct Scene {
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
+ lifecycle: scenario_lifecycle::render::Rendering,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
}
@@ -1211,6 +1235,7 @@ impl Scene {
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
+ lifecycle: scenario_lifecycle::render::Rendering::default(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
};
@@ -1299,22 +1324,33 @@ impl Scene {
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
+ scene.size.height = (y + 45.).max(210.);
if scenario {
+ scene.lifecycle = scenario_lifecycle::render::Rendering::new(
+ p,
+ owner,
+ &lane_x,
+ &scene.rows,
+ &scene.sequence_bounds,
+ scene.size.height,
+ );
+ scene.lifecycle.align_messages(&mut scene.lines);
scene.layout_activations(p, &lane_x);
}
- scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes {
let x = lane_x[&id];
- scene.lines.insert(
- 0,
- Line {
- id,
- points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
- label: String::new(),
- label_at: Point::ORIGIN,
- dashed: false,
- },
- );
+ if !scenario {
+ scene.lines.insert(
+ 0,
+ Line {
+ id,
+ points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
+ label: String::new(),
+ label_at: Point::ORIGIN,
+ dashed: false,
+ },
+ );
+ }
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
@@ -1360,6 +1396,18 @@ impl Scene {
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue;
};
+ if scenario
+ && matches!(body, OccurrenceBody::Exchange {
+ sender: Some(sender), receiver: Some(receiver),
+ message_kind: MessageKind::Create | MessageKind::Delete, ..
+ } if sender == receiver)
+ {
+ // 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,
@@ -1607,6 +1655,12 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
+ if !point.x.is_finite() || !point.y.is_finite() {
+ return None;
+ }
+ if let Some(id) = self.lifecycle.marker_at(point) {
+ return Some(id);
+ }
self.boxes
.iter()
.rev()
@@ -1652,18 +1706,8 @@ impl Scene {
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
- .or_else(|| {
- self.lines
- .iter()
- .find(|line| {
- !line.label.is_empty()
- && line.points.windows(2).any(|p| {
- crate::diagram::distance_to_segment(point, p[0], p[1])
- < 8. / self.scale
- })
- })
- .map(|line| line.id)
- })
+ .or_else(|| self.near_sequence_item(point))
+ .or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
self.lines
@@ -1678,6 +1722,33 @@ impl Scene {
.map(|line| line.id)
})
}
+
+ fn near_sequence_item(&self, point: Point) -> Option<Id> {
+ if !self.scale.is_finite() || self.scale <= 0. {
+ return None;
+ }
+ let messages = self
+ .lines
+ .iter()
+ .filter(|line| !line.label.is_empty())
+ .map(|line| {
+ let distance = line
+ .points
+ .windows(2)
+ .map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
+ .fold(f32::INFINITY, f32::min);
+ (line.id, distance)
+ });
+ // 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()
+ .chain(messages)
+ .filter(|(_, distance)| *distance <= 8. / self.scale)
+ .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
+ .map(|(id, _)| id)
+ }
}
impl canvas::Program<Message> for Scene {
type State = SequenceInput;
@@ -1962,6 +2033,7 @@ impl canvas::Program<Message> for Scene {
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
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()) {
@@ -2084,24 +2156,32 @@ impl canvas::Program<Message> for Scene {
}
}
for b in &self.boxes {
- f.fill_text(Text {
- 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 {
- style::TEAL
- } else {
- style::INK
- },
- ..Default::default()
- });
- if !b.detail.is_empty() {
- f.fill_text(Text {
- content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
- position: Point::new(b.rect.x + 12., b.rect.y + 39.),
- size: 11.into(),
- color: style::MUTED,
- ..Default::default()
+ if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
+ // 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);
+ label.fill_text(Text {
+ 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 {
+ style::TEAL
+ } else {
+ style::INK
+ },
+ ..Default::default()
+ });
+ if !b.detail.is_empty() {
+ label.fill_text(Text {
+ content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
+ position: Point::new(b.rect.x + 12., b.rect.y + 39.),
+ size: 11.into(),
+ color: style::MUTED,
+ ..Default::default()
+ });
+ }
});
}
}
@@ -2114,6 +2194,8 @@ impl canvas::Program<Message> for Scene {
..Default::default()
});
}
+ self.lifecycle
+ .draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor.position_in(bounds).map(|p| {
diff --git a/crates/oa-desktop/src/behavior/scenario_activation.rs b/crates/oa-desktop/src/behavior/scenario_activation.rs
index fdafed5855b3d52edd04fef2fc7085bf320039b0..88fa4d6e861fc96f4dc5497088cd9119cf25e151 100644
--- a/crates/oa-desktop/src/behavior/scenario_activation.rs
+++ b/crates/oa-desktop/src/behavior/scenario_activation.rs
@@ -12,9 +12,13 @@ pub(super) enum Finish {
/// 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)]
+#[derive(Clone, Debug, PartialEq)]
pub(super) struct Segment {
pub call: Id,
pub receiver: Id,
@@ -40,6 +44,9 @@ impl Segment {
impl Scene {
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
+ // 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()
@@ -64,6 +71,7 @@ impl Scene {
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()
@@ -79,6 +87,9 @@ impl Scene {
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| {
@@ -87,13 +98,7 @@ impl Scene {
.then_with(|| a.rect.y.total_cmp(&b.rect.y))
.then_with(|| a.call.cmp(&b.call))
});
- self.open_calls = self
- .activations
- .iter()
- .filter(|a| matches!(a.finish, Finish::Open | Finish::Continuation))
- .map(|a| Point::new(a.rect.x + a.rect.width + 4., a.bottom() - 7.))
- .collect();
- self.anchor_activation_messages(p, lanes);
+ self.anchor_activation_messages(p, lanes, &correlations);
}
fn activation_segment(
@@ -105,17 +110,33 @@ impl Scene {
span: std::ops::Range<f32>,
finish: Finish,
) {
- if span.end > span.start {
+ // 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., span.start),
- Size::new(WIDTH, span.end - span.start),
+ Point::new(x - WIDTH / 2., alive.start),
+ Size::new(WIDTH, alive.end - alive.start),
),
column: 0,
- finish,
+ finish: if alive.end == span.end {
+ finish
+ } else {
+ Finish::Clipped
+ },
});
}
}
@@ -130,9 +151,10 @@ impl Scene {
receiver: Id,
x: f32,
mut start: Option<f32>,
- ) -> bool {
+ ) -> 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;
@@ -154,6 +176,26 @@ impl Scene {
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),
..
@@ -185,12 +227,22 @@ impl Scene {
.collect();
let open = match operator {
FragmentOperator::Alternative => {
- outcomes.is_empty() || outcomes.iter().any(|open| *open)
+ outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open)
}
- FragmentOperator::Parallel => outcomes.iter().all(|open| *open),
+ FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open),
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
- start = open.then_some(bottom);
+ 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);
+ }
}
_ => {}
}
@@ -210,22 +262,19 @@ impl Scene {
};
self.activation_segment(call, receiver, owner, x, top..bottom, finish);
}
- start.is_some()
+ Outcome {
+ open: start.is_some(),
+ may_be_terminated,
+ }
}
- fn anchor_activation_messages(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
+ fn anchor_activation_messages(
+ &mut self,
+ p: &Project,
+ lanes: &BTreeMap<Id, f32>,
+ correlations: &BTreeMap<Id, Correlation>,
+ ) {
let segments = &self.activations;
- let mut correlations: BTreeMap<Id, Correlation> = BTreeMap::new();
- for segment in segments {
- let correlation = correlations.entry(segment.call).or_insert(Correlation {
- start: segment.rect.y,
- replies: vec![],
- });
- correlation.start = correlation.start.min(segment.rect.y);
- if matches!(segment.finish, Finish::Reply(_)) {
- correlation.replies.push((segment.scope, segment.bottom()));
- }
- }
for line in &mut self.lines {
let Some(SharedBody::Occurrence {
body:
@@ -259,15 +308,18 @@ impl Scene {
let reflective = sender == receiver;
let source = if *message_kind == MessageKind::Reply {
reply_to.and_then(|call| {
- segments
- .iter()
- .find(|a| a.call == call && a.finish == Finish::Reply(line.id))
+ 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,
+ correlations,
*sender,
scope,
send_y,
@@ -282,7 +334,7 @@ impl Scene {
active_segment(
p,
segments,
- &correlations,
+ correlations,
*receiver,
scope,
receive_y,
@@ -307,6 +359,57 @@ impl Scene {
}
}
+struct Outcome {
+ open: bool,
+ may_be_terminated: bool,
+}
+
+fn logical_correlations(
+ p: &Project,
+ rows: &BTreeMap<Id, (Id, f32)>,
+ lines: &[Line],
+) -> BTreeMap<Id, Correlation> {
+ 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.
diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle.rs
index 18a0ba9c0b8b1017bbe47bb6c71eddb4a3df5a7b..6fe044890864ecf3095d0fedf590104f6121abf7 100644
--- a/crates/oa-desktop/src/behavior/scenario_lifecycle.rs
+++ b/crates/oa-desktop/src/behavior/scenario_lifecycle.rs
@@ -2,6 +2,8 @@
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
+pub(super) mod render;
+
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs
new file mode 100644
index 0000000000000000000000000000000000000000..989fb2d5b173fc68e50705f8d406f7786ba9c979
--- /dev/null
+++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs
@@ -0,0 +1,407 @@
+//! Scene bridge for local lifetime geometry; no authoring or model mutations.
+use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
+use crate::{behavior::Line, style, views::short};
+use iced::{
+ Color, Point, Rectangle, Size,
+ widget::canvas::{self, Frame, Path, Stroke, Text},
+};
+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<BTreeMap<Id, f32>> = 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,
+ lifeline: Id,
+ center: Point,
+ capsule: Rectangle,
+ name: String,
+ sender_x: Option<f32>,
+}
+
+#[derive(Debug, Default, Clone, PartialEq)]
+pub(in crate::behavior) struct Rendering {
+ // Complete rendering positions, including warning-admitted unassigned lanes.
+ // Scene.lanes independently retains the existing authoring eligibility rule.
+ lanes: BTreeMap<Id, f32>,
+ span: Option<Span>,
+ lifetimes: Vec<LaneLifetime>,
+ markers: Vec<EventMarker>,
+}
+
+impl Rendering {
+ pub(in crate::behavior) fn new(
+ project: &Project,
+ owner: Id,
+ lanes: &BTreeMap<Id, f32>,
+ rows: &BTreeMap<Id, (Id, f32)>,
+ insertion_scopes: &BTreeMap<Id, (f32, f32)>,
+ height: f32,
+ ) -> Self {
+ let span = Span {
+ start: 85.,
+ end: height - 20.,
+ };
+ if !span.valid() {
+ return Self::default();
+ }
+ // Insertion scopes deliberately start later and end earlier. Empty and
+ // Create-only scenarios still have the full visible lifetime extent.
+ let mut scopes = insertion_scopes.clone();
+ for (scope, (start, end)) in &mut scopes {
+ if *scope != owner && *start == *end && start.is_finite() {
+ // An empty operand has 42 px between its insertion axis and
+ // frame end. Match the existing 26 px open-activation allowance.
+ *end = *start + 26.;
+ }
+ }
+ scopes.insert(owner, (span.start, span.end));
+ let lifetimes = project_lifetimes(
+ project,
+ owner,
+ &Geometry {
+ rows,
+ scopes: &scopes,
+ },
+ );
+ let lanes: BTreeMap<_, _> = lanes
+ .iter()
+ .filter(|(_, x)| x.is_finite())
+ .map(|(id, x)| (*id, *x))
+ .collect();
+ let mut markers = Vec::new();
+ for lane in &lifetimes {
+ let Some(&x) = lanes.get(&lane.lifeline) else {
+ continue;
+ };
+ for event in &lane.markers {
+ let sender_x = project
+ .elements
+ .get(&event.occurrence)
+ .and_then(|e| e.shared())
+ .and_then(|body| match body {
+ SharedBody::Occurrence {
+ body:
+ OccurrenceBody::Exchange {
+ sender: Some(sender),
+ ..
+ },
+ ..
+ } => lanes.get(sender).copied(),
+ _ => None,
+ });
+ markers.push(EventMarker {
+ event: *event,
+ lifeline: lane.lifeline,
+ center: Point::new(x, event.y),
+ capsule: Rectangle::new(
+ Point::new(x - 70., event.y - 14.),
+ Size::new(140., 28.),
+ ),
+ name: project.named(lane.lifeline).to_owned(),
+ sender_x,
+ });
+ }
+ }
+ Self {
+ lanes,
+ span: Some(span),
+ lifetimes,
+ markers,
+ }
+ }
+
+ pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
+ for marker in &self.markers {
+ let Some(sender) = marker.sender_x else {
+ continue;
+ };
+ let Some(line) = lines
+ .iter_mut()
+ .find(|line| line.id == marker.event.occurrence)
+ else {
+ continue;
+ };
+ let x = marker.center.x;
+ let y = marker.center.y;
+ let self_message = sender == x;
+ let end_x = match marker.event.kind {
+ MarkerKind::Birth if sender < x => marker.capsule.x,
+ MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
+ MarkerKind::Death => x,
+ };
+ line.points = if self_message {
+ // The receiver is on the recorded occurrence row, including self
+ // lifecycle events. The elbow stays outside the birth capsule.
+ let elbow = x + 112.;
+ line.label_at = Point::new(x + 12., y - 52.);
+ vec![
+ Point::new(x, y - 24.),
+ Point::new(elbow, y - 24.),
+ Point::new(elbow, y),
+ Point::new(end_x, y),
+ ]
+ } else {
+ if marker.event.kind == MarkerKind::Birth {
+ // Keep the label above the capsule, including right-to-left
+ // messages whose existing label starts beside the receiver.
+ line.label_at.y = y - 40.;
+ }
+ vec![Point::new(sender, y), Point::new(end_x, y)]
+ };
+ }
+ }
+
+ pub(in crate::behavior) fn alive_spans(
+ &self,
+ lifeline: Id,
+ scope: Id,
+ top: f32,
+ bottom: f32,
+ ) -> Vec<Span> {
+ if !top.is_finite() || !bottom.is_finite() || bottom <= top {
+ return vec![];
+ }
+ self.lifetimes
+ .iter()
+ .find(|lane| lane.lifeline == lifeline)
+ .into_iter()
+ .flat_map(|lane| &lane.intervals)
+ .filter(|interval| interval.scope == scope && interval.life == Life::Alive)
+ .filter_map(|interval| {
+ let start = top.max(interval.span.start);
+ let end = bottom.min(interval.span.end);
+ (start < end).then_some(Span { start, end })
+ })
+ .collect()
+ }
+
+ pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
+ self.markers
+ .iter()
+ .rev()
+ .find(|marker| match marker.event.kind {
+ MarkerKind::Birth => marker.capsule.contains(point),
+ MarkerKind::Death => marker_distance(marker, point) <= 1.25,
+ })
+ .map(|marker| marker.event.occurrence)
+ }
+
+ pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
+ if !point.x.is_finite() || !point.y.is_finite() {
+ return None;
+ }
+ // Exact visible shapes are tested first by Scene. A low-zoom tolerance
+ // must not outrank another marker's actual capsule, a header, or a label.
+ self.markers
+ .iter()
+ .map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
+ .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
+ }
+
+ pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
+ let span = self.span?;
+ if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
+ return None;
+ }
+ self.lanes
+ .iter()
+ .filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
+ .min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
+ .map(|(id, _)| *id)
+ }
+
+ pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
+ let Some(span) = self.span else {
+ return;
+ };
+ for (&id, &x) in &self.lanes {
+ frame.stroke(
+ &Path::line(Point::new(x, span.start), Point::new(x, span.end)),
+ Stroke {
+ line_dash: canvas::LineDash {
+ segments: &[2., 5.],
+ offset: 0,
+ },
+ ..Stroke::default()
+ .with_width(1.)
+ .with_color(if id == selected {
+ Color::from_rgb8(148, 190, 187)
+ } else {
+ Color::from_rgb8(214, 222, 222)
+ })
+ },
+ );
+ }
+ for lifetime in &self.lifetimes {
+ let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
+ continue;
+ };
+ for interval in &lifetime.intervals {
+ match interval.life {
+ Life::Alive => frame.stroke(
+ &Path::line(
+ Point::new(x, interval.span.start),
+ Point::new(x, interval.span.end),
+ ),
+ Stroke::default().with_width(2.).with_color(
+ if lifetime.lifeline == selected {
+ style::TEAL
+ } else {
+ Color::from_rgb8(91, 113, 113)
+ },
+ ),
+ ),
+ Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
+ frame.fill_text(Text {
+ content: "?".into(),
+ position: Point::new(x + 9., interval.span.start + 2.),
+ size: 11.into(),
+ color: style::MUTED,
+ ..Default::default()
+ });
+ }
+ _ => {}
+ }
+ }
+ }
+ if !self.lanes.is_empty() {
+ frame.fill_text(Text {
+ content:
+ "Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
+ .into(),
+ position: Point::new(28., span.end + 4.),
+ size: 10.into(),
+ color: style::MUTED,
+ ..Default::default()
+ });
+ }
+ }
+
+ pub(in crate::behavior) fn draw_markers(
+ &self,
+ frame: &mut Frame,
+ selected: Id,
+ scale: f32,
+ pan: iced::Vector,
+ ) {
+ for marker in &self.markers {
+ let color = if selected == marker.event.occurrence || selected == marker.lifeline {
+ style::TEAL
+ } else {
+ style::INK
+ };
+ match marker.event.kind {
+ MarkerKind::Birth => {
+ let path = Path::rounded_rectangle(
+ marker.capsule.position(),
+ marker.capsule.size(),
+ 6.into(),
+ );
+ frame.fill(&path, Color::from_rgb8(243, 248, 248));
+ frame.stroke(
+ &path,
+ Stroke::default().with_color(color).with_width(
+ if selected == marker.event.occurrence {
+ 2.5
+ } else {
+ 1.5
+ },
+ ),
+ );
+ if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
+ // WGPU clipped frames start with an identity transform;
+ // neither the parent's pan/scale nor its clip is inherited.
+ frame.with_clip(clip, |label| {
+ label.translate(pan);
+ label.scale(scale);
+ label.fill_text(Text {
+ content: short(&marker.name, 18),
+ position: marker.capsule.position() + iced::Vector::new(8., 6.),
+ size: 12.into(),
+ color,
+ ..Default::default()
+ });
+ });
+ }
+ }
+ MarkerKind::Death => {
+ for (a, b) in death_strokes(marker.center) {
+ frame.stroke(
+ &Path::line(a, b),
+ Stroke::default().with_color(color).with_width(2.5),
+ );
+ }
+ }
+ }
+ }
+ }
+}
+
+fn birth_label_clip(
+ pan: iced::Vector,
+ capsule: Rectangle,
+ scale: f32,
+ viewport: Size,
+) -> Option<Rectangle> {
+ if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
+ return None;
+ }
+ Rectangle::new(
+ Point::new(
+ pan.x + (capsule.x + 8.) * scale,
+ pan.y + (capsule.y + 4.) * scale,
+ ),
+ Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
+ )
+ .intersection(&Rectangle::new(Point::ORIGIN, viewport))
+}
+
+fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
+ match marker.event.kind {
+ MarkerKind::Birth => {
+ let dx = (marker.capsule.x - point.x)
+ .max(point.x - marker.capsule.x - marker.capsule.width)
+ .max(0.);
+ let dy = (marker.capsule.y - point.y)
+ .max(point.y - marker.capsule.y - marker.capsule.height)
+ .max(0.);
+ dx.hypot(dy)
+ }
+ MarkerKind::Death => death_strokes(marker.center)
+ .iter()
+ .map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
+ .fold(f32::INFINITY, f32::min),
+ }
+}
+
+fn death_strokes(center: Point) -> [(Point, Point); 2] {
+ [
+ (
+ center + iced::Vector::new(-7., -7.),
+ center + iced::Vector::new(7., 7.),
+ ),
+ (
+ center + iced::Vector::new(-7., 7.),
+ center + iced::Vector::new(7., -7.),
+ ),
+ ]
+}
+
+#[cfg(test)]
+#[path = "render_tests.rs"]
+mod tests;
diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e0d9ee812a49fb1f8b24b0e9e460e08cd42aa34f
--- /dev/null
+++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs
@@ -0,0 +1,991 @@
+use super::*;
+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,
+ sender: Id,
+ receiver: Id,
+ work: Id,
+ exchange: Id,
+}
+impl Fixture {
+ fn new(layer: Layer) -> Self {
+ let mut project = Project::empty();
+ let kind = if layer == Layer::Oa {
+ Kind::Activity
+ } else {
+ Kind::Function
+ };
+ let a = project.add_element(kind, layer, "Request", None).unwrap();
+ let work = project.add_element(kind, layer, "Provide", None).unwrap();
+ let exchange = project.relate(
+ if layer == Layer::Oa {
+ RelationKind::Interaction
+ } else {
+ RelationKind::FunctionalExchange
+ },
+ "Service",
+ a,
+ work,
+ );
+ let owner = project
+ .add_element(Kind::Scenario, layer, "Lifecycle", None)
+ .unwrap();
+ let mut lane = |name, represented| {
+ let id = project
+ .add_element(Kind::Lifeline, layer, name, Some(owner))
+ .unwrap();
+ *project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
+ represented: Some(represented),
+ };
+ id
+ };
+ let sender = lane("Requester", a);
+ let receiver = lane("Provider", work);
+ Self {
+ project,
+ owner,
+ sender,
+ receiver,
+ work,
+ exchange,
+ }
+ }
+ fn occurrence(&mut self, parent: Id, name: &str, body: OccurrenceBody) -> Id {
+ let mut element = self
+ .project
+ .make_element(
+ Kind::Occurrence,
+ self.project.elements[&parent].layer(),
+ name,
+ Some(parent),
+ )
+ .unwrap();
+ let id = element.id;
+ *element.shared_mut().unwrap() = SharedBody::Occurrence {
+ body,
+ precondition: String::new(),
+ postcondition: String::new(),
+ };
+ self.project.elements.insert(id, element);
+ let parent = self.project.elements.get_mut(&parent).unwrap();
+ if let Some(steps) = parent.steps_mut() {
+ steps.push(id);
+ } else if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Fragment { branches, .. },
+ ..
+ }) = parent.shared_mut()
+ {
+ branches.push(id);
+ } else {
+ panic!("fixture parent must own ordered occurrences or operands");
+ }
+ id
+ }
+ fn message(&mut self, parent: Id, kind: MessageKind) -> Id {
+ self.occurrence(
+ parent,
+ "Message",
+ OccurrenceBody::Exchange {
+ exchange: Some(self.exchange),
+ sender: Some(self.sender),
+ receiver: Some(self.receiver),
+ message_kind: kind,
+ operation: None,
+ reply_to: None,
+ values: vec![],
+ },
+ )
+ }
+ 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,
+ "Paths",
+ OccurrenceBody::Fragment {
+ operator,
+ branches: vec![],
+ },
+ )
+ }
+ fn branch(&mut self, fragment: Id) -> Id {
+ self.occurrence(
+ fragment,
+ "Operand",
+ OccurrenceBody::Branch {
+ guard: "condition".into(),
+ occurrences: vec![],
+ },
+ )
+ }
+ fn document(&self) -> Document {
+ validate_candidate(&self.project).expect("ordinary core admission must succeed");
+ let mut bundle = source_project::format_bundle(&self.project);
+ for text in bundle.files.values_mut() {
+ *text = format!(
+ "// Retain exact authored CRLF and Unicode Ω\r\n{}",
+ text.replace('\n', "\r\n")
+ );
+ }
+ let document =
+ Document::from_bundle(bundle.clone(), source_project::ProjectLimits::default())
+ .unwrap();
+ assert_eq!(document.project(), &self.project);
+ assert_eq!(document.bundle(), &bundle);
+ document
+ }
+ fn scene(&self) -> Scene {
+ let document = self.document();
+ let before = document.snapshot();
+ let scene = Scene::sequence(document.project(), self.owner, self.owner);
+ assert_eq!(document.project(), &before.decoded.project);
+ assert_eq!(document.bundle(), &before.bundle);
+ assert_eq!(document.revision(), 0);
+ assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
+ assert_eq!(
+ scene.lifecycle,
+ Scene::sequence(document.project(), self.owner, self.owner).lifecycle
+ );
+ assert!(
+ scene
+ .lines
+ .iter()
+ .flat_map(|line| &line.points)
+ .all(|p| p.x.is_finite() && p.y.is_finite())
+ );
+ scene
+ }
+}
+
+fn intervals(scene: &Scene, lifeline: Id, scope: Id) -> Vec<(f32, f32, Life)> {
+ scene
+ .lifecycle
+ .lifetimes
+ .iter()
+ .find(|lane| lane.lifeline == lifeline)
+ .unwrap()
+ .intervals
+ .iter()
+ .filter(|i| i.scope == scope)
+ .map(|i| (i.span.start, i.span.end, i.life))
+ .collect()
+}
+
+#[test]
+fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ for name in [
+ "W".repeat(18),
+ "界".repeat(24),
+ "e\u{301}".repeat(24),
+ "👩\u{200d}🔧".repeat(12),
+ ] {
+ let mut f = Fixture::new(layer);
+ f.project.elements.get_mut(&f.receiver).unwrap().name = name.clone();
+ f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
+ let scene = f.scene();
+ let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
+ assert_eq!(header.title, name);
+ assert_eq!(header.detail, name);
+ assert_eq!(header.rect.size(), Size::new(204., 70.));
+ assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
+ // Scroll offset is handled by the existing outer canvas/scrollable;
+ // these are the local drawing frame's coordinates, not screen input.
+ for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] {
+ let clip = crate::behavior::box_text_clip(
+ iced::Vector::ZERO,
+ header.rect,
+ scale,
+ Size::new(2000., 2000.),
+ )
+ .unwrap();
+ let world = Rectangle::new(
+ Point::new(clip.x / scale, clip.y / scale),
+ Size::new(clip.width / scale, clip.height / scale),
+ );
+ assert!((world.x - header.rect.x - 10.).abs() < 0.001);
+ assert!((world.y - header.rect.y - 8.).abs() < 0.001);
+ assert!((world.width - 184.).abs() < 0.001);
+ assert!((world.height - 54.).abs() < 0.001);
+ assert!(header.rect.contains(world.position()));
+ assert!(
+ header
+ .rect
+ .contains(Point::new(world.x + world.width, world.y + world.height,))
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
+ use crate::behavior::box_text_clip;
+ let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
+ assert_eq!(
+ box_text_clip(iced::Vector::ZERO, card, 2., Size::new(300., 500.)),
+ Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.)))
+ );
+ assert_eq!(
+ box_text_clip(
+ iced::Vector::ZERO,
+ Rectangle::new(Point::new(-20., -10.), card.size()),
+ 2.,
+ Size::new(300., 500.),
+ ),
+ Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.)))
+ );
+ for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] {
+ assert_eq!(box_text_clip(iced::Vector::ZERO, card, 2., viewport), None);
+ }
+ for scale in [0., -1., f32::NAN, f32::INFINITY] {
+ assert_eq!(
+ box_text_clip(iced::Vector::ZERO, card, scale, Size::new(1000., 1000.)),
+ None
+ );
+ }
+ for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] {
+ assert_eq!(
+ box_text_clip(
+ iced::Vector::ZERO,
+ Rectangle::new(Point::ORIGIN, size),
+ 1.,
+ card.size()
+ ),
+ None
+ );
+ }
+}
+
+#[test]
+fn real_scene_wide_latin_header_exceeds_old_character_budget() {
+ use iced::advanced::text::{self, Paragraph as _};
+ let mut f = Fixture::new(Layer::Oa);
+ f.project.elements.get_mut(&f.receiver).unwrap().name = "W".repeat(18);
+ f.project.elements.get_mut(&f.work).unwrap().name = "W".repeat(24);
+ let scene = f.scene();
+ let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
+ for (content, size, divisor) in [(&header.title, 13., 7.), (&header.detail, 11., 6.)] {
+ let label = iced::widget::canvas::Text {
+ content: crate::views::short(content, ((header.rect.width - 25.) / divisor) as usize),
+ size: size.into(),
+ ..Default::default()
+ };
+ // Use the same real paragraph/font/shaping defaults as canvas Text.
+ // The native defect survives short(): glyph width, not scalar count,
+ // is what exceeds the card. This is a font witness, not a pixel gate.
+ let paragraph = <iced::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
+ content: label.content.as_str(),
+ bounds: Size::INFINITE,
+ size: label.size,
+ line_height: label.line_height,
+ font: label.font,
+ align_x: label.align_x,
+ align_y: label.align_y,
+ shaping: label.shaping,
+ wrapping: text::Wrapping::default(),
+ });
+ let old_available = header.rect.width - 12.;
+ assert!(
+ paragraph.min_width() > old_available,
+ "wide glyph fixture must overflow the old unbounded card: {} <= {old_available}",
+ paragraph.min_width()
+ );
+ }
+}
+
+#[test]
+fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
+ let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
+ for (scale, expected) in [
+ (
+ 0.05,
+ Rectangle::new(Point::new(5.4, 10.2), Size::new(6.2, 1.)),
+ ),
+ (
+ 0.5,
+ Rectangle::new(Point::new(54., 102.), Size::new(62., 10.)),
+ ),
+ (
+ 1.,
+ Rectangle::new(Point::new(108., 204.), Size::new(124., 20.)),
+ ),
+ (
+ 2.,
+ Rectangle::new(Point::new(216., 408.), Size::new(248., 40.)),
+ ),
+ ] {
+ let clip =
+ birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)).unwrap();
+ for (actual, expected) in [
+ (clip.x, expected.x),
+ (clip.y, expected.y),
+ (clip.width, expected.width),
+ (clip.height, expected.height),
+ ] {
+ assert!((actual - expected).abs() < 0.0001);
+ }
+ assert!(capsule.contains(Point::new(clip.x / scale, clip.y / scale)));
+ assert!(capsule.contains(Point::new(
+ (clip.x + clip.width) / scale,
+ (clip.y + clip.height) / scale,
+ )));
+ }
+}
+
+#[test]
+fn birth_label_clip_preserves_parent_viewport_bounds() {
+ let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
+ assert_eq!(
+ birth_label_clip(iced::Vector::ZERO, capsule, 2., Size::new(300., 420.)),
+ Some(Rectangle::new(Point::new(216., 408.), Size::new(84., 12.)))
+ );
+ let crossing_origin = Rectangle::new(Point::new(-20., -10.), Size::new(140., 28.));
+ assert_eq!(
+ birth_label_clip(
+ iced::Vector::ZERO,
+ crossing_origin,
+ 2.,
+ Size::new(300., 420.)
+ ),
+ Some(Rectangle::new(Point::ORIGIN, Size::new(224., 28.)))
+ );
+ for viewport in [Size::new(200., 420.), Size::new(300., 400.), Size::ZERO] {
+ assert_eq!(
+ birth_label_clip(iced::Vector::ZERO, capsule, 2., viewport),
+ None
+ );
+ }
+ for scale in [0., -1., f32::NAN, f32::INFINITY] {
+ assert_eq!(
+ birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)),
+ None
+ );
+ }
+}
+
+#[test]
+fn real_scene_empty_and_create_only_use_visible_root_extents_not_insertion_bounds() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ let empty = f.scene();
+ assert_eq!(empty.sequence_bounds[&f.owner], (106., 106.));
+ assert_eq!(
+ intervals(&empty, f.receiver, f.owner),
+ vec![(85., empty.size.height - 20., Life::Alive)]
+ );
+ assert!(empty.lines.is_empty(), "lane guides are not message lines");
+ let create = f.message(f.owner, MessageKind::Create);
+ let scene = f.scene();
+ let birth = scene.rows[&create].1;
+ assert_eq!(birth, 148.);
+ assert_eq!(scene.sequence_bounds[&f.owner], (106., 198.));
+ assert_eq!(
+ intervals(&scene, f.receiver, f.owner),
+ vec![
+ (85., birth, Life::Absent),
+ (birth, scene.size.height - 20., Life::Alive),
+ ]
+ );
+ assert_eq!(
+ intervals(&scene, f.sender, f.owner),
+ vec![(85., scene.size.height - 20., Life::Alive)]
+ );
+ assert_eq!(scene.slots[0].y, 106.);
+ assert_eq!(
+ scene.lane_at(Point::new(scene.lanes[&f.receiver], 90.)),
+ Some(f.receiver)
+ );
+ }
+}
+
+#[test]
+fn real_scene_birth_death_markers_align_endpoints_and_win_selection_without_mutating_source() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ let create = f.message(f.owner, MessageKind::Create);
+ f.occurrence(
+ f.owner,
+ "Work",
+ OccurrenceBody::Action {
+ action: Some(f.work),
+ lifeline: Some(f.receiver),
+ },
+ );
+ let delete = f.message(f.owner, MessageKind::Delete);
+ let mut scene = f.scene();
+ let birth = scene.rows[&create].1;
+ let death = scene.rows[&delete].1;
+ assert_eq!(
+ intervals(&scene, f.receiver, f.owner),
+ vec![
+ (85., birth, Life::Absent),
+ (birth, death, Life::Alive),
+ (death, scene.size.height - 20., Life::Absent),
+ ]
+ );
+ let capsule = scene
+ .lifecycle
+ .markers
+ .iter()
+ .find(|m| m.event.occurrence == create)
+ .unwrap()
+ .capsule;
+ let line = scene.lines.iter().find(|l| l.id == create).unwrap();
+ let expected_x = if scene.lanes[&f.sender] < scene.lanes[&f.receiver] {
+ capsule.x
+ } else {
+ capsule.x + capsule.width
+ };
+ assert_eq!(line.points.last(), Some(&Point::new(expected_x, birth)));
+ assert!(
+ line.label_at.y + 22. < capsule.y,
+ "message caption clears birth capsule"
+ );
+ let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
+ assert_eq!(
+ line.points.last(),
+ Some(&Point::new(scene.lanes[&f.receiver], death))
+ );
+ for scale in [0.5, 1., 2.] {
+ scene.scale = scale;
+ assert_eq!(
+ scene.hit(Point::new(scene.lanes[&f.receiver], birth)),
+ Some(create)
+ );
+ assert_eq!(
+ scene.hit(Point::new(scene.lanes[&f.receiver], death)),
+ Some(delete)
+ );
+ assert_eq!(
+ scene.hit(Point::new(
+ scene.lanes[&f.receiver],
+ scene.size.height - 25.
+ )),
+ Some(f.receiver)
+ );
+ assert_eq!(
+ scene.hit(Point::new(scene.lanes[&f.receiver], 40.)),
+ Some(f.receiver)
+ );
+ }
+ }
+}
+
+#[test]
+fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_visible() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ let call = f.message(f.owner, MessageKind::Call);
+ let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
+ let left = f.branch(fragment);
+ let delete = f.message(left, MessageKind::Delete);
+ let right = f.branch(fragment);
+ let scene = f.scene();
+ let death = scene.rows[&delete].1;
+ let (left_start, left_end) = scene.sequence_bounds[&left];
+ let (right_start, _) = scene.sequence_bounds[&right];
+ let bars: Vec<_> = scene
+ .activations
+ .iter()
+ .filter(|b| b.call == call && b.receiver == f.receiver)
+ .map(|b| b.rect)
+ .collect();
+ assert!(
+ bars.iter()
+ .any(|r| r.y == left_start && r.y + r.height == death)
+ );
+ assert!(
+ !bars
+ .iter()
+ .any(|r| r.y < left_end && r.y + r.height > death && r.y >= left_start)
+ );
+ assert!(
+ bars.iter().any(|r| r.y == right_start && r.height == 26.),
+ "empty exclusive sibling retains its own open activation"
+ );
+ assert_eq!(
+ intervals(&scene, f.receiver, right),
+ vec![(right_start, right_start + 26., Life::Alive)]
+ );
+ let frame_end = scene.boxes.iter().find(|b| b.id == fragment).unwrap().rect;
+ assert!(
+ !bars.iter().any(|r| r.y >= frame_end.y + frame_end.height),
+ "uncertain post-fragment lifetime cannot carry a definite activation bar"
+ );
+ assert!(
+ scene
+ .open_calls
+ .iter()
+ .any(|p| p.y < death && p.y > left_start)
+ );
+ assert!(scene.open_calls.iter().any(|p| p.y > right_start));
+ assert_eq!(
+ intervals(&scene, f.receiver, left),
+ vec![
+ (left_start, death, Life::Alive),
+ (death, left_end, Life::Absent)
+ ]
+ );
+ assert_eq!(
+ intervals(&scene, f.receiver, f.owner).last().unwrap().2,
+ Life::Unresolved
+ );
+ assert!(!f.project.elements.values().any(|e| matches!(
+ e.shared(),
+ Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange {
+ message_kind: MessageKind::Reply,
+ ..
+ },
+ ..
+ })
+ )));
+ }
+}
+
+#[test]
+fn real_scene_unassigned_lanes_and_explicit_incomplete_receiver_keep_real_marker_identity() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ *f.project
+ .elements
+ .get_mut(&f.receiver)
+ .unwrap()
+ .shared_mut()
+ .unwrap() = SharedBody::Lifeline { represented: None };
+ let create = f.message(f.owner, MessageKind::Create);
+ if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange { exchange, .. },
+ ..
+ }) = f.project.elements.get_mut(&create).unwrap().shared_mut()
+ {
+ *exchange = None;
+ }
+ let scene = f.scene();
+ assert!(
+ !scene.lanes.contains_key(&f.receiver),
+ "authoring eligibility remains separate"
+ );
+ let x = scene.lifecycle.lanes[&f.receiver];
+ assert_eq!(scene.lifecycle.markers[0].lifeline, f.receiver);
+ assert_eq!(
+ scene.hit(Point::new(x, scene.rows[&create].1)),
+ Some(create)
+ );
+ assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
+ assert_eq!(
+ scene.hit(Point::new(x, scene.size.height - 25.)),
+ Some(f.receiver)
+ );
+ assert_eq!(scene.lane_at(Point::new(x, 90.)), None);
+ if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange {
+ receiver, exchange, ..
+ },
+ ..
+ }) = f.project.elements.get_mut(&create).unwrap().shared_mut()
+ {
+ *receiver = None;
+ *exchange = Some(f.exchange);
+ }
+ let missing = f.scene();
+ assert!(
+ missing.lifecycle.markers.is_empty(),
+ "do not infer receiver from global exchange"
+ );
+ }
+}
+
+#[test]
+fn real_scene_minimum_zoom_marker_halos_do_not_steal_adjacent_capsule_or_header_hits() {
+ let mut f = Fixture::new(Layer::Sa);
+ *f.project
+ .elements
+ .get_mut(&f.receiver)
+ .unwrap()
+ .shared_mut()
+ .unwrap() = SharedBody::Lifeline { represented: None };
+ let create = f.message(f.owner, MessageKind::Create);
+ let delete = f.message(f.owner, MessageKind::Delete);
+ for event in [create, delete] {
+ if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange { exchange, .. },
+ ..
+ }) = f.project.elements.get_mut(&event).unwrap().shared_mut()
+ {
+ *exchange = None;
+ }
+ }
+ let mut scene = f.scene();
+ let x = scene.lifecycle.lanes[&f.receiver];
+ assert_eq!(scene.rows[&delete].1 - scene.rows[&create].1, 92.);
+ for scale in [0.05, 0.1, 0.5, 1., 2.] {
+ scene.scale = scale;
+ assert_eq!(
+ scene.hit(Point::new(x, scene.rows[&create].1)),
+ Some(create)
+ );
+ assert_eq!(
+ scene.hit(Point::new(x, scene.rows[&delete].1)),
+ Some(delete)
+ );
+ assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
+ if scale <= 0.1 {
+ for offset in [-25., -15., 15., 25.] {
+ assert_eq!(
+ scene.hit(Point::new(x, scene.rows[&delete].1 + offset)),
+ Some(delete),
+ "a nearby death halo must beat an earlier message's wider tolerance"
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn real_scene_minimum_zoom_near_ordinary_message_wins_over_lifecycle_halos() {
+ let mut f = Fixture::new(Layer::Sa);
+ f.message(f.owner, MessageKind::Create);
+ let ordinary = f.message(f.owner, MessageKind::Asynchronous);
+ f.message(f.owner, MessageKind::Delete);
+ let mut scene = f.scene();
+ let x = (scene.lanes[&f.sender] + scene.lanes[&f.receiver]) / 2.;
+ for scale in [0.05, 0.1] {
+ scene.scale = scale;
+ assert_eq!(
+ scene.hit(Point::new(x, scene.rows[&ordinary].1 + 2.)),
+ Some(ordinary)
+ );
+ }
+}
+
+#[test]
+fn real_scene_self_lifecycle_elbows_end_on_recorded_marker_rows() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ for kind in [MessageKind::Create, MessageKind::Delete] {
+ let mut f = Fixture::new(layer);
+ let event = f.message(f.owner, kind);
+ if let Some(SharedBody::Occurrence {
+ body:
+ OccurrenceBody::Exchange {
+ exchange, sender, ..
+ },
+ ..
+ }) = f.project.elements.get_mut(&event).unwrap().shared_mut()
+ {
+ *exchange = None;
+ *sender = Some(f.receiver);
+ }
+ let scene = f.scene();
+ let marker = &scene.lifecycle.markers[0];
+ let line = scene.lines.iter().find(|l| l.id == event).unwrap();
+ assert_eq!(line.points.len(), 4);
+ assert_eq!(line.points[0].y, marker.center.y - 24.);
+ assert_eq!(line.points[3].y, marker.center.y);
+ assert_eq!(
+ line.points[3].x,
+ if marker.event.kind == MarkerKind::Birth {
+ marker.capsule.x + marker.capsule.width
+ } else {
+ marker.center.x
+ }
+ );
+ assert!(line.points[1].x > marker.capsule.x + marker.capsule.width);
+ assert_eq!(scene.hit(marker.center), Some(event));
+ }
+ }
+}
+
+#[test]
+fn real_scene_self_delete_caption_clears_the_preceding_action_box() {
+ let mut f = Fixture::new(Layer::Sa);
+ let action = f.occurrence(
+ f.owner,
+ "Finish work",
+ OccurrenceBody::Action {
+ action: Some(f.work),
+ lifeline: Some(f.receiver),
+ },
+ );
+ let delete = f.message(f.owner, MessageKind::Delete);
+ if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange {
+ exchange, sender, ..
+ },
+ ..
+ }) = f.project.elements.get_mut(&delete).unwrap().shared_mut()
+ {
+ *exchange = None;
+ *sender = Some(f.receiver);
+ }
+ let scene = f.scene();
+ let action = scene.boxes.iter().find(|b| b.id == action).unwrap();
+ let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
+ assert!(line.label_at.y > action.rect.y + action.rect.height);
+ assert!(line.label_at.y + 22. < line.points[0].y);
+ assert_eq!(line.points[3].y, scene.rows[&delete].1);
+ assert_eq!(
+ scene
+ .slots
+ .iter()
+ .find(|s| s.slot.before == Some(delete))
+ .unwrap()
+ .y,
+ scene.rows[&delete].1 - 42.
+ );
+}
+
+#[test]
+fn real_scene_conditional_repeated_and_parallel_scopes_do_not_claim_definite_continuation() {
+ for operator in [
+ FragmentOperator::Alternative,
+ FragmentOperator::Optional,
+ FragmentOperator::Loop,
+ FragmentOperator::Parallel,
+ ] {
+ let mut f = Fixture::new(Layer::Sa);
+ let fragment = f.fragment(f.owner, operator);
+ let first = f.branch(fragment);
+ let create = f.message(first, MessageKind::Create);
+ let scene = f.scene();
+ assert_eq!(scene.lifecycle.markers[0].event.occurrence, create);
+ assert_eq!(
+ intervals(&scene, f.receiver, f.owner).first().unwrap().2,
+ Life::Unresolved
+ );
+ assert_eq!(
+ intervals(&scene, f.receiver, f.owner).last().unwrap().2,
+ Life::Unresolved
+ );
+ assert!(
+ scene
+ .lifecycle
+ .lifetimes
+ .iter()
+ .find(|l| l.lifeline == f.sender)
+ .unwrap()
+ .intervals
+ .iter()
+ .all(|i| i.life == Life::Alive)
+ );
+ }
+}
+
+#[test]
+fn real_scene_lifecycle_edit_undo_redo_restores_exact_source_and_changed_geometry() {
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ let create = f.message(f.owner, MessageKind::Create);
+ let mut document = f.document();
+ let before = document.snapshot();
+ let geometry = Scene::sequence(document.project(), f.owner, create).lifecycle;
+ let mut edited = document.project().elements[&create].clone();
+ if let Some(SharedBody::Occurrence {
+ body: OccurrenceBody::Exchange { message_kind, .. },
+ ..
+ }) = edited.shared_mut()
+ {
+ *message_kind = MessageKind::Asynchronous;
+ }
+ document
+ .execute(Command::EditElement(create, edited))
+ .unwrap();
+ let after = document.snapshot();
+ let changed = Scene::sequence(document.project(), f.owner, create).lifecycle;
+ assert_ne!(geometry, changed);
+ assert!(changed.markers.is_empty());
+ assert_ne!(before.bundle, after.bundle);
+ assert!(document.undo());
+ assert_eq!(document.project(), &before.decoded.project);
+ assert_eq!(document.bundle(), &before.bundle);
+ assert_eq!(
+ Scene::sequence(document.project(), f.owner, create).lifecycle,
+ geometry
+ );
+ assert!(!document.can_undo() && document.can_redo());
+ assert!(document.redo());
+ assert_eq!(document.project(), &after.decoded.project);
+ assert_eq!(document.bundle(), &after.bundle);
+ assert_eq!(
+ Scene::sequence(document.project(), f.owner, create).lifecycle,
+ changed
+ );
+ assert!(!document.can_redo());
+ }
+}
+
+#[test]
+fn panned_header_and_create_clips_use_screen_translation_and_actual_viewport() {
+ use crate::behavior::box_text_clip;
+ use iced::Vector;
+ let header = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
+ let capsule = Rectangle::new(header.position(), Size::new(140., 28.));
+ let rect = |x, y, w, h| Rectangle::new(Point::new(x, y), Size::new(w, h));
+ for (pan, scale, viewport, header_expected, birth_expected) in [
+ (
+ Vector::new(37., -41.),
+ 0.5,
+ Size::new(300., 400.),
+ rect(92., 63., 92., 27.),
+ rect(91., 61., 62., 10.),
+ ),
+ (
+ Vector::new(37., -41.),
+ 1.,
+ Size::new(300., 400.),
+ rect(147., 167., 153., 54.),
+ rect(145., 163., 124., 20.),
+ ),
+ (
+ Vector::new(37., -41.),
+ 2.,
+ Size::new(300., 400.),
+ rect(257., 375., 43., 25.),
+ rect(253., 367., 47., 33.),
+ ),
+ (
+ Vector::new(-200., -400.),
+ 2.,
+ Size::new(300., 100.),
+ rect(20., 16., 280., 84.),
+ rect(16., 8., 248., 40.),
+ ),
+ (
+ Vector::new(-237., -411.),
+ 2.,
+ Size::new(300., 100.),
+ rect(0., 5., 300., 95.),
+ rect(0., 0., 227., 37.),
+ ),
+ ] {
+ assert_eq!(
+ box_text_clip(pan, header, scale, viewport),
+ Some(header_expected)
+ );
+ assert_eq!(
+ birth_label_clip(pan, capsule, scale, viewport),
+ Some(birth_expected)
+ );
+ }
+ for pan in [
+ Vector::new(1000., 1000.),
+ Vector::new(-1000., -1000.),
+ Vector::new(f32::NAN, 0.),
+ Vector::new(0., f32::INFINITY),
+ ] {
+ assert_eq!(box_text_clip(pan, header, 2., Size::new(300., 100.)), None);
+ assert_eq!(
+ birth_label_clip(pan, capsule, 2., Size::new(300., 100.)),
+ None
+ );
+ }
+}
+
+#[test]
+fn real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity() {
+ use crate::{
+ app::Message,
+ behavior::{
+ SequenceInput,
+ scenario::{CanvasEvent, CanvasTool},
+ },
+ };
+ use iced::{Vector, mouse, widget::canvas};
+ for layer in [Layer::Oa, Layer::Sa] {
+ let mut f = Fixture::new(layer);
+ f.project.elements.get_mut(&f.receiver).unwrap().name = "界".repeat(24);
+ let create = f.message(f.owner, MessageKind::Create);
+ let delete = f.message(f.owner, MessageKind::Delete);
+ let document = f.document();
+ let before = document.snapshot();
+ for scale in [0.5, 1., 2.] {
+ for pan in [Vector::new(35., 40.), Vector::new(-35., -5.)] {
+ let mut scene = f.scene();
+ scene.scale = scale;
+ scene.camera_pan = pan;
+ let context = crate::behavior::scenario::direct_tests::context(f.owner);
+ scene.interaction = Some((context, CanvasTool::Select, true));
+ let header = scene.boxes.iter().find(|b| b.id == f.sender).unwrap();
+ let targets = [
+ (f.sender, header.rect.center()),
+ (
+ create,
+ scene
+ .lifecycle
+ .markers
+ .iter()
+ .find(|m| m.event.occurrence == create)
+ .unwrap()
+ .center,
+ ),
+ (
+ delete,
+ scene
+ .lifecycle
+ .markers
+ .iter()
+ .find(|m| m.event.occurrence == delete)
+ .unwrap()
+ .center,
+ ),
+ ];
+ // Absolute widget origin models the outer layout/scroll translation;
+ // camera pan remains local screen units and must be subtracted once.
+ let bounds = Rectangle::new(Point::new(73., 119.), Size::new(2000., 1200.));
+ for (expected, world) in targets {
+ let screen =
+ bounds.position() + pan + Vector::new(world.x * scale, world.y * scale);
+ assert!(bounds.contains(screen));
+ let mut state = SequenceInput::default();
+ let mut published = Vec::new();
+ for event in [
+ mouse::Event::CursorMoved { position: screen },
+ mouse::Event::ButtonPressed(mouse::Button::Left),
+ mouse::Event::ButtonReleased(mouse::Button::Left),
+ ] {
+ if let Some(message) = canvas::Program::update(
+ &scene,
+ &mut state,
+ &canvas::Event::Mouse(event),
+ bounds,
+ mouse::Cursor::Available(screen),
+ )
+ .and_then(|a| a.into_inner().0)
+ {
+ published.push(message);
+ }
+ }
+ assert!(
+ matches!(published.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Select(actual_context, actual))] if *actual_context == context && *actual == expected),
+ "Pan/zoom must retain the typed hit identity"
+ );
+ }
+ assert_eq!(scene.lifecycle, f.scene().lifecycle);
+ }
+ }
+ assert_eq!(document.project(), &before.decoded.project);
+ assert_eq!(document.bundle(), &before.bundle);
+ assert!(!document.can_undo() && !document.dirty());
+ }
+}
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 0000000000000000000000000000000000000000..86fa0cceff11ff66497c3514e43659a0ca003a52
--- /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<T>(id: Id, offset: f32, f: impl FnOnce() -> T) -> T {
+ struct Restore(BTreeMap<Id, f32>);
+ 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::<Vec<_>>(),
+ scene
+ .boxes
+ .iter()
+ .map(|b| (b.id, b.rect, &b.title, &b.detail, b.marker))
+ .collect::<Vec<_>>(),
+ scene
+ .slots
+ .iter()
+ .map(|s| (s.slot, s.y, &s.label))
+ .collect::<Vec<_>>(),
+ 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());
+ }
+}