diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index ea70799e..0a0c4df4 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, } +fn box_text_clip( + pan: iced::Vector, + rect: Rectangle, + scale: f32, + viewport: Size, +) -> Option { + 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, @@ -1147,6 +1170,7 @@ struct Scene { slots: Vec, rows: BTreeMap, activations: Vec, + lifecycle: scenario_lifecycle::render::Rendering, open_calls: Vec, sequence_bounds: BTreeMap, } @@ -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 { + 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 { + 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 for Scene { type State = SequenceInput; @@ -1962,6 +2033,7 @@ impl canvas::Program 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 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 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 fdafed58..88fa4d6e 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) { + // 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, 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, - ) -> 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; @@ -151,6 +173,26 @@ impl Scene { continue; }; match p.elements[id].shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Delete, + receiver: Some(deleted), + .. + }, + .. + }) if *deleted == receiver => { + self.activation_segment( + call, + receiver, + owner, + x, + top..y, + Finish::Terminated(*id), + ); + start = None; + may_be_terminated = true; + } Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { @@ -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) { + fn anchor_activation_messages( + &mut self, + p: &Project, + lanes: &BTreeMap, + correlations: &BTreeMap, + ) { let segments = &self.activations; - let mut correlations: BTreeMap = 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, + lines: &[Line], +) -> BTreeMap { + let mut result = BTreeMap::new(); + let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect(); + for (&id, &(_, y)) in rows { + if matches!( + p.elements.get(&id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + .. + }, + .. + }) + ) { + result.insert( + id, + Correlation { + start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y), + replies: vec![], + }, + ); + } + } + for (&id, &(scope, y)) in rows { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(call), + .. + }, + .. + }) = p.elements.get(&id).and_then(|e| e.shared()) + && let Some(correlation) = result.get_mut(call) + { + let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y); + correlation.replies.push((scope, sent)); + } + } + result +} + /// A caller has no implicit execution just because it sent a message. Only an /// actually drawn, known-duration execution can supply its anchor. Crossed /// intervals provide no unambiguous innermost execution, so fall back to the lane. diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle.rs index 18a0ba9c..6fe04489 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, 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 00000000..989fb2d5 --- /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> = 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, +} + +#[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, + span: Option, + lifetimes: Vec, + markers: Vec, +} + +impl Rendering { + pub(in crate::behavior) fn new( + project: &Project, + owner: Id, + lanes: &BTreeMap, + rows: &BTreeMap, + insertion_scopes: &BTreeMap, + 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 { + 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 { + 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 { + 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 { + 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 00000000..e0d9ee81 --- /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 = ::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 00000000..86fa0cce --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/union_tests.rs @@ -0,0 +1,489 @@ +use super::*; +use crate::behavior::scenario_activation::{Finish, Segment}; + +fn line(scene: &Scene, id: Id) -> &crate::behavior::Line { + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn segments(scene: &Scene, call: Id) -> Vec<&Segment> { + scene + .activations + .iter() + .filter(|a| a.call == call) + .collect() +} +fn assert_visible_lifetime(scene: &Scene) { + for a in &scene.activations { + assert_eq!( + scene + .lifecycle + .alive_spans(a.receiver, a.scope, a.rect.y, a.rect.y + a.rect.height), + vec![Span { + start: a.rect.y, + end: a.rect.y + a.rect.height + }] + ); + if let Finish::Reply(reply) = a.finish { + assert_eq!(a.rect.y + a.rect.height, line(scene, reply).points[0].y); + } + } +} + +#[test] +fn admitted_delayed_create_nested_calls_replies_delete_share_one_geometry_pipeline() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let birth = f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + let r2 = f.reply(f.owner, c2); + let r1 = f.reply(f.owner, c1); + let death = f.message(f.owner, MessageKind::Delete); + let scene = f.scene(); + assert_eq!(scene.activations.len(), 2); + for (call, reply, column) in [(c1, r1, 0), (c2, r2, 1)] { + let a = segments(&scene, call)[0]; + assert_eq!( + (a.receiver, a.scope, a.column, a.finish), + (f.receiver, f.owner, column, Finish::Reply(reply)) + ); + let received = *line(&scene, call).points.last().unwrap(); + let sent = line(&scene, reply).points[0]; + assert_eq!((a.rect.y, a.rect.y + a.rect.height), (received.y, sent.y)); + assert_eq!(received.x, sent.x); + assert!( + a.rect.y > scene.rows[&birth].1 && a.rect.y + a.rect.height < scene.rows[&death].1 + ); + } + assert_visible_lifetime(&scene); + assert!(scene.open_calls.is_empty()); + for event in [birth, death] { + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)), + Some(event) + ); + } + } +} + +#[test] +fn admitted_death_and_incomplete_later_reply_do_not_fabricate_an_answered_execution() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let death = f.message(f.owner, MessageKind::Delete); + let reply = f.reply(f.owner, call); + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + } = f + .project + .elements + .get_mut(&reply) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = None; + *exchange = None; + } + let scene = f.scene(); + let a = segments(&scene, call)[0]; + assert_eq!(a.finish, Finish::Terminated(death)); + assert_eq!(a.rect.y + a.rect.height, scene.rows[&death].1); + assert!( + scene.boxes.iter().any(|b| b.id == reply), + "The incomplete reply remains a repairable card" + ); + assert_eq!(scene.open_calls.len(), 1); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn admitted_self_lifecycle_and_self_call_keep_distinct_send_receive_rows() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let action = f.occurrence( + f.owner, + "Sender preparation", + OccurrenceBody::Action { + action: None, + lifeline: Some(f.sender), + }, + ); + let birth = f.message(f.owner, MessageKind::Create); + let call = f.message(f.owner, MessageKind::Call); + let reply = f.reply(f.owner, call); + let death = f.message(f.owner, MessageKind::Delete); + for event in [birth, call, reply, death] { + if let SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + sender, + receiver, + exchange, + .. + }, + .. + } = f + .project + .elements + .get_mut(&event) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = Some(f.receiver); + *receiver = Some(f.receiver); + *exchange = None; + } + } + let scene = f.scene(); + let preparation = scene.boxes.iter().find(|b| b.id == action).unwrap(); + assert!(line(&scene, birth).label_at.y > preparation.rect.y + preparation.rect.height); + for event in [birth, death] { + let message = line(&scene, event); + assert_eq!(message.points[0].y, scene.rows[&event].1 - 24.); + assert_eq!(message.points.last().unwrap().y, scene.rows[&event].1); + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)), + Some(event) + ); + } + let activation = segments(&scene, call)[0]; + assert_eq!(activation.rect.y, scene.rows[&call].1 + 24.); + assert_eq!( + activation.rect.y, + line(&scene, call).points.last().unwrap().y + ); + assert_eq!( + activation.rect.y + activation.rect.height, + line(&scene, reply).points[0].y + ); + assert_eq!(activation.finish, Finish::Reply(reply)); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn admitted_unresolved_operand_keeps_unanswered_cue_without_any_visible_segment() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + f.message(left, MessageKind::Create); + let right = f.branch(fragment); + let call = f.message(right, MessageKind::Call); + let scene = f.scene(); + assert!(segments(&scene, call).is_empty()); + assert!( + intervals(&scene, f.receiver, right) + .iter() + .all(|i| i.2 == Life::Unresolved) + ); + assert_eq!(scene.open_calls.len(), 1); + assert!(scene.open_calls[0].y > scene.rows[&call].1); + assert_eq!( + line(&scene, call).points.last().unwrap().x, + scene.lanes[&f.receiver] + ); + } +} + +#[test] +fn admitted_calls_without_sender_polylines_still_have_logical_correlation_metadata() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + for call in [c1, c2] { + if let SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + } = f + .project + .elements + .get_mut(&call) + .unwrap() + .shared_mut() + .unwrap() + { + *sender = None; + *exchange = None; + } + } + let outgoing = f.occurrence( + f.owner, + "Outgoing", + OccurrenceBody::Exchange { + exchange: None, + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + ); + f.reply(f.owner, c2); + f.reply(f.owner, c1); + let scene = f.scene(); + assert!(scene.boxes.iter().any(|b| b.id == c1)); + assert!(scene.boxes.iter().any(|b| b.id == c2)); + let inner = segments(&scene, c2)[0]; + let expected = if scene.lanes[&f.sender] > scene.lanes[&f.receiver] { + inner.rect.x + inner.rect.width + } else { + inner.rect.x + }; + assert_eq!(line(&scene, outgoing).points[0].x, expected); + } +} + +fn lifetime_rejected(f: &Fixture) { + assert!(validate_candidate(&f.project).is_err()); + assert!( + validation::validate(&f.project) + .iter() + .any(|issue| issue.severity == validation::Severity::Error + && issue.code == "lifeline_lifetime") + ); +} + +#[test] +fn invalid_prebirth_call_and_postdeath_reply_remain_rejected_with_defensive_scene_clipping() { + for layer in [Layer::Oa, Layer::Sa] { + for later_birth in [false, true] { + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let death = f.message(f.owner, MessageKind::Delete); + if later_birth { + f.message(f.owner, MessageKind::Create); + } + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + // Deliberately bypass Document only for defensive renderer coverage + // of rejected data. This is not an admitted editing workflow. + let scene = Scene::sequence(&f.project, f.owner, f.owner); + assert!( + scene + .activations + .iter() + .all(|a| a.rect.y + a.rect.height <= scene.rows[&death].1) + ); + assert!( + scene + .activations + .iter() + .all(|a| a.finish != Finish::Reply(reply)) + ); + assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]); + assert!(!scene.open_calls.is_empty()); + assert_visible_lifetime(&scene); + } + let mut f = Fixture::new(layer); + let call = f.message(f.owner, MessageKind::Call); + let birth = f.message(f.owner, MessageKind::Create); + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + assert_eq!(segments(&scene, call)[0].rect.y, scene.rows[&birth].1); + assert_eq!(segments(&scene, call)[0].finish, Finish::Reply(reply)); + assert_eq!( + line(&scene, call).points.last().unwrap().x, + scene.lanes[&f.receiver] + ); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn conditional_death_then_recreate_never_revives_call_but_keeps_open_sibling() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + f.message(f.owner, MessageKind::Create); + let call = f.message(f.owner, MessageKind::Call); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + let death = f.message(left, MessageKind::Delete); + let right = f.branch(fragment); + let rebirth = f.message(f.owner, MessageKind::Create); + let reply = f.reply(f.owner, call); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + let bars = segments(&scene, call); + assert!( + bars.iter() + .any(|a| a.scope == left && a.finish == Finish::Terminated(death)) + ); + assert!( + bars.iter().any(|a| a.scope == right + && a.rect.height == 26. + && a.finish == Finish::Continuation) + ); + assert!( + bars.iter() + .all(|a| a.rect.y + a.rect.height < scene.rows[&rebirth].1) + ); + assert!(!bars.iter().any(|a| a.finish == Finish::Reply(reply))); + assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]); + assert_eq!( + scene.open_calls.len(), + 3, + "Terminated, open sibling and uncertain parent cues survive" + ); + assert_visible_lifetime(&scene); + } +} + +#[test] +fn clipped_operand_preserves_original_nested_or_crossed_correlation_distinction() { + for layer in [Layer::Oa, Layer::Sa] { + for nested in [false, true] { + let mut f = Fixture::new(layer); + f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + let fragment = f.fragment(f.owner, FragmentOperator::Alternative); + let left = f.branch(fragment); + f.message(left, MessageKind::Delete); + let right = f.branch(fragment); + let outgoing = f.occurrence( + right, + "Outgoing", + OccurrenceBody::Exchange { + exchange: None, + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + ); + let r1 = f.reply(f.owner, if nested { c2 } else { c1 }); + let r2 = f.reply(f.owner, if nested { c1 } else { c2 }); + lifetime_rejected(&f); + let scene = Scene::sequence(&f.project, f.owner, f.owner); + let send = line(&scene, outgoing).points[0]; + let expected = if nested { + let inner = segments(&scene, c2) + .into_iter() + .find(|a| a.scope == right) + .unwrap(); + if scene.lanes[&f.sender] > scene.lanes[&f.receiver] { + inner.rect.x + inner.rect.width + } else { + inner.rect.x + } + } else { + scene.lanes[&f.receiver] + }; + assert_eq!(send.x, expected); + for call in [c1, c2] { + assert!(segments(&scene, call).iter().any(|a| a.scope == right + && a.rect.y <= send.y + && send.y < a.rect.y + a.rect.height)); + } + assert!(scene.rows[&r1].1 < scene.rows[&r2].1); + assert_visible_lifetime(&scene); + } + } +} + +fn with_offset(id: Id, offset: f32, f: impl FnOnce() -> T) -> T { + struct Restore(BTreeMap); + impl Drop for Restore { + fn drop(&mut self) { + TEST_ROW_OFFSETS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0)); + } + } + let _restore = + Restore(TEST_ROW_OFFSETS.with(|current| current.replace(BTreeMap::from([(id, offset)])))); + f() +} +fn snapshot(scene: &Scene) -> String { + format!( + "{:?}", + ( + &scene.lifecycle, + &scene.activations, + &scene.open_calls, + &scene.rows, + &scene.sequence_bounds, + scene + .lines + .iter() + .map(|l| (l.id, &l.points, &l.label, l.label_at, l.dashed)) + .collect::>(), + scene + .boxes + .iter() + .map(|b| (b.id, b.rect, &b.title, &b.detail, b.marker)) + .collect::>(), + scene + .slots + .iter() + .map(|s| (s.slot, s.y, &s.label)) + .collect::>(), + scene.size, + ) + ) +} + +#[test] +fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without_authoring() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + let birth = f.message(f.owner, MessageKind::Create); + let c1 = f.message(f.owner, MessageKind::Call); + let c2 = f.message(f.owner, MessageKind::Call); + f.reply(f.owner, c2); + f.reply(f.owner, c1); + let death = f.message(f.owner, MessageKind::Delete); + let document = f.document(); + let before = document.snapshot(); + let baseline = Scene::sequence(document.project(), f.owner, c1); + let moved = with_offset(birth, 60., || { + Scene::sequence(document.project(), f.owner, c1) + }); + assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.); + assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.); + for (a, b) in baseline.activations.iter().zip(&moved.activations) { + assert_eq!( + (a.call, a.receiver, a.scope, a.column, a.finish), + (b.call, b.receiver, b.scope, b.column, b.finish) + ); + assert_eq!( + b.rect, + Rectangle::new( + a.rect.position() + iced::Vector::new(0., 60.), + a.rect.size() + ) + ); + } + assert_eq!(baseline.activations.len(), moved.activations.len()); + assert_visible_lifetime(&moved); + let reparsed = language::parse(&language::format(document.project())).unwrap(); + assert_eq!(reparsed, *document.project()); + assert_eq!( + snapshot(&moved), + with_offset(birth, 60., || snapshot(&Scene::sequence( + &reparsed, f.owner, c1 + ))) + ); + assert_eq!( + snapshot(&baseline), + snapshot(&Scene::sequence(document.project(), f.owner, c1)) + ); + assert_eq!(document.project(), &before.decoded.project); + assert_eq!(document.bundle(), &before.bundle); + assert_eq!(document.revision(), 0); + assert!(!document.dirty() && !document.can_undo() && !document.can_redo()); + } +}