diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 48cfb57d..6bbc1901 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -21,6 +21,9 @@ pub(crate) mod creation; mod form_session; pub(crate) mod scenario; mod scenario_activation; +// Pure saved-geometry bridge; workspace integration is a separate checkpoint. +#[allow(dead_code)] +mod scenario_layout; mod scenario_lifecycle; pub(crate) mod source_draft; pub(crate) mod state_canvas; @@ -1172,12 +1175,15 @@ struct Scene { boxes: Vec, lines: Vec, size: Size, + layout_origin: Point, selected: Id, scale: f32, camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, + // Exact render coordinates include unassigned lanes; do not derive from cards. + render_lanes: BTreeMap, slots: Vec, rows: BTreeMap, activations: Vec, @@ -1228,16 +1234,27 @@ impl SequenceInput { } impl Scene { fn sequence(p: &Project, owner: Id, selected: Id) -> Self { + Self::sequence_geometry(p, owner, selected, None, None) + } + fn sequence_geometry( + p: &Project, + owner: Id, + selected: Id, + lane_override: Option<&BTreeMap>, + plan: Option<&scenario_layout::Plan>, + ) -> Self { let mut scene = Self { scale: 1., camera_pan: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), + layout_origin: Point::ORIGIN, selected, interaction: None, pending_placement: None, lanes: BTreeMap::new(), + render_lanes: BTreeMap::new(), slots: vec![], rows: BTreeMap::new(), activations: vec![], @@ -1308,12 +1325,32 @@ impl Scene { }) .collect(); } - let lane_x = lanes + let mut lane_x = lanes .iter() .enumerate() .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); + if let Some(values) = lane_override { + for (id, x) in values { + if let Some(lane) = lane_x.get_mut(id) { + *lane = *x; + } + } + } + scene.render_lanes = lane_x.clone(); scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); + let layout_left = if lane_override.is_some() { + lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.)) + } else { + 0. + }; + scene.layout_origin.x = layout_left; + if lane_override.is_some() { + scene.size.width = lane_x + .values() + .fold(scene.size.width, |right, x| right.max(*x + 240.)) + - layout_left; + } if scenario { scene.lanes = lane_x .iter() @@ -1329,8 +1366,26 @@ impl Scene { .collect(); } 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.); + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan); + if layout_left != 0. { + for item in &mut scene.boxes { + let body = scenario_layout::occurrence(p, item.id); + if matches!( + body, + Some( + OccurrenceBody::Branch { .. } + | OccurrenceBody::ScenarioUse { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. } + | OccurrenceBody::Exchange { .. } + ) + ) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. }))) + { + item.rect.x += layout_left; + } + } + } + scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height); if scenario { scene.lifecycle = scenario_lifecycle::render::Rendering::new( p, @@ -1341,6 +1396,9 @@ impl Scene { scene.size.height, ); scene.lifecycle.align_messages(&mut scene.lines); + if let Some(plan) = plan { + plan.align_message_anchors(&mut scene.lines); + } scene.layout_activations(p, &lane_x); } for (id, name, detail) in lanes { @@ -1371,6 +1429,7 @@ impl Scene { } scene } + #[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state. fn occurrences( &mut self, p: &Project, @@ -1379,6 +1438,7 @@ impl Scene { lanes: &BTreeMap, y: &mut f32, depth: usize, + plan: Option<&scenario_layout::Plan>, ) { let steps = p .elements @@ -1418,6 +1478,9 @@ impl Scene { } #[cfg(test)] scenario_lifecycle::render::apply_test_row_offset(*id, y); + if let Some(plan) = plan { + *y = plan.anchors[id].y; + } if ordered { let slot = scenario::Slot { parent: owner, @@ -1434,7 +1497,7 @@ impl Scene { OccurrenceBody::Fragment { operator, .. } => { let top = *y; *y += 55.; - self.occurrences(p, *id, scenario, lanes, y, depth + 1); + self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan); let rect = Rectangle::new( Point::new(18. + depth as f32 * 10., top), Size::new( @@ -1442,6 +1505,7 @@ impl Scene { (*y - top).max(95.), ), ); + let rect = plan.map_or(rect, |plan| plan.frames[id]); self.boxes.insert( 0, BoxItem { @@ -1473,7 +1537,7 @@ impl Scene { marker: Some(Kind::Occurrence), }); *y += if scenario { 90. } else { 62. }; - self.occurrences(p, *id, scenario, lanes, y, depth + 1); + self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan); } OccurrenceBody::Exchange { exchange, @@ -1501,7 +1565,7 @@ impl Scene { source.and_then(|id| lanes.get(&id)), target.and_then(|id| lanes.get(&id)), ) { - let points = if a == b { + let points = if source == target { vec![ Point::new(*a, *y), Point::new(a + 75., *y), @@ -1626,6 +1690,10 @@ impl Scene { } if ordered { self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); + if let Some(plan) = plan { + self.sequence_bounds.insert(owner, plan.scopes[&owner]); + *y = plan.scopes[&owner].1 + 42.; + } let slot = scenario::Slot { parent: owner, before: None, diff --git a/crates/oa-desktop/src/behavior/scenario_layout.rs b/crates/oa-desktop/src/behavior/scenario_layout.rs new file mode 100644 index 00000000..d1d1e238 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_layout.rs @@ -0,0 +1,817 @@ +//! Pure complete Scenario geometry. No persistence, gesture or fallback policy. +//! The only renderer is Scene::sequence_geometry; this module resolves its inputs. +use super::*; +use oa_core::{DiagramLayout, ScenarioLayout}; + +#[cfg(test)] +mod tests; + +type Candidate = BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnchorRole { + MessageSend, + MessageReceive, + Observation, + FrameStart, + OperandStart, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Anchor { + pub(super) y: f32, + role: AnchorRole, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct MessageAnchors { + send_y: f32, + receive_y: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Diagnostic { + SourceOrderCrossing { scope: Id, before: Id, after: Id }, + FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum GeometryIssue { + UnknownAnchor(Id), + LifecycleGeometryNotSupported { lifeline: Id, scope: Id }, + InvalidRepresentation(Id), + InvalidCoordinate(Id), + DuplicateCoordinate(Id), + ExpandedAddressNotSupported(Id), + CoincidentDistinctEndpoints(Id), + TypedActivationOutsideScope { call: Id, scope: Id }, + TypedActivationOutsideLifetime { call: Id, receiver: Id }, + TypedActivationCrossingNotSupported { call: Id, event: Id }, + NonFinite(Id), + CompoundMoveNotImplemented(Id), + FixedFrameHeaderWouldMove { frame: Id }, + OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id }, + LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id }, + LifetimeInversionNotSupported { create: Id, delete: Id }, +} + +#[derive(Clone, Debug)] +pub(super) struct Plan { + pub(super) anchors: BTreeMap, + messages: BTreeMap, + pub(super) frames: BTreeMap, + pub(super) scopes: BTreeMap, + pub(super) height: f32, + diagnostics: Vec, +} + +pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> { + match p.elements.get(&id)?.shared()? { + SharedBody::Occurrence { body, .. } => Some(body), + _ => None, + } +} + +fn role(body: &OccurrenceBody) -> AnchorRole { + match body { + OccurrenceBody::Exchange { + message_kind: MessageKind::Create | MessageKind::Delete, + .. + } => AnchorRole::MessageReceive, + OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend, + OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart, + OccurrenceBody::Branch { .. } => AnchorRole::OperandStart, + _ => AnchorRole::Observation, + } +} + +fn inside(p: &Project, id: Id, owner: Id) -> bool { + let mut current = p.elements.get(&id).and_then(|e| e.parent); + for _ in 0..p.elements.len() { + match current { + Some(parent) if parent == owner => return true, + Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent), + None => return false, + } + } + false +} + +fn depth(p: &Project, id: Id) -> usize { + let mut current = p.elements.get(&id).and_then(|e| e.parent); + let mut result = 0; + while let Some(parent) = current { + result += 1; + if result > p.elements.len() { + break; + } + current = p.elements.get(&parent).and_then(|e| e.parent); + } + result +} + +pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle { + let x = a.x.min(b.x); + let y = a.y.min(b.y); + Rectangle::new( + Point::new(x, y), + Size::new( + (a.x + a.width).max(b.x + b.width) - x, + (a.y + a.height).max(b.y + b.height) - y, + ), + ) +} + +/// Actual named shapes/message paths supply footprint measurements; activation +/// bars are intentionally excluded because current Scene derives them later. +fn footprints( + scene: &Scene, + anchors: &BTreeMap, + seed: &BTreeMap, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) { + let Some(anchor) = anchors.get(&item.id) else { + continue; + }; + let mut rect = item.rect; + rect.y += anchor.y - seed[&item.id].y; + out.insert(item.id, rect); + } + for line in &scene.lines { + let Some(anchor) = anchors.get(&line.id) else { + continue; + }; + let delta = anchor.y - seed[&line.id].y; + let mut rect = line.label_bounds(); + rect.y += delta; + for point in &line.points { + rect = union( + rect, + Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO), + ); + } + out.entry(line.id) + .and_modify(|old| *old = union(*old, rect)) + .or_insert(rect); + } + out +} + +fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap) { + let mut current = p.elements.get(&id).and_then(|e| e.parent); + for _ in 0..p.elements.len() { + let Some(parent) = current else { + break; + }; + covered + .entry(parent) + .and_modify(|old| *old = union(*old, rect)) + .or_insert(rect); + current = p.elements.get(&parent).and_then(|e| e.parent); + } +} + +fn coverage(p: &Project, footprints: &BTreeMap) -> BTreeMap { + let mut covered = BTreeMap::new(); + for (&id, &rect) in footprints { + include_ancestors(p, id, rect, &mut covered); + } + covered +} + +impl Plan { + /// Authored endpoint values are primary inputs, not deltas reconstructed from + /// rounded rows. Lifecycle alignment owns x and marker attachment; this final + /// y alignment precedes typed activation derivation and preserves send bits. + pub(super) fn align_message_anchors(&self, lines: &mut [Line]) { + for line in lines { + let Some(message) = self.messages.get(&line.id) else { + continue; + }; + let previous_send = line.points.first().map_or(message.send_y, |point| point.y); + line.label_at.y += message.send_y - previous_send; + let last = line.points.len().saturating_sub(1); + for (index, point) in line.points.iter_mut().enumerate() { + point.y = if index == last || (last == 3 && index == 2) { + message.receive_y + } else { + message.send_y + }; + } + } + } + + fn build( + p: &Project, + owner: Id, + baseline: &Scene, + desired: &Candidate, + ) -> Result { + let mut seed: BTreeMap<_, _> = baseline + .rows + .iter() + .map(|(id, (_, y))| { + ( + *id, + Anchor { + y: *y, + role: role(occurrence(p, *id).expect("Scene occurrence")), + }, + ) + }) + .collect(); + // Operand headers are real Scene boxes, but are not entries in rows. + for item in &baseline.boxes { + if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) { + seed.insert( + item.id, + Anchor { + y: item.rect.y, + role: AnchorRole::OperandStart, + }, + ); + } + } + let mut anchors = seed.clone(); + for (&id, &y) in desired { + let anchor = anchors + .get_mut(&id) + .ok_or(GeometryIssue::UnknownAnchor(id))?; + if !y.is_finite() { + return Err(GeometryIssue::NonFinite(id)); + } + if matches!( + anchor.role, + AnchorRole::FrameStart | AnchorRole::OperandStart + ) && y != anchor.y + { + return Err(GeometryIssue::CompoundMoveNotImplemented(id)); + } + anchor.y = y; + } + let mut messages = BTreeMap::new(); + for line in &baseline.lines { + if let (Some(anchor), Some(first), Some(last)) = ( + anchors.get(&line.id), + line.points.first(), + line.points.last(), + ) { + let message = if desired.contains_key(&line.id) { + let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange { + sender: Some(sender), receiver, message_kind, .. + }) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender)); + let lifecycle = matches!( + occurrence(p, line.id), + Some(OccurrenceBody::Exchange { + message_kind: MessageKind::Create | MessageKind::Delete, + .. + }) + ); + if self_message && lifecycle { + MessageAnchors { + send_y: anchor.y - 24., + receive_y: anchor.y, + } + } else { + MessageAnchors { + send_y: anchor.y, + receive_y: if self_message { + anchor.y + 24. + } else { + anchor.y + }, + } + } + } else { + MessageAnchors { + send_y: first.y, + receive_y: last.y, + } + }; + messages.insert(line.id, message); + } + } + // Track actual semantic lifecycle order per receiver and scope. In particular, + // a later recreation must not be compared with an earlier deletion. + for element in p + .elements + .values() + .filter(|e| e.id == owner || inside(p, e.id, owner)) + { + let Some(steps) = element.steps() else { + continue; + }; + let mut last = BTreeMap::new(); + for id in steps { + if let ( + Some(message), + Some(OccurrenceBody::Exchange { + receiver: Some(lane), + message_kind: MessageKind::Create | MessageKind::Delete, + .. + }), + ) = (messages.get(id), occurrence(p, *id)) + { + if let Some((previous, previous_y)) = + last.insert(*lane, (*id, message.receive_y)) + { + if previous_y > message.receive_y { + return Err(GeometryIssue::LifetimeInversionNotSupported { + create: previous, + delete: *id, + }); + } + } + } + } + } + let old_footprints = footprints(baseline, &seed, &seed); + let mut drawn = footprints(baseline, &anchors, &seed); + let old_coverage = coverage(p, &old_footprints); + let mut new_coverage = coverage(p, &drawn); + let mut frames = BTreeMap::new(); + let mut containers: Vec<_> = seed + .keys() + .copied() + .filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. }))) + .collect(); + containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id))); + for id in containers { + let original = old_footprints[&id]; + let mut rect = original; + if let Some(covered) = new_coverage.get(&id).copied() { + // Preserve automatic extents and their existing descendant padding. + // Upward envelope growth would move the painted frame header + // while its fixed FrameStart/row/slot remained at the seed. + // Reject that unsupported compound change, not mere overlap. + if covered.y < rect.y { + return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id }); + } + if covered.y + covered.height > rect.y + rect.height { + let old_covered = old_coverage[&id]; + let padding = + (original.y + original.height - old_covered.y - old_covered.height).max(0.); + rect.height = covered.y + covered.height + padding - rect.y; + } + } + frames.insert(id, rect); + drawn.insert(id, rect); + include_ancestors(p, id, rect, &mut new_coverage); + } + let mut scopes = baseline.sequence_bounds.clone(); + for (&scope, bounds) in &mut scopes { + if let (Some(old), Some(new)) = ( + old_coverage.get(&scope).copied(), + new_coverage.get(&scope).copied(), + ) { + let top_padding = (old.y - bounds.0).max(0.); + let bottom_padding = (bounds.1 - old.y - old.height).max(0.); + if new.y < bounds.0 { + bounds.0 = new.y - top_padding; + } + bounds.1 = bounds.1.max(new.y + new.height + bottom_padding); + } + } + let tail = baseline.size.height - baseline.sequence_bounds[&owner].1; + let height = baseline.size.height.max(scopes[&owner].1 + tail); + for (&operand, &(start, end)) in &scopes { + if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) { + continue; + } + let fragment = p.elements[&operand].parent.expect("operand fragment"); + let caller = p.elements[&fragment] + .parent + .expect("fragment caller sequence"); + let enclosing_frame = frames[&fragment]; + let (caller_start, caller_end) = if caller == owner { + (85., height - 20.) + } else { + let (a, b) = scopes[&caller]; + (a, if a == b { a + 26. } else { b }) + }; + let enclosing_start = anchors[&fragment].y.max(caller_start); + let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end); + let effective_end = if start == end { start + 26. } else { end }; + // Actual lifecycle traversal cannot enter an operand that escapes + // this enclosing interval. This guards bridge feasibility only; + // it is not a generic event-spacing or sibling-ordering policy. + if start < enclosing_start || effective_end > enclosing_end { + return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval { + operand, + fragment, + }); + } + } + for (&id, message) in &messages { + if matches!( + occurrence(p, id), + Some(OccurrenceBody::Exchange { + message_kind: MessageKind::Create | MessageKind::Delete, + .. + }) + ) { + let scope = p.elements[&id].parent.expect("lifecycle sequence owner"); + // Match Rendering::new's effective scope, not the insertion + // scope: it overrides the root span to 85..height-20 and gives + // empty non-root scopes the existing 26px lifetime allowance. + let (start, end) = if scope == owner { + (85., height - 20.) + } else if let Some(&(start, end)) = scopes.get(&scope) { + (start, if start == end { start + 26. } else { end }) + } else { + return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope { + occurrence: id, + scope, + }); + }; + if message.receive_y < start || message.receive_y > end { + return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope { + occurrence: id, + scope, + }); + } + } + } + let mut diagnostics = Vec::new(); + for element in p + .elements + .values() + .filter(|e| e.id == owner || inside(p, e.id, owner)) + { + if let Some(steps) = element.steps() { + for pair in steps.windows(2) { + if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) { + if a.y > b.y { + diagnostics.push(Diagnostic::SourceOrderCrossing { + scope: element.id, + before: pair[0], + after: pair[1], + }); + } + if let Some(frame) = frames.get(&pair[0]) { + if frame.y + frame.height > b.y { + diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { + frame: pair[0], + following: pair[1], + }); + } + } + } + } + } + } + Ok(Self { + anchors, + messages, + frames, + scopes, + height, + diagnostics, + }) + } +} + +/// An appearance identity, never inferred from selection or from the owner alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Representation { + Derived { owner: Id }, + Saved { diagram: Id }, +} + +/// Complete immutable geometry produced from one Project borrow. Scene is retained +/// with its plan so a caller cannot accidentally render this plan against another +/// Project or representation. Document/revision freshness belongs to that caller. +pub(super) struct ResolvedScenarioPlan { + pub(super) representation: Representation, + pub(super) owner: Id, + pub(super) lanes: BTreeMap, + pub(super) plan: Plan, + pub(super) latent_messages: Vec, + pub(super) bounds: Rectangle, + pub(super) scene: Scene, +} + +pub(super) fn resolve( + p: &Project, + representation: Representation, + selected: Id, +) -> Result> { + let (owner, layout) = match representation { + Representation::Derived { owner } => (owner, None), + Representation::Saved { diagram } => { + let view = p + .diagrams + .get(&diagram) + .ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?; + let owner = view + .projection + .scenario() + .ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?; + if p.elements + .get(&owner) + .is_none_or(|e| e.layer() != view.layer) + { + return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]); + } + let layout = view + .layout + .as_ref() + .map(|DiagramLayout::Scenario(layout)| layout); + (owner, layout) + } + }; + if p.elements + .get(&owner) + .is_none_or(|e| e.kind() != Kind::Scenario) + { + return Err(vec![GeometryIssue::InvalidRepresentation(owner)]); + } + let empty = ScenarioLayout::default(); + let layout = layout.unwrap_or(&empty); + let mut lanes = BTreeMap::new(); + let mut issues = Vec::new(); + for lane in &layout.lanes { + if p.elements + .get(&lane.lifeline) + .is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner)) + { + issues.push(GeometryIssue::UnknownAnchor(lane.lifeline)); + } + if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE { + issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline)); + } + if lanes.insert(lane.lifeline, lane.x).is_some() { + issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline)); + } + } + if !issues.is_empty() { + return Err(issues); + } + // The actual automatic traversal runs first, with authored lane centers already + // applied. It supplies every untouched anchor, shape, insertion scope and label. + let baseline = Scene::sequence_geometry( + p, + owner, + selected, + (!lanes.is_empty()).then_some(&lanes), + None, + ); + let mut desired = BTreeMap::new(); + let mut seen = std::collections::BTreeSet::new(); + let mut latent_messages = Vec::new(); + let line_ids: std::collections::BTreeSet<_> = + baseline.lines.iter().map(|line| line.id).collect(); + for message in &layout.messages { + let id = message.address.occurrence; + if !seen.insert(id) { + issues.push(GeometryIssue::DuplicateCoordinate(id)); + } + if !message.address.references.is_empty() { + issues.push(GeometryIssue::ExpandedAddressNotSupported(id)); + } + if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE { + issues.push(GeometryIssue::InvalidCoordinate(id)); + } + let Some(OccurrenceBody::Exchange { + sender, + receiver, + message_kind, + .. + }) = occurrence(p, id) + else { + issues.push(GeometryIssue::UnknownAnchor(id)); + continue; + }; + if !baseline.rows.contains_key(&id) { + issues.push(GeometryIssue::UnknownAnchor(id)); + continue; + } + if !line_ids.contains(&id) { + latent_messages.push(id); + continue; + } + // Legacy lifecycle rows denote receipt, whereas the saved field denotes send. + let row = if sender.is_some() + && sender == receiver + && matches!(message_kind, MessageKind::Create | MessageKind::Delete) + { + message.send_y + 24. + } else { + message.send_y + }; + desired.insert(id, row); + } + let actual_lanes = &baseline.render_lanes; + // A coincident pair is not a self message. Do not invent an arrow or lane order. + for line in &baseline.lines { + if let Some(OccurrenceBody::Exchange { + sender: Some(sender), + receiver, + message_kind, + .. + }) = occurrence(p, line.id) + { + let target = if matches!(message_kind, MessageKind::Timer { .. }) { + Some(*sender) + } else { + *receiver + }; + if target.is_some_and(|target| target != *sender) + && target + .is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target)) + { + issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id)); + } + } + } + if !issues.is_empty() { + return Err(issues); + } + let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?; + // Keep the schema's exact send value; send+24 is performed once. In particular, + // a self Create/Delete receive row must never round-trip back into saved send. + for message in &layout.messages { + let id = message.address.occurrence; + if let Some(anchors) = plan.messages.get_mut(&id) { + let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange { + sender: Some(sender), receiver, message_kind, .. + }) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender)); + *anchors = MessageAnchors { + send_y: message.send_y, + receive_y: if self_message { + message.send_y + 24. + } else { + message.send_y + }, + }; + } + } + // Typed bars require a positive receiver interval. This is a narrow renderer + // limitation, not a global ban on reordered/negative asynchronous anchors. + for (&id, message) in &plan.messages { + match occurrence(p, id) { + Some(OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + .. + }) => { + let scope = baseline.rows[&id].0; + let (start, end) = if scope == owner { + (85., plan.height - 20.) + } else { + plan.scopes[&scope] + }; + if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end) + { + issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope }); + } + } + Some(OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(call), + .. + }) => { + if let Some(start) = plan.messages.get(call) { + if (desired.contains_key(&id) || desired.contains_key(call)) + && message.send_y <= start.receive_y + { + issues.push(GeometryIssue::TypedActivationCrossingNotSupported { + call: *call, + event: id, + }); + } + } + } + _ => {} + } + } + if !issues.is_empty() { + return Err(issues); + } + for segment in &baseline.activations { + if baseline + .rows + .get(&segment.call) + .is_none_or(|(scope, _)| *scope != segment.scope) + { + continue; + } + let Some(start) = plan.messages.get(&segment.call) else { + continue; + }; + let event = match segment.finish { + scenario_activation::Finish::Reply(event) + | scenario_activation::Finish::Terminated(event) => Some(event), + _ => None, + }; + if let Some(event) = event { + if (desired.contains_key(&event) || desired.contains_key(&segment.call)) + && plan.anchors[&event].y <= start.receive_y + { + issues.push(GeometryIssue::TypedActivationCrossingNotSupported { + call: segment.call, + event, + }); + } + } + } + if !issues.is_empty() { + return Err(issues); + } + let scene = Scene::sequence_geometry( + p, + owner, + selected, + (!lanes.is_empty()).then_some(&lanes), + Some(&plan), + ); + // Compare the actual projector, whose cursor rules include sibling scopes and + // lifecycle/fragment events. Retain existing incomplete-model uncertainty; + // reject only newly missing geometry introduced by this presentation. + let previous_missing = baseline.lifecycle.missing_geometry(); + for (lifeline, scope) in scene + .lifecycle + .missing_geometry() + .difference(&previous_missing) + { + issues.push(GeometryIssue::LifecycleGeometryNotSupported { + lifeline: *lifeline, + scope: *scope, + }); + } + if !issues.is_empty() { + return Err(issues); + } + for segment in &baseline.activations { + let Some(seed) = baseline + .lines + .iter() + .find(|line| line.id == segment.call) + .and_then(|line| line.points.last()) + else { + continue; + }; + if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope { + continue; + } + let receive = plan.messages[&segment.call].receive_y; + if !desired.is_empty() + && !scene.activations.iter().any(|current| { + current.call == segment.call + && current.scope == segment.scope + && current.rect.y == receive + }) + { + issues.push(GeometryIssue::TypedActivationOutsideLifetime { + call: segment.call, + receiver: segment.receiver, + }); + } + } + if !issues.is_empty() { + return Err(issues); + } + let bounds = structural_bounds(&scene); + let all_lanes = scene.render_lanes.clone(); + Ok(ResolvedScenarioPlan { + representation, + owner, + lanes: all_lanes, + plan, + latent_messages, + bounds, + scene, + }) +} + +fn structural_bounds(scene: &Scene) -> Rectangle { + let mut bounds = Rectangle::new(scene.layout_origin, scene.size); + for item in &scene.boxes { + bounds = union(bounds, item.rect); + } + for line in &scene.lines { + if !line.label.is_empty() { + bounds = union(bounds, line.label_bounds()); + } + for point in &line.points { + // Arrow wings extend beyond the centerline in either direction. + bounds = union( + bounds, + Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)), + ); + } + } + for segment in &scene.activations { + bounds = union(bounds, segment.rect); + } + for point in &scene.open_calls { + bounds = union( + bounds, + Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)), + ); + } + if let Some(lifetime) = scene.lifecycle.bounds() { + bounds = union(bounds, lifetime); + } + bounds +} diff --git a/crates/oa-desktop/src/behavior/scenario_layout/tests.rs b/crates/oa-desktop/src/behavior/scenario_layout/tests.rs new file mode 100644 index 00000000..d5ea1b28 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_layout/tests.rs @@ -0,0 +1,1222 @@ +use super::*; +use crate::document::Document; +use oa_core::{ + language, + source_project::{ProjectLimits, SourceBundle}, + validation, +}; + +fn id(p: &Project, alias: &str) -> Id { + p.identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +fn fixture(lifetime: bool) -> (Document, Id) { + let (name, input, owner) = if lifetime { + ("lifetime.arcadia", LIFETIME, "lifetime_conversation") + } else { + ("fixture.arcadia", FIXTURE, "conversation") + }; + let p = language::parse(input).expect("existing real Arcadia fixture"); + let errors: Vec<_> = validation::validate(&p) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + let bundle = SourceBundle { + entry: name.into(), + files: BTreeMap::from([(name.into(), input.into())]), + }; + let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); + assert_eq!(document.project(), &p); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); + let owner = id(&p, owner); + (document, owner) +} + +fn passive(document: &Document, before: &Project, source: &SourceBundle) { + assert_eq!(document.project(), before); + assert_eq!(document.bundle(), source); + assert!(!document.dirty()); + assert!(!document.can_undo() && !document.can_redo()); +} + +fn row(scene: &Scene, id: Id) -> f32 { + scene.rows[&id].1 +} +fn line(scene: &Scene, id: Id) -> &Line { + assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1); + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn named_box(scene: &Scene, id: Id) -> &BoxItem { + scene + .boxes + .iter() + .find(|item| item.id == id && !item.title.is_empty()) + .unwrap() +} + +fn same_scene(a: &Scene, b: &Scene) { + assert!( + !a.activations.is_empty(), + "the complete typed comparison must be exercised" + ); + assert_eq!(a.activations, b.activations); + assert_eq!(a.rows, b.rows); + assert_eq!(a.sequence_bounds, b.sequence_bounds); + assert_eq!(a.size, b.size); + assert_eq!(a.layout_origin, b.layout_origin); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.render_lanes, b.render_lanes); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let boxes = |s: &Scene| { + s.boxes + .iter() + .map(|b| (b.id, b.rect, b.title.clone(), b.detail.clone(), b.marker)) + .collect::>() + }; + let lines = |s: &Scene| { + s.lines + .iter() + .map(|l| { + ( + l.id, + l.points.clone(), + l.label_at, + l.label.clone(), + l.dashed, + ) + }) + .collect::>() + }; + let slots = |s: &Scene| { + s.slots + .iter() + .map(|slot| { + ( + slot.slot.parent, + slot.slot.before, + slot.y, + slot.label.clone(), + ) + }) + .collect::>() + }; + assert_eq!(boxes(a), boxes(b)); + assert_eq!(lines(a), lines(b)); + assert_eq!(slots(a), slots(b)); +} + +const FIXTURE: &str = r###"arcadia 7; +project layout_probe "Behavior layout Scene probe" id "10000000-0000-4000-8000-000000000001" { + oa_entity sender "Sender" id "10000000-0000-4000-8000-000000000002" {} + oa_entity receiver "Receiver" id "10000000-0000-4000-8000-000000000003" {} + oa_activity inspect "Inspect" id "10000000-0000-4000-8000-000000000004" {} + allocation inspect_assignment "Receiver performs inspection" id "10000000-0000-4000-8000-000000000005" { + source @receiver; target @inspect; + } + communication_mean channel "Shared channel" id "10000000-0000-4000-8000-000000000006" { + source @sender; target @receiver; oriented false; + } + oa_scenario conversation "Conversation" id "10000000-0000-4000-8000-000000000007" { + viewpoint ParticipantInteraction; + steps [@first, @second, @fragment, @self_call, @self_reply]; + } + oa_lifeline left "Sender role" id "10000000-0000-4000-8000-000000000008" { parent @conversation; represented @sender; } + oa_lifeline right "Receiver role" id "10000000-0000-4000-8000-000000000009" { parent @conversation; represented @receiver; } + oa_occurrence first "First delivery" id "10000000-0000-4000-8000-00000000000a" { + parent @conversation; + body { Exchange { + exchange @channel; sender @left; receiver @right; + message_kind Asynchronous; + }; }; + } + oa_occurrence second "Second delivery" id "10000000-0000-4000-8000-00000000000b" { + parent @conversation; + body { Exchange { + exchange @channel; sender @left; receiver @right; + message_kind Asynchronous; + }; }; + } + oa_occurrence fragment "Optional inspection" id "10000000-0000-4000-8000-00000000000c" { + parent @conversation; + body { Fragment { operator Optional; branches [@operand]; }; }; + } + oa_occurrence operand "When requested" id "10000000-0000-4000-8000-00000000000d" { + parent @fragment; + body { Branch { guard "requested"; occurrences [@nested]; }; }; + } + oa_occurrence nested "Inspection occurrence" id "10000000-0000-4000-8000-00000000000e" { + parent @operand; + body { Action { action @inspect; lifeline @right; }; }; + } + // Self calls intentionally have no global self-edge: such a relation is + // invalid in the current metamodel. Their unassigned exchange is a known + // incomplete-model warning, while explicit lifelines make Scene drawable. + oa_occurrence self_call "Local call" id "10000000-0000-4000-8000-00000000000f" { + parent @conversation; + body { Exchange { + exchange none; sender @right; receiver @right; message_kind Call; + }; }; + } + oa_occurrence self_reply "Local reply" id "10000000-0000-4000-8000-000000000010" { + parent @conversation; + body { Exchange { + exchange none; sender @right; receiver @right; + message_kind Reply; reply_to @self_call; + }; }; + } +} +"###; + +const LIFETIME: &str = r###"arcadia 7; +// Separate lifetime probe: preserve this authored note and all stable IDs. +project lifetime_probe "Delayed receiver lifetime" id "20000000-0000-4000-8000-000000000001" { + oa_activity prepare "Prepare request" id "20000000-0000-4000-8000-000000000002" {} + oa_activity serve "Serve request" id "20000000-0000-4000-8000-000000000003" {} + interaction service "Service request" id "20000000-0000-4000-8000-000000000004" { + source @prepare; target @serve; + } + oa_scenario lifetime_conversation "Receiver created on demand" id "20000000-0000-4000-8000-000000000005" { + viewpoint OperationalActivity; + steps [@before_birth, @create_receiver, @service_call, @delete_receiver]; + } + oa_lifeline requester "Requester" id "20000000-0000-4000-8000-000000000006" { + parent @lifetime_conversation; represented @prepare; + } + oa_lifeline provider "Provider" id "20000000-0000-4000-8000-000000000007" { + parent @lifetime_conversation; represented @serve; + } + oa_occurrence before_birth "Prepare before receiver exists" id "20000000-0000-4000-8000-000000000008" { + parent @lifetime_conversation; + body { Action { action @prepare; lifeline @requester; }; }; + } + oa_occurrence create_receiver "Create receiver" id "20000000-0000-4000-8000-000000000009" { + parent @lifetime_conversation; + body { Exchange { + exchange @service; sender @requester; receiver @provider; + message_kind Create; + }; }; + } + oa_occurrence service_call "Call receiver" id "20000000-0000-4000-8000-00000000000a" { + parent @lifetime_conversation; + body { Exchange { + exchange @service; sender @requester; receiver @provider; + message_kind Call; + }; }; + } + // Death ends the visible activation but deliberately does not invent a Reply. + oa_occurrence delete_receiver "Delete receiver" id "20000000-0000-4000-8000-00000000000b" { + parent @lifetime_conversation; + body { Exchange { + exchange @service; sender @requester; receiver @provider; + message_kind Delete; + }; }; + } +} +"###; + +fn saved(p: &mut Project, owner: Id, layout: Option) -> Id { + let view = Id::new_v4(); + p.diagrams.insert( + view, + oa_core::Diagram { + id: view, + alias: format!("view_{}", p.diagrams.len()), + name: "Saved conversation".into(), + description: String::new(), + layer: p.elements[&owner].layer(), + projection: oa_core::ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: BTreeMap::new(), + routes: vec![], + layout: layout.map(DiagramLayout::Scenario), + }, + ); + view +} +fn positions(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout { + ScenarioLayout { + lanes: lanes + .iter() + .map(|(id, x)| oa_core::ScenarioLanePosition { + lifeline: *id, + x: *x, + }) + .collect(), + messages: messages + .iter() + .map(|(id, y)| oa_core::ScenarioMessagePosition { + address: oa_core::OccurrenceEndpoint { + references: vec![], + occurrence: *id, + }, + send_y: *y, + }) + .collect(), + } +} +fn stored(p: Project) -> Document { + let errors: Vec<_> = validation::validate(&p) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + let text = format!( + "// Preserve source and identities through passive resolution.\n{}", + language::format(&p) + ); + let document = Document::from_bundle( + SourceBundle { + entry: "saved.arcadia".into(), + files: BTreeMap::from([("saved.arcadia".into(), text)]), + }, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(document.project(), &p); + document +} +fn result(p: &Project, view: Id) -> ResolvedScenarioPlan { + resolve(p, Representation::Saved { diagram: view }, view) + .unwrap_or_else(|issues| panic!("{issues:?}")) +} +fn failures(p: &Project, view: Id) -> Vec { + match resolve(p, Representation::Saved { diagram: view }, view) { + Ok(_) => panic!("expected explicit unsupported geometry"), + Err(issues) => issues, + } +} +fn contains(outer: Rectangle, inner: Rectangle) -> bool { + outer.x <= inner.x + && outer.y <= inner.y + && outer.x + outer.width >= inner.x + inner.width + && outer.y + outer.height >= inner.y + inner.height +} + +#[test] +fn absent_empty_and_explicit_baseline_saved_values_preserve_complete_scene_and_source() { + for lifetime in [false, true] { + let (document, owner) = fixture(lifetime); + let mut p = document.project().clone(); + let baseline = Scene::sequence(&p, owner, owner); + let messages: Vec<_> = baseline + .lines + .iter() + .map(|line| (line.id, line.points[0].y)) + .collect(); + let lane_values: Vec<_> = baseline.lanes.iter().map(|(id, x)| (*id, *x)).collect(); + let views = [ + saved(&mut p, owner, None), + saved(&mut p, owner, Some(ScenarioLayout::default())), + saved(&mut p, owner, Some(positions(&messages, &lane_values))), + ]; + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + for view in views { + let resolved = result(p, view); + assert_eq!( + resolved.representation, + Representation::Saved { diagram: view } + ); + assert_eq!(resolved.owner, owner); + same_scene(&baseline, &resolved.scene); + } + let derived = resolve(p, Representation::Derived { owner }, owner) + .unwrap_or_else(|e| panic!("{e:?}")); + same_scene(&baseline, &derived.scene); + passive(&document, &before, &source); + } +} + +#[test] +fn independent_saved_appearances_of_reused_exchange_have_exact_anchors_and_no_ambient_state() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + let second = id(&p, "second"); + let a = saved(&mut p, owner, Some(positions(&[(second, 200.)], &[]))); + let b = saved(&mut p, owner, Some(positions(&[(first, 1000.)], &[]))); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let a1 = result(p, a); + let b1 = result(p, b); + let a2 = result(p, a); + assert_eq!( + (row(&a1.scene, first), row(&a1.scene, second)), + (148., 200.) + ); + assert_eq!( + (row(&b1.scene, first), row(&b1.scene, second)), + (1000., 240.) + ); + assert_eq!( + line(&a1.scene, second) + .points + .iter() + .map(|p| p.y) + .collect::>(), + vec![200., 200.] + ); + assert!( + b1.plan + .diagnostics + .contains(&Diagnostic::SourceOrderCrossing { + scope: owner, + before: first, + after: second + }) + ); + assert!(b1.bounds.y + b1.bounds.height >= 1008.); + same_scene(&a1.scene, &a2.scene); + same_scene( + &Scene::sequence(p, owner, owner), + &resolve(p, Representation::Derived { owner }, owner) + .unwrap_or_else(|e| panic!("{e:?}")) + .scene, + ); + passive(&document, &before, &source); +} + +#[test] +fn authored_lanes_precede_headers_arrows_lifetimes_and_complete_bounds() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let requester = id(&p, "requester"); + let provider = id(&p, "provider"); + let create = id(&p, "create_receiver"); + let call = id(&p, "service_call"); + let view = saved( + &mut p, + owner, + Some(positions(&[], &[(requester, 900.), (provider, -500.)])), + ); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + let scene = &resolved.scene; + assert_eq!(named_box(scene, provider).rect.x, -602.); + assert_eq!( + line(scene, create).points, + vec![Point::new(900., 243.), Point::new(-430., 243.)] + ); + assert_eq!( + line(scene, call).points, + vec![Point::new(900., 335.), Point::new(-494., 335.)] + ); + assert_eq!( + scene.lifecycle.marker_at(Point::new(-500., 243.)), + Some(create) + ); + assert_eq!(scene.activations[0].rect.x, -506.); + for item in &scene.boxes { + assert!(contains(resolved.bounds, item.rect)); + } + for bar in &scene.activations { + assert!(contains(resolved.bounds, bar.rect)); + } + assert_eq!(resolved.scene.layout_origin.x, -660.); + assert_eq!(resolved.bounds.x, -660.); + assert_eq!(resolved.bounds.x + resolved.bounds.width, 1140.); + passive(&document, &before, &source); +} + +#[test] +fn same_x_distinct_lifelines_are_not_self_messages_in_either_bridge() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let requester = id(&p, "requester"); + let provider = id(&p, "provider"); + let lanes = BTreeMap::from([(requester, 160.), (provider, 160.)]); + let baseline = Scene::sequence_geometry(&p, owner, owner, Some(&lanes), None); + assert_eq!(line(&baseline, id(&p, "create_receiver")).points.len(), 2); + assert_eq!(line(&baseline, id(&p, "create_receiver")).points[0].y, 243.); + let view = saved( + &mut p, + owner, + Some(positions(&[], &[(requester, 160.), (provider, 160.)])), + ); + assert!( + failures(&p, view) + .iter() + .any(|e| matches!(e, GeometryIssue::CoincidentDistinctEndpoints(_))) + ); +} + +#[test] +fn self_call_receive_and_reply_send_drive_the_full_typed_execution() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let call = id(&p, "self_call"); + let reply = id(&p, "self_reply"); + let receiver = id(&p, "right"); + let view = saved(&mut p, owner, Some(positions(&[(call, 610.)], &[]))); + let resolved = result(&p, view); + let x = resolved.lanes[&receiver]; + assert_eq!( + line(&resolved.scene, call).points, + vec![ + Point::new(x, 610.), + Point::new(x + 66., 610.), + Point::new(x + 66., 634.), + Point::new(x + 6., 634.) + ] + ); + assert_eq!( + resolved.scene.activations, + vec![scenario_activation::Segment { + call, + receiver, + scope: owner, + rect: Rectangle::new(Point::new(x - 6., 634.), Size::new(12., 94.)), + column: 0, + finish: scenario_activation::Finish::Reply(reply), + }] + ); + assert_eq!(line(&resolved.scene, reply).points[0].y, 728.); + assert_eq!( + resolved.plan.messages[&call], + MessageAnchors { + send_y: 610., + receive_y: 634. + } + ); +} + +#[test] +fn moved_death_clips_actual_bar_and_preserves_unanswered_cue() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let call = id(&p, "service_call"); + let delete = id(&p, "delete_receiver"); + let provider = id(&p, "provider"); + let view = saved(&mut p, owner, Some(positions(&[(delete, 380.)], &[]))); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + let x = resolved.lanes[&provider]; + assert_eq!( + resolved.scene.activations, + vec![scenario_activation::Segment { + call, + receiver: provider, + scope: owner, + rect: Rectangle::new(Point::new(x - 6., 335.), Size::new(12., 45.)), + column: 0, + finish: scenario_activation::Finish::Terminated(delete), + }] + ); + assert_eq!(resolved.scene.open_calls, vec![Point::new(x + 10., 373.)]); + assert_eq!( + resolved + .scene + .lifecycle + .alive_spans(provider, owner, 0., 1000.) + .iter() + .map(|s| (s.start, s.end)) + .collect::>(), + vec![(243., 380.)] + ); + assert_eq!( + resolved.scene.lifecycle.marker_at(Point::new(x, 380.)), + Some(delete) + ); + passive(&document, &before, &source); +} + +#[test] +fn explicit_root_scope_failures_do_not_clamp_calls_or_lifecycle_but_async_can_be_negative() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let create = id(&p, "create_receiver"); + let call = id(&p, "service_call"); + for y in [50., 84.] { + let view = saved(&mut p, owner, Some(positions(&[(create, y)], &[]))); + assert_eq!( + failures(&p, view), + vec![GeometryIssue::LifecycleAnchorOutsideRenderScope { + occurrence: create, + scope: owner + }] + ); + } + let view = saved(&mut p, owner, Some(positions(&[(create, 85.)], &[]))); + assert_eq!(result(&p, view).scene.rows[&create].1, 85.); + let view = saved(&mut p, owner, Some(positions(&[(call, -10.)], &[]))); + assert_eq!( + failures(&p, view), + vec![GeometryIssue::TypedActivationOutsideScope { call, scope: owner }] + ); + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + let view = saved(&mut p, owner, Some(positions(&[(first, -10.)], &[]))); + let resolved = result(&p, view); + assert_eq!(line(&resolved.scene, first).points[0].y, -10.); + assert!(resolved.bounds.y <= -36.); +} + +#[test] +fn reply_crossing_is_a_specific_typed_failure() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let call = id(&p, "self_call"); + let reply = id(&p, "self_reply"); + let view = saved(&mut p, owner, Some(positions(&[(reply, 600.)], &[]))); + assert_eq!( + failures(&p, view), + vec![GeometryIssue::TypedActivationCrossingNotSupported { call, event: reply }] + ); +} + +#[test] +fn complete_scope_plan_rejects_escape_preserves_overlap_and_keeps_headers_and_followers() { + // Internal complete-plan oracle: observations are automatic in the persisted + // first slice, but its dependent scope math must retain the proven boundary. + let (document, owner) = fixture(false); + let p = document.project(); + let baseline = Scene::sequence(p, owner, owner); + let nested = id(p, "nested"); + let frame = id(p, "fragment"); + let operand = id(p, "operand"); + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(nested, 353.)])).unwrap_err(), + GeometryIssue::OperandScopeOutsideEnclosingInterval { + operand, + fragment: frame + } + ); + let plan = Plan::build(p, owner, &baseline, &Candidate::from([(nested, 380.)])).unwrap(); + let scene = Scene::sequence_geometry(p, owner, owner, None, Some(&plan)); + assert_eq!(named_box(&scene, nested).rect.y, 360.); + assert_eq!(scene.sequence_bounds[&operand], (338., 530.)); + assert_eq!( + named_box(&scene, frame).rect, + named_box(&baseline, frame).rect + ); + assert_eq!( + scene + .slots + .iter() + .find(|slot| slot.slot.before == Some(frame)) + .unwrap() + .y, + 290. + ); + assert!(!scene.activations.is_empty()); + assert_eq!(scene.activations, baseline.activations); + for (id, row) in &baseline.rows { + if *id != nested { + assert_eq!(scene.rows[id], *row); + } + } +} + +#[test] +fn unfinished_coordinate_is_latent_and_zero_or_signed_zero_never_rewrites_source() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let second = id(&p, "second"); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { receiver, .. }, + .. + }) = p.elements.get_mut(&second).unwrap().shared_mut() + { + *receiver = None; + } + let left = id(&p, "left"); + let view = saved( + &mut p, + owner, + Some(positions(&[(second, -0.)], &[(left, -0.)])), + ); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + assert_eq!(resolved.latent_messages, vec![second]); + assert!(!resolved.scene.lines.iter().any(|line| line.id == second)); + assert_eq!(resolved.scene.rows[&second].1, 240.); + assert_eq!(named_box(&resolved.scene, second).rect.y, 220.); + passive(&document, &before, &source); +} + +#[test] +fn self_lifecycle_saved_send_is_converted_once_to_the_actual_receive_marker() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let create = id(&p, "create_receiver"); + let delete = id(&p, "delete_receiver"); + let provider = id(&p, "provider"); + for event in [create, delete] { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + }) = p.elements.get_mut(&event).unwrap().shared_mut() + { + *sender = Some(provider); + *exchange = None; + } + } + let view = saved( + &mut p, + owner, + Some(positions(&[(create, 200.), (delete, 400.)], &[])), + ); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + let x = resolved.lanes[&provider]; + for (event, send, receive) in [(create, 200., 224.), (delete, 400., 424.)] { + let points = &line(&resolved.scene, event).points; + assert_eq!(points.len(), 4); + assert_eq!(points[0].y, send); + assert_eq!(points[3].y, receive); + assert_eq!(resolved.scene.rows[&event].1, receive); + assert_eq!( + resolved.plan.messages[&event], + MessageAnchors { + send_y: send, + receive_y: receive + } + ); + assert_eq!( + resolved.scene.lifecycle.marker_at(Point::new(x, receive)), + Some(event) + ); + } + assert_eq!( + resolved + .scene + .lifecycle + .alive_spans(provider, owner, 0., 1000.) + .iter() + .map(|s| (s.start, s.end)) + .collect::>(), + vec![(224., 424.)] + ); + assert_eq!(resolved.scene.activations.len(), 1); + assert_eq!( + resolved.scene.activations[0].finish, + scenario_activation::Finish::Terminated(delete) + ); + passive(&document, &before, &source); +} + +#[test] +fn timer_uses_implicit_effective_receiver_and_true_self_offset() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + receiver, + exchange, + message_kind, + .. + }, + .. + }) = p.elements.get_mut(&first).unwrap().shared_mut() + { + *receiver = None; + *exchange = None; + *message_kind = MessageKind::Timer { + expression: "after 10 ms".into(), + }; + } + let view = saved(&mut p, owner, Some(positions(&[(first, 180.)], &[]))); + let document = stored(p); + let resolved = result(document.project(), view); + let points = &line(&resolved.scene, first).points; + assert_eq!(points.len(), 4); + assert_eq!((points[0].y, points[3].y), (180., 204.)); + assert_eq!(points[0].x, points[3].x); + assert_eq!( + resolved.plan.messages[&first], + MessageAnchors { + send_y: 180., + receive_y: 204. + } + ); +} + +#[test] +fn latent_message_becomes_exact_only_when_its_own_endpoint_is_completed() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + let second = id(&p, "second"); + let receiver = id(&p, "right"); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { receiver, .. }, + .. + }) = p.elements.get_mut(&second).unwrap().shared_mut() + { + *receiver = None; + } + let view = saved(&mut p, owner, Some(positions(&[(second, 200.)], &[]))); + let pending = result(&p, view); + assert_eq!(pending.latent_messages, vec![second]); + let authored = p.diagrams[&view].layout.clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + receiver: target, .. + }, + .. + }) = p.elements.get_mut(&second).unwrap().shared_mut() + { + *target = Some(receiver); + } + let document = stored(p); + let complete = result(document.project(), view); + assert!(complete.latent_messages.is_empty()); + assert_eq!( + line(&complete.scene, second) + .points + .iter() + .map(|point| point.y) + .collect::>(), + vec![200., 200.] + ); + assert_eq!(line(&complete.scene, first).points[0].y, 148.); + assert_eq!(document.project().diagrams[&view].layout, authored); +} + +#[test] +fn wide_gap_bounds_include_all_actual_shapes_and_typed_extensions() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let left = id(&p, "left"); + let right = id(&p, "right"); + let call = id(&p, "self_call"); + let view = saved( + &mut p, + owner, + Some(positions(&[], &[(left, -1_000_000.), (right, 1_000_000.)])), + ); + let resolved = result(&p, view); + assert_eq!(resolved.bounds.x, -1_000_160.); + assert_eq!(resolved.bounds.x + resolved.bounds.width, 1_000_240.); + let frame = named_box(&resolved.scene, id(&p, "fragment")).rect; + assert!(frame.x < resolved.lanes[&left] && frame.x + frame.width > resolved.lanes[&right]); + assert_eq!(line(&resolved.scene, call).points[1].x, 1_000_066.); + assert!(!resolved.scene.activations.is_empty()); + for segment in &resolved.scene.activations { + assert!(contains(resolved.bounds, segment.rect)); + } + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + // The same owner also admits a separate representation with one shifted lane. + let right = id(&p, "right"); + let view = saved(&mut p, owner, Some(positions(&[], &[(right, 900.)]))); + let resolved = result(&p, view); + assert!( + resolved.bounds.x + resolved.bounds.width + >= named_box(&resolved.scene, right).rect.x + 204. + ); +} + +#[test] +fn earlier_receiver_death_and_prebirth_call_are_named_typed_failures_not_disappearing_bars() { + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let call = id(&p, "service_call"); + let delete = id(&p, "delete_receiver"); + let provider = id(&p, "provider"); + let view = saved(&mut p, owner, Some(positions(&[(delete, 300.)], &[]))); + assert_eq!( + failures(&p, view), + vec![GeometryIssue::TypedActivationCrossingNotSupported { + call, + event: delete + }] + ); + let view = saved(&mut p, owner, Some(positions(&[(call, 200.)], &[]))); + assert_eq!( + failures(&p, view), + vec![GeometryIssue::TypedActivationOutsideLifetime { + call, + receiver: provider + }] + ); +} + +#[test] +fn unsupported_expansion_duplicate_keys_and_foreign_identity_fail_without_mutating_input() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + let mut layout = positions(&[(first, 200.), (first, 300.)], &[]); + layout.messages[0].address.references.push(owner); + let view = saved(&mut p, owner, Some(layout)); + let before = p.clone(); + let issues = failures(&p, view); + assert!(issues.contains(&GeometryIssue::ExpandedAddressNotSupported(first))); + assert!(issues.contains(&GeometryIssue::DuplicateCoordinate(first))); + assert_eq!(p, before); + assert_eq!( + failures(&p, Id::nil()), + vec![GeometryIssue::InvalidRepresentation(Id::nil())] + ); +} + +#[test] +fn saved_direct_nested_message_uses_operand_scopes_without_reparenting_or_moving_followers() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let nested = id(&p, "nested"); + let operand = id(&p, "operand"); + let frame = id(&p, "fragment"); + let first = id(&p, "first"); + let body = occurrence(&p, first).unwrap().clone(); + if let Some(SharedBody::Occurrence { body: target, .. }) = + p.elements.get_mut(&nested).unwrap().shared_mut() + { + *target = body; + } + let baseline = Scene::sequence(&p, owner, owner); + let view = saved(&mut p, owner, Some(positions(&[(nested, 380.)], &[]))); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + assert_eq!( + line(&resolved.scene, nested) + .points + .iter() + .map(|point| point.y) + .collect::>(), + vec![380., 380.] + ); + assert_eq!(resolved.scene.rows[&nested].0, operand); + assert_eq!(resolved.scene.sequence_bounds[&operand].0, 338.); + assert_eq!( + named_box(&resolved.scene, frame).rect, + named_box(&baseline, frame).rect + ); + for (id, row) in &baseline.rows { + if *id != nested { + assert_eq!(resolved.scene.rows[id], *row); + } + } + assert!(!baseline.activations.is_empty()); + assert_eq!(resolved.scene.activations, baseline.activations); + passive(&document, &before, &source); +} + +#[test] +fn exact_authored_lane_bits_survive_headers_and_tiny_distinct_lanes_are_not_coincident() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let left = id(&p, "left"); + let right = id(&p, "right"); + let first = id(&p, "first"); + let view = saved( + &mut p, + owner, + Some(positions(&[], &[(left, 0.1), (right, 400.)])), + ); + let resolved = result(&p, view); + assert_eq!(resolved.lanes[&left].to_bits(), 0.1_f32.to_bits()); + assert_eq!( + line(&resolved.scene, first).points[0].x.to_bits(), + 0.1_f32.to_bits() + ); + let view = saved( + &mut p, + owner, + Some(positions(&[], &[(left, 0.), (right, 0.000001)])), + ); + let resolved = result(&p, view); + assert_eq!(resolved.lanes[&left].to_bits(), 0_f32.to_bits()); + assert_eq!(resolved.lanes[&right].to_bits(), 0.000001_f32.to_bits()); + assert_ne!(resolved.lanes[&left], resolved.lanes[&right]); + let points = &line(&resolved.scene, first).points; + assert_eq!(points.len(), 2); + assert_eq!(points[1].x.to_bits(), 0.000001_f32.to_bits()); +} + +fn alternatives_with_death() -> (Document, Id, Id, Id, Id, Id) { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let frame = id(&p, "fragment"); + let branch1 = id(&p, "operand"); + let first = id(&p, "first"); + let second = id(&p, "second"); + let delete = id(&p, "self_reply"); + let right = id(&p, "right"); + let left = id(&p, "left"); + // Reuse admitted occurrence identities with a second actual operand. Delete + // occupies the former call's sibling identity; the former reply is the event. + let branch2 = id(&p, "self_call"); + let channel = id(&p, "channel"); + if let Some(SharedBody::Scenario { + steps: occurrences, .. + }) = p.elements.get_mut(&owner).unwrap().shared_mut() + { + *occurrences = vec![frame, delete]; + } + *p.elements.get_mut(&frame).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: oa_core::FragmentOperator::Alternative, + branches: vec![branch1, branch2], + }, + precondition: String::new(), + postcondition: String::new(), + }; + for (branch, child) in [(branch1, first), (branch2, second)] { + let e = p.elements.get_mut(&branch).unwrap(); + e.parent = Some(frame); + *e.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: "choice".into(), + occurrences: vec![child], + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements.get_mut(&child).unwrap().parent = Some(branch); + } + p.elements.remove(&id(document.project(), "nested")); + let template = occurrence(&p, first).unwrap().clone(); + let mut death = template; + if let OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + reply_to, + .. + } = &mut death + { + *exchange = Some(channel); + *sender = Some(left); + *receiver = Some(right); + *message_kind = MessageKind::Delete; + *reply_to = None; + } + *p.elements.get_mut(&delete).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: death, + precondition: String::new(), + postcondition: String::new(), + }; + (stored(p), owner, first, second, delete, right) +} + +#[test] +fn newly_skipped_sibling_scope_is_rejected_using_real_projector_diagnostics() { + let (document, owner, first, second, _, _) = alternatives_with_death(); + let mut p = document.project().clone(); + let baseline = Scene::sequence(&p, owner, owner); + assert_eq!( + (baseline.rows[&first].1, baseline.rows[&second].1), + (293., 475.) + ); + assert!(baseline.lifecycle.missing_geometry().is_empty()); + let plan = Plan::build(&p, owner, &baseline, &Candidate::from([(first, 500.)])).unwrap(); + let unsupported = Scene::sequence_geometry(&p, owner, owner, None, Some(&plan)); + let missing = unsupported.lifecycle.missing_geometry(); + assert!(!missing.is_empty()); + let view = saved(&mut p, owner, Some(positions(&[(first, 500.)], &[]))); + let failures = failures(&p, view); + for (lifeline, scope) in missing { + assert!( + failures.contains(&GeometryIssue::LifecycleGeometryNotSupported { lifeline, scope }) + ); + } +} + +#[test] +fn post_fragment_death_cannot_disappear_and_feasible_operand_spacing_retains_its_marker() { + let (document, owner, _, second, delete, right) = alternatives_with_death(); + let mut p = document.project().clone(); + let baseline = Scene::sequence(&p, owner, owner); + let x = baseline.render_lanes[&right]; + assert_eq!(baseline.rows[&delete].1, 631.); + assert_eq!( + baseline.lifecycle.marker_at(Point::new(x, 631.)), + Some(delete) + ); + let view = saved(&mut p, owner, Some(positions(&[(second, 700.)], &[]))); + assert!( + failures(&p, view) + .iter() + .any(|issue| matches!(issue, GeometryIssue::LifecycleGeometryNotSupported { .. })) + ); + let view = saved(&mut p, owner, Some(positions(&[(second, 520.)], &[]))); + let resolved = result(&p, view); + assert!(resolved.scene.lifecycle.missing_geometry().is_empty()); + assert_eq!( + resolved.scene.lifecycle.marker_at(Point::new(x, 631.)), + Some(delete) + ); + assert_eq!(resolved.scene.rows[&delete].1, 631.); +} + +#[test] +fn authored_send_bits_survive_baseline_delta_and_self_lifecycle_row_conversion() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + for send in [0.1_f32, 0.000001_f32] { + let view = saved(&mut p, owner, Some(positions(&[(first, send)], &[]))); + let resolved = result(&p, view); + assert_eq!( + resolved.plan.messages[&first].send_y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, first).points[0].y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, first).points[1].y.to_bits(), + send.to_bits() + ); + } + let (document, owner) = fixture(true); + let mut p = document.project().clone(); + let create = id(&p, "create_receiver"); + let provider = id(&p, "provider"); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, exchange, .. + }, + .. + }) = p.elements.get_mut(&create).unwrap().shared_mut() + { + *sender = Some(provider); + *exchange = None; + } + let send = f32::from_bits(64_f32.to_bits() - 1); + assert_ne!(((send + 24.) - 24.).to_bits(), send.to_bits()); + let view = saved(&mut p, owner, Some(positions(&[(create, send)], &[]))); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + assert_eq!( + resolved.plan.messages[&create].send_y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, create).points[0].y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, create).points[3].y.to_bits(), + (send + 24.).to_bits() + ); + assert_eq!( + resolved.scene.rows[&create].1.to_bits(), + (send + 24.).to_bits() + ); + passive(&document, &before, &source); +} + +#[test] +fn decimal_call_anchor_is_admitted_and_exact_in_plan_line_and_typed_activation() { + let (document, owner) = fixture(false); + let mut p = document.project().clone(); + let first = id(&p, "first"); + let call = id(&p, "second"); + let reply = id(&p, "self_reply"); + let left = id(&p, "left"); + let right = id(&p, "right"); + for alias in ["fragment", "operand", "nested", "self_call"] { + p.elements.remove(&id(document.project(), alias)); + } + *p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = vec![first, call, reply]; + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { message_kind, .. }, + .. + }) = p.elements.get_mut(&call).unwrap().shared_mut() + { + *message_kind = MessageKind::Call; + } + let mut response = occurrence(&p, call).unwrap().clone(); + if let OccurrenceBody::Exchange { + sender, + receiver, + message_kind, + reply_to, + .. + } = &mut response + { + *sender = Some(right); + *receiver = Some(left); + *message_kind = MessageKind::Reply; + *reply_to = Some(call); + } + *p.elements.get_mut(&reply).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: response, + precondition: String::new(), + postcondition: String::new(), + }; + let baseline = Scene::sequence(&p, owner, owner); + assert_eq!( + ( + baseline.rows[&first].1, + baseline.rows[&call].1, + baseline.rows[&reply].1 + ), + (148., 240., 332.) + ); + let send = 100.1_f32; + let view = saved( + &mut p, + owner, + Some(positions(&[(first, 90.), (call, send)], &[])), + ); + let document = stored(p); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let resolved = result(p, view); + assert_eq!( + resolved.plan.messages[&call].send_y.to_bits(), + send.to_bits() + ); + assert_eq!( + resolved.plan.messages[&call].receive_y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, call).points[0].y.to_bits(), + send.to_bits() + ); + assert_eq!( + line(&resolved.scene, call).points[1].y.to_bits(), + send.to_bits() + ); + assert_eq!(resolved.scene.activations.len(), 1); + assert_eq!( + resolved.scene.activations[0].rect.y.to_bits(), + send.to_bits() + ); + assert_eq!( + resolved.scene.activations[0].finish, + scenario_activation::Finish::Reply(reply) + ); + passive(&document, &before, &source); +} diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs index 989fb2d5..5321ade4 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs @@ -31,6 +31,7 @@ struct EventMarker { capsule: Rectangle, name: String, sender_x: Option, + self_message: bool, } #[derive(Debug, Default, Clone, PartialEq)] @@ -89,7 +90,7 @@ impl Rendering { continue; }; for event in &lane.markers { - let sender_x = project + let sender_id = project .elements .get(&event.occurrence) .and_then(|e| e.shared()) @@ -101,7 +102,7 @@ impl Rendering { .. }, .. - } => lanes.get(sender).copied(), + } => Some(*sender), _ => None, }); markers.push(EventMarker { @@ -113,7 +114,8 @@ impl Rendering { Size::new(140., 28.), ), name: project.named(lane.lifeline).to_owned(), - sender_x, + sender_x: sender_id.and_then(|id| lanes.get(&id).copied()), + self_message: sender_id == Some(lane.lifeline), }); } } @@ -138,7 +140,7 @@ impl Rendering { }; let x = marker.center.x; let y = marker.center.y; - let self_message = sender == x; + let self_message = marker.self_message; let end_x = match marker.event.kind { MarkerKind::Birth if sender < x => marker.capsule.x, MarkerKind::Birth => marker.capsule.x + marker.capsule.width, @@ -166,6 +168,48 @@ impl Rendering { } } + /// Geometry loss is distinct from warning-admitted semantic uncertainty. + /// The saved bridge compares these exact projector diagnostics with baseline. + pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> { + self.lifetimes + .iter() + .flat_map(|lane| { + lane.uncertainties + .iter() + .filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry) + .map(|issue| (lane.lifeline, issue.scope)) + }) + .collect() + } + + /// Complete structural lifetime bounds, including the birth capsule and death mark. + pub(in crate::behavior) fn bounds(&self) -> Option { + let mut bounds = self.span.and_then(|span| { + self.lanes + .values() + .map(|x| { + Rectangle::new( + Point::new(*x, span.start), + Size::new(0., span.end - span.start), + ) + }) + .reduce(crate::behavior::scenario_layout::union) + }); + for marker in &self.markers { + let rect = match marker.event.kind { + MarkerKind::Birth => marker.capsule, + MarkerKind::Death => Rectangle::new( + Point::new(marker.center.x - 10., marker.center.y - 10.), + Size::new(20., 20.), + ), + }; + bounds = Some(bounds.map_or(rect, |old| { + crate::behavior::scenario_layout::union(old, rect) + })); + } + bounds + } + pub(in crate::behavior) fn alive_spans( &self, lifeline: Id,