Integrate lifecycle rendering with typed scenario activations

Reuse published lifecycle bridge and renderer checks from peer checkpoint 7aa007b. Preserve logical correlation independently from clipped paint spans, terminate executions on receiver death, and retain conservative sibling uncertainty without resurrection.
This commit is contained in:
2026-09-12 16:56:20 -07:00
parent e0685b8e52
commit aadc6396d3
6 changed files with 2151 additions and 77 deletions
+99 -17
View File
@@ -21,8 +21,6 @@ pub(crate) mod creation;
mod form_session; mod form_session;
pub(crate) mod scenario; pub(crate) mod scenario;
mod scenario_activation; mod scenario_activation;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle; mod scenario_lifecycle;
pub(crate) mod source_draft; pub(crate) mod source_draft;
pub(crate) mod state_canvas; pub(crate) mod state_canvas;
@@ -1127,6 +1125,31 @@ struct BoxItem {
detail: String, detail: String,
marker: Option<Kind>, 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 { struct Line {
id: Id, id: Id,
points: Vec<Point>, points: Vec<Point>,
@@ -1147,6 +1170,7 @@ struct Scene {
slots: Vec<SequenceSlot>, slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>, rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>, activations: Vec<scenario_activation::Segment>,
lifecycle: scenario_lifecycle::render::Rendering,
open_calls: Vec<Point>, open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>, sequence_bounds: BTreeMap<Id, (f32, f32)>,
} }
@@ -1211,6 +1235,7 @@ impl Scene {
slots: vec![], slots: vec![],
rows: BTreeMap::new(), rows: BTreeMap::new(),
activations: vec![], activations: vec![],
lifecycle: scenario_lifecycle::render::Rendering::default(),
open_calls: vec![], open_calls: vec![],
sequence_bounds: BTreeMap::new(), sequence_bounds: BTreeMap::new(),
}; };
@@ -1299,12 +1324,22 @@ impl Scene {
} }
let mut y = if scenario { 148. } else { 120. }; let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
if scenario { 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.layout_activations(p, &lane_x);
} }
scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes { for (id, name, detail) in lanes {
let x = lane_x[&id]; let x = lane_x[&id];
if !scenario {
scene.lines.insert( scene.lines.insert(
0, 0,
Line { Line {
@@ -1315,6 +1350,7 @@ impl Scene {
dashed: false, dashed: false,
}, },
); );
}
scene.boxes.push(BoxItem { scene.boxes.push(BoxItem {
id, id,
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), 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 { let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue; 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 { if ordered {
let slot = scenario::Slot { let slot = scenario::Slot {
parent: owner, parent: owner,
@@ -1607,6 +1655,12 @@ impl Scene {
)) ))
} }
fn hit(&self, point: Point) -> Option<Id> { 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 self.boxes
.iter() .iter()
.rev() .rev()
@@ -1652,18 +1706,8 @@ impl Scene {
}) })
// The generous message tolerance must not steal the exposed // The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out. // shoulder of another call's execution when the view is zoomed out.
.or_else(|| { .or_else(|| self.near_sequence_item(point))
self.lines .or_else(|| self.lifecycle.lane_at(point, self.scale))
.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.lane_at(point)) .or_else(|| self.lane_at(point))
.or_else(|| { .or_else(|| {
self.lines self.lines
@@ -1678,6 +1722,33 @@ impl Scene {
.map(|line| line.id) .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 { impl canvas::Program<Message> for Scene {
type State = SequenceInput; type State = SequenceInput;
@@ -1962,6 +2033,7 @@ impl canvas::Program<Message> for Scene {
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan); f.translate(self.camera_pan);
f.scale(self.scale); f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
// Guides belong behind execution fills and cards. In particular, a // Guides belong behind execution fills and cards. In particular, a
// lifeline must not cut through the middle of its activation rectangle. // lifeline must not cut through the middle of its activation rectangle.
for guide in self.lines.iter().filter(|line| line.label.is_empty()) { for guide in self.lines.iter().filter(|line| line.label.is_empty()) {
@@ -2084,7 +2156,13 @@ impl canvas::Program<Message> for Scene {
} }
} }
for b in &self.boxes { for b in &self.boxes {
f.fill_text(Text { 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), content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.), position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(), size: 13.into(),
@@ -2096,7 +2174,7 @@ impl canvas::Program<Message> for Scene {
..Default::default() ..Default::default()
}); });
if !b.detail.is_empty() { if !b.detail.is_empty() {
f.fill_text(Text { label.fill_text(Text {
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize), content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 39.), position: Point::new(b.rect.x + 12., b.rect.y + 39.),
size: 11.into(), size: 11.into(),
@@ -2104,6 +2182,8 @@ impl canvas::Program<Message> for Scene {
..Default::default() ..Default::default()
}); });
} }
});
}
} }
for point in &self.open_calls { for point in &self.open_calls {
f.fill_text(Text { f.fill_text(Text {
@@ -2114,6 +2194,8 @@ impl canvas::Program<Message> for Scene {
..Default::default() ..Default::default()
}); });
} }
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction { if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool; let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor.position_in(bounds).map(|p| { let point = cursor.position_in(bounds).map(|p| {
@@ -12,9 +12,13 @@ pub(super) enum Finish {
/// Still open at this scope boundary; the visible span is not an isolated stub. /// Still open at this scope boundary; the visible span is not an isolated stub.
Continuation, Continuation,
Open, 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(super) struct Segment {
pub call: Id, pub call: Id,
pub receiver: Id, pub receiver: Id,
@@ -40,6 +44,9 @@ impl Segment {
impl Scene { impl Scene {
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) { 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 let mut calls: Vec<_> = self
.rows .rows
.iter() .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))); 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 { for &(call, receiver, scope, _, x) in &calls {
let first = self.activations.len(); let first = self.activations.len();
let first_cue = self.open_calls.len();
self.activation_sequence(p, scope, call, receiver, x, None); self.activation_sequence(p, scope, call, receiver, x, None);
let column = self.activations[first..] let column = self.activations[first..]
.iter() .iter()
@@ -79,6 +87,9 @@ impl Scene {
segment.column = column; segment.column = column;
segment.rect.x += column as f32 * OFFSET; 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. // Shallow executions paint first; columns cannot collide within a scope.
self.activations.sort_by(|a, b| { 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.rect.y.total_cmp(&b.rect.y))
.then_with(|| a.call.cmp(&b.call)) .then_with(|| a.call.cmp(&b.call))
}); });
self.open_calls = self self.anchor_activation_messages(p, lanes, &correlations);
.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);
} }
fn activation_segment( fn activation_segment(
@@ -105,17 +110,33 @@ impl Scene {
span: std::ops::Range<f32>, span: std::ops::Range<f32>,
finish: Finish, 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 { self.activations.push(Segment {
call, call,
receiver, receiver,
scope, scope,
rect: Rectangle::new( rect: Rectangle::new(
Point::new(x - WIDTH / 2., span.start), Point::new(x - WIDTH / 2., alive.start),
Size::new(WIDTH, span.end - span.start), Size::new(WIDTH, alive.end - alive.start),
), ),
column: 0, column: 0,
finish, finish: if alive.end == span.end {
finish
} else {
Finish::Clipped
},
}); });
} }
} }
@@ -130,9 +151,10 @@ impl Scene {
receiver: Id, receiver: Id,
x: f32, x: f32,
mut start: Option<f32>, mut start: Option<f32>,
) -> bool { ) -> Outcome {
let entered_open = start.is_some(); let entered_open = start.is_some();
let mut split = false; let mut split = false;
let mut may_be_terminated = false;
for id in p.elements[&owner].steps().into_iter().flatten() { for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else { let Some(&(_, y)) = self.rows.get(id) else {
continue; continue;
@@ -151,6 +173,26 @@ impl Scene {
continue; continue;
}; };
match p.elements[id].shared() { 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 { Some(SharedBody::Occurrence {
body: body:
OccurrenceBody::Exchange { OccurrenceBody::Exchange {
@@ -185,13 +227,23 @@ impl Scene {
.collect(); .collect();
let open = match operator { let open = match operator {
FragmentOperator::Alternative => { 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, 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); start = open.then_some(bottom);
} }
}
_ => {} _ => {}
} }
} }
@@ -210,22 +262,19 @@ impl Scene {
}; };
self.activation_segment(call, receiver, owner, x, top..bottom, finish); 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 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 { for line in &mut self.lines {
let Some(SharedBody::Occurrence { let Some(SharedBody::Occurrence {
body: body:
@@ -259,15 +308,18 @@ impl Scene {
let reflective = sender == receiver; let reflective = sender == receiver;
let source = if *message_kind == MessageKind::Reply { let source = if *message_kind == MessageKind::Reply {
reply_to.and_then(|call| { reply_to.and_then(|call| {
segments segments.iter().find(|a| {
.iter() a.call == call
.find(|a| a.call == call && a.finish == Finish::Reply(line.id)) && a.scope == scope
&& a.finish == Finish::Reply(line.id)
&& a.bottom() == send_y
})
}) })
} else { } else {
active_segment( active_segment(
p, p,
segments, segments,
&correlations, correlations,
*sender, *sender,
scope, scope,
send_y, send_y,
@@ -282,7 +334,7 @@ impl Scene {
active_segment( active_segment(
p, p,
segments, segments,
&correlations, correlations,
*receiver, *receiver,
scope, scope,
receive_y, 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 /// A caller has no implicit execution just because it sent a message. Only an
/// actually drawn, known-duration execution can supply its anchor. Crossed /// actually drawn, known-duration execution can supply its anchor. Crossed
/// intervals provide no unambiguous innermost execution, so fall back to the lane. /// intervals provide no unambiguous innermost execution, so fall back to the lane.
@@ -2,6 +2,8 @@
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody}; use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
pub(super) mod render;
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence. /// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> { pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>, pub rows: &'a BTreeMap<Id, (Id, f32)>,
@@ -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;
@@ -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());
}
}
@@ -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());
}
}