Compare commits

..
12 changed files with 4525 additions and 213 deletions
+173 -202
View File
@@ -20,6 +20,10 @@ mod contextual_source_tests;
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;
@@ -1171,17 +1175,21 @@ struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
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<Id>)>,
lanes: BTreeMap<Id, f32>,
// Exact render coordinates include unassigned lanes; do not derive from cards.
render_lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
lifecycle: scenario_lifecycle::render::Rendering,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
lifecycle: scenario_lifecycle::render::Rendering,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -1226,21 +1234,33 @@ 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<Id, f32>>,
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![],
lifecycle: scenario_lifecycle::render::Rendering::default(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
lifecycle: scenario_lifecycle::render::Rendering::default(),
};
let mut lanes = p
.children(owner)
@@ -1305,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::<BTreeMap<_, _>>();
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()
@@ -1326,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,
@@ -1338,29 +1396,10 @@ impl Scene {
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
let calls: Vec<_> = scene
.rows
.keys()
.filter_map(|&id| {
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements[&id].shared()
{
Some((id, *lane_x.get(receiver)?))
} else {
None
}
})
.collect();
for (call, x) in calls {
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
if let Some(plan) = plan {
plan.align_message_anchors(&mut scene.lines);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
let x = lane_x[&id];
@@ -1390,6 +1429,7 @@ impl Scene {
}
scene
}
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
fn occurrences(
&mut self,
p: &Project,
@@ -1398,6 +1438,7 @@ impl Scene {
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
plan: Option<&scenario_layout::Plan>,
) {
let steps = p
.elements
@@ -1427,14 +1468,19 @@ impl Scene {
};
if scenario
&& matches!(body, OccurrenceBody::Exchange {
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
{
// Self lifecycle elbows and their caption need clearance from
// the preceding action/state/reference box, before recording rows.
// Record the lifecycle receive row only after making room for
// the self-message elbow and caption above its marker.
*y += 16.;
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if let Some(plan) = plan {
*y = plan.anchors[id].y;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1451,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(
@@ -1459,6 +1505,7 @@ impl Scene {
(*y - top).max(95.),
),
);
let rect = plan.map_or(rect, |plan| plan.frames[id]);
self.boxes.insert(
0,
BoxItem {
@@ -1490,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,
@@ -1518,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),
@@ -1643,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,
@@ -1654,148 +1705,6 @@ impl Scene {
});
}
}
fn activation_segment(
&mut self,
call: Id,
scope: Id,
receiver: Id,
x: f32,
top: f32,
bottom: f32,
) {
for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 4., span.start),
Size::new(8., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
}
}
/// Follow a single correlation through the ordered tree. Branches are drawn
/// as independent segments, never as one chronological stretch through ALT
/// operands. The result says whether a path can leave this sequence open.
fn activation_sequence(
&mut self,
p: &Project,
owner: Id,
call: Id,
x: f32,
mut start: Option<f32>,
) -> bool {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
receiver: Some(receiver),
..
},
..
}) = p.elements[&call].shared()
else {
return false;
};
let receiver = *receiver;
let entered_open = start.is_some();
let mut split = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else {
continue;
};
if *id == call {
start = Some(y);
}
let Some(top) = start else {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
// This path cannot show activation beyond its local death.
// Keep the unresolved-call cue; a Delete is not a Reply.
self.activation_segment(call, owner, receiver, x, top, y);
self.open_calls.push(Point::new(x + 12., y - 16.));
start = None;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
self.activation_segment(call, owner, receiver, x, top, y);
for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 7., span.start),
Size::new(14., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
}
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, owner, receiver, x, top, y);
let bottom = self
.boxes
.iter()
.find(|b| b.id == *id && !b.title.is_empty())
.map(|b| b.rect.y + b.rect.height)
.unwrap_or(y);
let outcomes: Vec<_> = branches
.iter()
.filter_map(|branch| {
let &(top, _) = self.sequence_bounds.get(branch)?;
Some(self.activation_sequence(p, *branch, call, x, Some(top)))
})
.collect();
let open = match operator {
FragmentOperator::Alternative => {
outcomes.is_empty() || outcomes.iter().any(|open| *open)
}
FragmentOperator::Parallel => outcomes.iter().all(|open| *open),
// Optional/repeated bodies do not guarantee execution.
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
start = open.then_some(bottom);
}
_ => {}
}
}
if let Some(top) = start {
let bottom = if entered_open || split {
self.sequence_bounds
.get(&owner)
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
} else {
top + 26.
};
self.activation_segment(call, owner, receiver, x, top, bottom);
self.open_calls.push(Point::new(x + 8., bottom - 7.));
}
start.is_some()
}
fn slot_at(&self, point: Point, parent: Option<Id>) -> Option<&SequenceSlot> {
self.slots
.iter()
@@ -1842,17 +1751,40 @@ impl Scene {
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.find(|line| {
!line.label.is_empty()
&& line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| line.id)
})
.or_else(|| self.near_sequence_item(point))
.or_else(|| {
self.boxes
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point) && b.title.is_empty())
.map(|b| b.id)
.find(|b| b.rect.contains(point))
.map(|b| b.call)
})
.or_else(|| {
self.lines
.iter()
.find(|line| {
!line.label.is_empty()
&& Rectangle::new(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
.contains(point)
})
.map(|line| line.id)
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| self.near_sequence_item(point))
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
@@ -1885,9 +1817,8 @@ impl Scene {
.fold(f32::INFINITY, f32::min);
(line.id, distance)
});
// One distance comparison for both kinds of screen-sized hit tolerance.
// Dense minimum-Fit rows must not give the first message or last marker
// a larger hit priority than the visibly nearer item. UUID breaks ties.
// Compare broad message and lifecycle-marker tolerance together after
// exact shapes, so dense minimum-Fit rows choose the nearer item.
self.lifecycle
.nearest_marker(point)
.into_iter()
@@ -2158,6 +2089,55 @@ impl canvas::Program<Message> for Scene {
f.translate(self.camera_pan);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
// Guides belong behind execution fills and cards. In particular, a
// lifeline must not cut through the middle of its activation rectangle.
for guide in self.lines.iter().filter(|line| line.label.is_empty()) {
f.stroke(
&Path::new(|path| {
path.move_to(guide.points[0]);
for point in &guide.points[1..] {
path.line_to(*point);
}
}),
Stroke::default()
.with_color(if guide.id == self.selected {
style::TEAL
} else {
style::MUTED
})
.with_width(if guide.id == self.selected { 2.5 } else { 1.2 }),
);
}
// Label backplates clear guides, but must not erase execution borders
// or message geometry. Their text remains in the foreground below.
for line in self.lines.iter().filter(|line| !line.label.is_empty()) {
f.fill_rectangle(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
22.,
),
Color::WHITE,
);
}
for activation in &self.activations {
let path = Path::rectangle(activation.rect.position(), activation.rect.size());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
&path,
Stroke::default()
.with_color(if activation.call == self.selected {
style::TEAL
} else {
style::LINE
})
.with_width(if activation.call == self.selected {
2.5
} else {
1.
}),
);
}
for b in &self.boxes {
let outline = matches!(
b.marker,
@@ -2184,7 +2164,7 @@ impl canvas::Program<Message> for Scene {
);
}
}
for l in &self.lines {
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if l.id == self.selected {
style::TEAL
} else {
@@ -2221,11 +2201,6 @@ impl canvas::Program<Message> for Scene {
}),
Stroke::default().with_color(color),
);
f.fill_rectangle(
l.label_at,
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.),
Color::WHITE,
);
f.fill_text(Text {
content: short(&l.label, 32),
position: l.label_at,
@@ -2237,8 +2212,8 @@ impl canvas::Program<Message> for Scene {
}
for b in &self.boxes {
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
// Clipped child frames start with an identity transform. Match
// the card's pan and scale, as for lifecycle Create labels.
// WGPU child clips start with an identity transform, as in
// the lifecycle capsule label painter.
f.with_clip(clip, |label| {
label.translate(self.camera_pan);
label.scale(self.scale);
@@ -3025,9 +3000,9 @@ mod direct_canvas_tests {
p = execute_batch(&p, &commands).unwrap();
let scene = Scene::sequence(&p, f.owner, f.call);
let bars: Vec<_> = scene
.boxes
.activations
.iter()
.filter(|b| b.id == f.call && b.rect.width == 8.)
.filter(|b| b.call == f.call)
.map(|b| b.rect)
.collect();
for reply in &replies {
@@ -3065,11 +3040,7 @@ mod direct_canvas_tests {
#[test]
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
let (f, scene) = scene();
let bar = scene
.boxes
.iter()
.find(|b| b.id == f.call && b.rect.width == 8.)
.unwrap();
let bar = scene.activations.iter().find(|b| b.call == f.call).unwrap();
assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1);
assert!(scene.open_calls.is_empty());
let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
@@ -3077,9 +3048,9 @@ mod direct_canvas_tests {
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
scene
.boxes
.activations
.iter()
.find(|b| b.id == f.call && b.rect.width == 8.)
.find(|b| b.call == f.call)
.unwrap()
.rect
.height,
+17 -5
View File
@@ -3234,12 +3234,24 @@ mod tests {
.iter()
.any(|l| l.id == steps[1] && l.dashed && l.label.contains("reply"))
);
assert!(
scene
.boxes
.iter()
.any(|b| b.id == steps[0] && b.rect.width == 8.)
assert_eq!(
scene.activations,
vec![super::super::scenario_activation::Segment {
call: steps[0],
receiver,
scope: owner,
rect: Rectangle::new(
Point::new(scene.lanes[&receiver] - 6., 148.),
Size::new(12., 92.),
),
column: 0,
finish: super::super::scenario_activation::Finish::Reply(steps[1]),
}]
);
let call_line = scene.lines.iter().find(|line| line.id == steps[0]).unwrap();
let reply_line = scene.lines.iter().find(|line| line.id == steps[1]).unwrap();
assert_eq!(call_line.points.last().unwrap().y, 148.);
assert_eq!(reply_line.points.first().unwrap().y, 240.);
let mut reply = State::default();
reply
.update(result, Event::Start(owner, Tool::Message))
@@ -0,0 +1,492 @@
//! Derived call geometry. Correlation and branch ownership come from the model;
//! columns separate overlapping drawings and do not assert an execution thread.
use super::*;
const WIDTH: f32 = 12.;
const OFFSET: f32 = 6.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Finish {
Reply(Id),
Boundary,
/// Still open at this scope boundary; the visible span is not an isolated stub.
Continuation,
Open,
/// The receiver was deleted without answering this call.
Terminated(Id),
/// Lifetime clipping ended this visible piece before its logical terminal.
Clipped,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Segment {
pub call: Id,
pub receiver: Id,
pub scope: Id,
pub rect: Rectangle,
pub column: usize,
pub finish: Finish,
}
impl Segment {
fn bottom(&self) -> f32 {
self.rect.y + self.rect.height
}
fn overlaps(&self, other: &Self) -> bool {
self.receiver == other.receiver
&& self.scope == other.scope
&& self.rect.y < other.bottom()
&& other.rect.y < self.bottom()
}
fn edge(&self, right: bool) -> f32 {
self.rect.x + if right { self.rect.width } else { 0. }
}
}
impl Scene {
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
// Logical endpoints precede clipping and remain available even when a
// lifetime hides a whole execution or terminates it before its Reply.
let correlations = logical_correlations(p, &self.rows, &self.lines);
let mut calls: Vec<_> = self
.rows
.iter()
.filter_map(|(&call, &(scope, y))| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements.get(&call).and_then(|e| e.shared())
else {
return None;
};
Some((call, *receiver, scope, y, *lanes.get(receiver)?))
})
.collect();
// Rows are laid out in semantic order. UUID order must not choose which
// rectangle obscures another (especially after source reidentification).
calls.sort_by(|a, b| a.3.total_cmp(&b.3).then_with(|| a.0.cmp(&b.0)));
for &(call, receiver, scope, _, x) in &calls {
let first = self.activations.len();
let first_cue = self.open_calls.len();
self.activation_sequence(p, scope, call, receiver, x, None);
let column = self.activations[first..]
.iter()
.flat_map(|segment| {
self.activations[..first]
.iter()
.filter(move |other| segment.overlaps(other))
})
.map(|other| other.column + 1)
.max()
.unwrap_or(0);
for segment in &mut self.activations[first..] {
segment.column = column;
segment.rect.x += column as f32 * OFFSET;
}
for cue in &mut self.open_calls[first_cue..] {
cue.x += column as f32 * OFFSET;
}
}
// Shallow executions paint first; columns cannot collide within a scope.
self.activations.sort_by(|a, b| {
a.column
.cmp(&b.column)
.then_with(|| a.rect.y.total_cmp(&b.rect.y))
.then_with(|| a.call.cmp(&b.call))
});
self.anchor_activation_messages(p, lanes, &correlations);
}
fn activation_segment(
&mut self,
call: Id,
receiver: Id,
scope: Id,
x: f32,
span: std::ops::Range<f32>,
finish: Finish,
) {
// Cues describe unanswered logical paths, including wholly invisible
// ones. Never derive them only from the surviving paint rectangles.
if matches!(
finish,
Finish::Open | Finish::Continuation | Finish::Terminated(_)
) {
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., span.end - 7.));
}
for alive in self
.lifecycle
.alive_spans(receiver, scope, span.start, span.end)
{
self.activations.push(Segment {
call,
receiver,
scope,
rect: Rectangle::new(
Point::new(x - WIDTH / 2., alive.start),
Size::new(WIDTH, alive.end - alive.start),
),
column: 0,
finish: if alive.end == span.end {
finish
} else {
Finish::Clipped
},
});
}
}
/// Follow one exact correlation. A segment belongs to one ordered scope;
/// vertically stacked operands must not become a single chronological span.
fn activation_sequence(
&mut self,
p: &Project,
owner: Id,
call: Id,
receiver: Id,
x: f32,
mut start: Option<f32>,
) -> Outcome {
let entered_open = start.is_some();
let mut split = false;
let mut may_be_terminated = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else {
continue;
};
if *id == call {
// A reflective call is received at the lower end of its loop.
start = Some(
self.lines
.iter()
.find(|line| line.id == call)
.and_then(|line| line.points.last())
.map_or(y, |point| point.y),
);
}
let Some(top) = start else {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
self.activation_segment(
call,
receiver,
owner,
x,
top..y,
Finish::Terminated(*id),
);
start = None;
may_be_terminated = true;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
// Execution ends when its reply is sent, including a self reply.
self.activation_segment(call, receiver, owner, x, top..y, Finish::Reply(*id));
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, receiver, owner, x, top..y, Finish::Boundary);
let bottom = self
.boxes
.iter()
.find(|b| b.id == *id && !b.title.is_empty())
.map(|b| b.rect.y + b.rect.height)
.unwrap_or(y);
let outcomes: Vec<_> = branches
.iter()
.filter_map(|branch| {
let &(top, _) = self.sequence_bounds.get(branch)?;
Some(self.activation_sequence(p, *branch, call, receiver, x, Some(top)))
})
.collect();
let open = match operator {
FragmentOperator::Alternative => {
outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open)
}
FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open),
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
if outcomes.iter().any(|outcome| outcome.may_be_terminated) {
// Sibling bars retain their own entry paths, but a
// conditional death cannot become a definite parent
// execution again after a later Create.
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., bottom - 7.));
may_be_terminated = true;
start = None;
} else {
start = open.then_some(bottom);
}
}
_ => {}
}
}
if let Some(top) = start {
let bottom = if entered_open || split {
self.sequence_bounds
.get(&owner)
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
} else {
top + 26.
};
let finish = if entered_open || split {
Finish::Continuation
} else {
Finish::Open
};
self.activation_segment(call, receiver, owner, x, top..bottom, finish);
}
Outcome {
open: start.is_some(),
may_be_terminated,
}
}
fn anchor_activation_messages(
&mut self,
p: &Project,
lanes: &BTreeMap<Id, f32>,
correlations: &BTreeMap<Id, Correlation>,
) {
let segments = &self.activations;
for line in &mut self.lines {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender: Some(sender),
receiver: Some(receiver),
message_kind,
reply_to,
..
},
..
}) = p.elements.get(&line.id).and_then(|e| e.shared())
else {
continue;
};
if !matches!(
message_kind,
MessageKind::Call | MessageKind::Reply | MessageKind::Asynchronous
) {
continue;
}
let (Some(&from), Some(&to), Some(&(scope, _))) = (
lanes.get(sender),
lanes.get(receiver),
self.rows.get(&line.id),
) else {
continue;
};
let send_y = line.points[0].y;
let receive_y = line.points.last().unwrap().y;
let reflective = sender == receiver;
let source = if *message_kind == MessageKind::Reply {
reply_to.and_then(|call| {
segments.iter().find(|a| {
a.call == call
&& a.scope == scope
&& a.finish == Finish::Reply(line.id)
&& a.bottom() == send_y
})
})
} else {
active_segment(
p,
segments,
correlations,
*sender,
scope,
send_y,
Some(line.id),
)
};
let target = if *message_kind == MessageKind::Call {
segments
.iter()
.find(|a| a.call == line.id && a.scope == scope && a.rect.y == receive_y)
} else {
active_segment(
p,
segments,
correlations,
*receiver,
scope,
receive_y,
*reply_to,
)
};
let start = source.map_or(from, |a| a.edge(reflective || to > from));
let end = target.map_or(to, |a| a.edge(reflective || from > to));
if reflective {
let outside = start.max(end) + 60.;
line.points = vec![
Point::new(start, send_y),
Point::new(outside, send_y),
Point::new(outside, receive_y),
Point::new(end, receive_y),
];
} else {
line.points = vec![Point::new(start, send_y), Point::new(end, receive_y)];
}
line.label_at.x = start.min(end) + 12.;
}
}
}
struct Outcome {
open: bool,
may_be_terminated: bool,
}
fn logical_correlations(
p: &Project,
rows: &BTreeMap<Id, (Id, f32)>,
lines: &[Line],
) -> BTreeMap<Id, Correlation> {
let mut result = BTreeMap::new();
let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect();
for (&id, &(_, y)) in rows {
if matches!(
p.elements.get(&id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
result.insert(
id,
Correlation {
start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y),
replies: vec![],
},
);
}
}
for (&id, &(scope, y)) in rows {
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
},
..
}) = p.elements.get(&id).and_then(|e| e.shared())
&& let Some(correlation) = result.get_mut(call)
{
let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y);
correlation.replies.push((scope, sent));
}
}
result
}
/// A caller has no implicit execution just because it sent a message. Only an
/// actually drawn, known-duration execution can supply its anchor. Crossed
/// intervals provide no unambiguous innermost execution, so fall back to the lane.
fn active_segment<'a>(
p: &Project,
segments: &'a [Segment],
correlations: &BTreeMap<Id, Correlation>,
receiver: Id,
scope: Id,
y: f32,
exclude: Option<Id>,
) -> Option<&'a Segment> {
let candidates: Vec<_> = segments
.iter()
.filter(|a| {
a.receiver == receiver
&& a.scope == scope
&& Some(a.call) != exclude
&& a.rect.y <= y
&& y < a.bottom()
&& a.finish != Finish::Open
})
.collect();
let inner = candidates.iter().max_by_key(|a| a.column).copied()?;
candidates
.iter()
.all(|a| {
a.call == inner.call
|| correlations[&a.call].contains(p, scope, &correlations[&inner.call])
})
.then_some(inner)
}
struct Correlation {
start: f32,
replies: Vec<(Id, f32)>,
}
impl Correlation {
fn contains(&self, p: &Project, scope: Id, inner: &Self) -> bool {
// Segment clipping at a fragment can make crossed calls appear nested.
// Compare actual correlated replies on compatible scope paths instead.
let relevant = |&(reply_scope, _): &(Id, f32)| {
ancestor(p, scope, reply_scope) || ancestor(p, reply_scope, scope)
};
let mut compared = false;
for &(inner_scope, inner_end) in inner.replies.iter().filter(|reply| relevant(reply)) {
let mut matched = false;
for &(outer_scope, outer_end) in self.replies.iter().filter(|reply| relevant(reply)) {
if ancestor(p, inner_scope, outer_scope) || ancestor(p, outer_scope, inner_scope) {
// Alternative operands are not chronological. Compare only
// returns that can belong to the same enclosing branch path.
if outer_end < inner_end {
return false;
}
matched = true;
}
}
if !matched {
return false;
}
compared = true;
}
self.start <= inner.start && compared
}
}
fn ancestor(p: &Project, expected: Id, mut scope: Id) -> bool {
for _ in 0..=p.elements.len() {
if scope == expected {
return true;
}
let Some(parent) = p.elements.get(&scope).and_then(|e| e.parent) else {
return false;
};
scope = parent;
}
false
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,842 @@
use super::*;
fn id(n: u128) -> Id {
Id::from_u128(0x21ca1100000040008000000000000000 + n)
}
struct Fixture {
project: Project,
owner: Id,
caller: Id,
callee: Id,
call: Id,
inner: Id,
reply: Id,
inner_reply: Id,
}
fn seed(p: &mut Project, layer: Layer, n: u128, kind: Kind, parent: Option<Id>) -> Id {
let mut e = p
.make_element(kind, layer, &format!("Element {n}"), parent)
.unwrap();
e.id = id(n);
e.alias = format!("element_{n}");
p.elements.insert(e.id, e);
id(n)
}
fn message(
p: &mut Project,
layer: Layer,
n: u128,
parent: Id,
endpoints: (Id, Id),
reply: Option<Id>,
) -> Id {
let e = seed(p, layer, n, Kind::Occurrence, Some(parent));
*p.elements.get_mut(&e).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: None,
sender: Some(endpoints.0),
receiver: Some(endpoints.1),
message_kind: if reply.is_some() {
MessageKind::Reply
} else {
MessageKind::Call
},
reply_to: reply,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
e
}
fn fixture(layer: Layer, reverse_uuid_order: bool) -> Fixture {
let mut p = Project::empty();
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = seed(&mut p, layer, 1, work, None);
let b = seed(&mut p, layer, 2, work, None);
let owner = seed(&mut p, layer, 3, Kind::Scenario, None);
if let SharedBody::Scenario { viewpoint, .. } =
p.elements.get_mut(&owner).unwrap().shared_mut().unwrap()
{
*viewpoint = if layer == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
}
let caller = seed(&mut p, layer, 4, Kind::Lifeline, Some(owner));
let callee = seed(&mut p, layer, 5, Kind::Lifeline, Some(owner));
for (line, represented) in [(caller, a), (callee, b)] {
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let (first, second) = if reverse_uuid_order {
(40, 30)
} else {
(30, 40)
};
let call = message(&mut p, layer, first, owner, (caller, callee), None);
let inner = message(&mut p, layer, second, owner, (caller, callee), None);
let reply = message(&mut p, layer, 50, owner, (callee, caller), Some(call));
let inner_reply = message(&mut p, layer, 60, owner, (callee, caller), Some(inner));
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() =
vec![call, inner, inner_reply, reply];
Fixture {
project: p,
owner,
caller,
callee,
call,
inner,
reply,
inner_reply,
}
}
fn scene(f: &Fixture) -> Scene {
let errors: Vec<_> = validation::validate(&f.project)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "Actual core fixture: {errors:#?}");
let source = language::format(&f.project);
let reparsed = language::parse(&source).unwrap();
assert_eq!(language::format(&reparsed), source);
let result = Scene::sequence(&reparsed, f.owner, f.owner);
assert_eq!(
language::format(&reparsed),
source,
"Projection must not write geometry into the model"
);
result
}
fn bar(scene: &Scene, call: Id) -> &Segment {
scene.activations.iter().find(|a| a.call == call).unwrap()
}
fn long_labels(f: &mut Fixture) {
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
f.project.elements.get_mut(&occurrence).unwrap().name =
"Pressure verification with a descriptive operation label".into();
}
}
#[test]
fn activation_shoulders_under_label_backplates_select_their_call() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
long_labels(&mut f);
let scene = scene(&f);
let outer = bar(&scene, f.call);
for occurrence in [f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.);
assert!(outer.rect.contains(shoulder));
assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder));
assert!(message.label.chars().count() > 32);
assert!(message.points.windows(2).all(|p| {
crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale
}));
assert_eq!(scene.hit(shoulder), Some(f.call));
assert_eq!(
scene.hit(message.label_at + iced::Vector::new(4., 4.)),
Some(occurrence),
"Label text outside activations remains selectable"
);
}
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
for endpoint in [message.points[0], *message.points.last().unwrap()] {
assert_eq!(scene.hit(endpoint), Some(occurrence));
}
}
}
}
#[tokio::test]
async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() {
use iced::advanced::renderer::Headless;
use iced_runtime::user_interface::{Cache, UserInterface};
let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await;
let mut f = fixture(Layer::Oa, true);
long_labels(&mut f);
let mut scene = scene(&f);
scene.selected = f.call;
let border_x = bar(&scene, f.call).rect.x;
let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply]
.into_iter()
.map(|id| {
let message = line(&scene, id);
assert!(
Rectangle::new(message.label_at, Size::new(225., 22.))
.contains(Point::new(border_x, message.label_at.y + 11.))
);
(message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize
})
.collect();
let dimensions = Size::new(800., 700.);
let content: Element<'_, Message> = canvas::Canvas::new(scene)
.width(dimensions.width)
.height(dimensions.height)
.into();
let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer);
ui.draw(
&mut mounted.renderer,
&style::theme(),
&iced::advanced::renderer::Style {
text_color: style::INK,
},
mouse::Cursor::Unavailable,
);
let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE);
let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.);
for span in label_spans {
for y in span {
assert!(
((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| {
let offset = (y * 800 + x) * 4;
pixels[offset..offset + 3]
.iter()
.zip(expected)
.all(|(actual, target)| (*actual as f32 - target).abs() < 30.)
}),
"Selected activation border was erased at y={y}"
);
}
}
}
fn line(scene: &Scene, occurrence: Id) -> &super::super::Line {
scene.lines.iter().find(|a| a.id == occurrence).unwrap()
}
fn endpoints(p: &mut Project, occurrence: Id, from: Id, to: Id) {
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
sender, receiver, ..
},
..
} = p
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = Some(from);
*receiver = Some(to);
}
}
#[test]
fn nested_calls_expose_both_bars_and_end_at_their_exact_replies_in_both_directions() {
for layer in [Layer::Oa, Layer::Sa] {
for reverse in [false, true] {
let mut f = fixture(layer, true);
if reverse {
for occurrence in [f.call, f.inner] {
endpoints(&mut f.project, occurrence, f.callee, f.caller);
}
for occurrence in [f.reply, f.inner_reply] {
endpoints(&mut f.project, occurrence, f.caller, f.callee);
}
}
let scene = scene(&f);
let outer = bar(&scene, f.call);
let inner = bar(&scene, f.inner);
assert!(outer.rect.y < inner.rect.y && outer.bottom() > inner.bottom());
assert_eq!(outer.column, 0);
assert_eq!(inner.column, 1);
assert!(outer.rect.x < inner.rect.x && outer.edge(true) < inner.edge(true));
for (call, reply) in [(f.call, f.reply), (f.inner, f.inner_reply)] {
let execution = bar(&scene, call);
let received = *line(&scene, call).points.last().unwrap();
let returned = line(&scene, reply).points[0];
assert_eq!(execution.rect.y, received.y);
assert_eq!(execution.bottom(), returned.y);
assert_eq!(received.x, execution.edge(reverse));
assert_eq!(returned.x, execution.edge(reverse));
assert_eq!(execution.finish, Finish::Reply(reply));
}
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn call_uuid_order_does_not_choose_activation_columns_or_message_anchors() {
let f = fixture(Layer::Oa, false);
let g = fixture(Layer::Oa, true);
let a = scene(&f);
let b = scene(&g);
for (x, y) in [
(f.call, g.call),
(f.inner, g.inner),
(f.reply, g.reply),
(f.inner_reply, g.inner_reply),
] {
assert_eq!(line(&a, x).points, line(&b, y).points);
}
for (x, y) in [(f.call, g.call), (f.inner, g.inner)] {
assert_eq!(bar(&a, x).rect, bar(&b, y).rect);
}
}
#[test]
fn sequential_calls_reuse_column_and_crossed_replies_keep_their_correlations() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.reply, f.inner, f.inner_reply];
let sequential = scene(&f);
assert_eq!(
bar(&sequential, f.call).rect.x,
bar(&sequential, f.inner).rect.x
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, f.reply, f.inner_reply];
let crossed = scene(&f);
assert!(bar(&crossed, f.call).bottom() < bar(&crossed, f.inner).bottom());
assert!(bar(&crossed, f.call).rect.x < bar(&crossed, f.inner).rect.x);
assert_eq!(
line(&crossed, f.reply).points[0].x,
bar(&crossed, f.call).edge(false)
);
assert_eq!(
line(&crossed, f.inner_reply).points[0].x,
bar(&crossed, f.inner).edge(false)
);
}
}
#[test]
fn self_call_receives_at_lower_loop_end_and_self_reply_returns_to_the_parent() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let scene = scene(&f);
let parent = bar(&scene, f.call);
let child = bar(&scene, f.inner);
let call = line(&scene, f.inner);
let reply = line(&scene, f.inner_reply);
assert_eq!(call.points[0].x, parent.edge(true));
assert_eq!(call.points[3], Point::new(child.edge(true), child.rect.y));
assert_eq!(child.rect.y, scene.rows[&f.inner].1 + 24.);
assert_eq!(
reply.points[0],
Point::new(child.edge(true), child.bottom())
);
assert_eq!(reply.points[3].x, parent.edge(true));
assert!(call.points[1].x > child.edge(true));
assert!(reply.points[1].x > child.edge(true));
}
}
#[test]
fn an_inactive_self_call_has_no_fabricated_parent_and_three_nested_calls_expose_three_columns() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
f.project.elements.remove(&f.call);
f.project.elements.remove(&f.reply);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.inner, f.inner_reply];
let isolated = scene(&f);
assert_eq!(isolated.activations.len(), 1);
assert_eq!(bar(&isolated, f.inner).column, 0);
assert_eq!(
line(&isolated, f.inner).points[0].x,
isolated.lanes[&f.callee]
);
assert_eq!(
line(&isolated, f.inner_reply).points[3].x,
isolated.lanes[&f.callee]
);
assert_eq!(
bar(&isolated, f.inner).rect.y,
line(&isolated, f.inner).points[3].y
);
let mut f = fixture(Layer::Oa, true);
let third = message(
&mut f.project,
Layer::Oa,
20,
f.owner,
(f.caller, f.callee),
None,
);
let third_reply = message(
&mut f.project,
Layer::Oa,
21,
f.owner,
(f.callee, f.caller),
Some(third),
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, third, third_reply, f.inner_reply, f.reply];
let scene = scene(&f);
let calls: Vec<_> = scene.activations.iter().map(|a| a.call).collect();
assert_eq!(
calls,
vec![f.call, f.inner, third],
"Paint order is shallow to deep"
);
for (column, call) in [f.call, f.inner, third].into_iter().enumerate() {
let execution = bar(&scene, call);
assert_eq!(execution.column, column);
let y = (bar(&scene, third).rect.y + bar(&scene, third).bottom()) / 2.;
assert_eq!(
scene.hit(Point::new(execution.rect.x + OFFSET / 2., y)),
Some(call)
);
}
}
#[test]
fn reentrant_call_uses_the_other_receiver_without_inventing_a_caller_execution() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.caller);
endpoints(&mut f.project, f.inner_reply, f.caller, f.callee);
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_eq!(bar(&scene, f.inner).receiver, f.caller);
assert_eq!(line(&scene, f.call).points[0].x, scene.lanes[&f.caller]);
assert_eq!(
line(&scene, f.inner).points[0].x,
bar(&scene, f.call).edge(false)
);
}
#[test]
fn actual_canvas_pointer_selects_each_exposed_shoulder_under_zoom_and_pan() {
let f = fixture(Layer::Oa, true);
let mut scene = scene(&f);
let context = scenario::direct_tests::context(f.owner);
scene.interaction = Some((context, scenario::CanvasTool::Select, true));
let y = (bar(&scene, f.inner).rect.y + bar(&scene, f.inner).bottom()) / 2.;
let outer = Point::new(bar(&scene, f.call).rect.x + OFFSET / 2., y);
let inner = Point::new(bar(&scene, f.inner).edge(true) - OFFSET / 2., y);
for scale in [1., 0.25, 0.05] {
scene.scale = scale;
scene.camera_pan = iced::Vector::new(50., 25.);
let bounds = Rectangle::new(Point::new(30., 40.), Size::new(1000., 700.));
for (point, call) in [(outer, f.call), (inner, f.inner)] {
assert_eq!(scene.hit(point), Some(call));
let cursor = bounds.position()
+ scene.camera_pan
+ iced::Vector::new(point.x * scale, point.y * scale);
let mut state = SequenceInput::default();
let _ = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
);
let action = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
)
.unwrap();
assert!(matches!(action.into_inner().0,
Some(Message::ScenarioCanvas(scenario::CanvasEvent::Select(_, selected))) if selected == call));
}
}
}
#[test]
fn missing_reply_is_a_short_explicit_open_span_and_an_uncorrelated_reply_closes_nothing() {
let mut f = fixture(Layer::Oa, true);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to, .. },
..
} = f
.project
.elements
.get_mut(&f.reply)
.unwrap()
.shared_mut()
.unwrap()
{
*reply_to = None;
}
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).finish, Finish::Open);
assert_eq!(bar(&scene, f.call).rect.height, 26.);
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
bar(&scene, f.inner).column,
0,
"Unknown duration does not prove later nesting"
);
assert_eq!(bar(&scene, f.inner).finish, Finish::Reply(f.inner_reply));
}
#[test]
fn alternative_and_parallel_operand_local_calls_do_not_nest_from_screen_order() {
for layer in [Layer::Oa, Layer::Sa] {
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
let mut f = fixture(layer, true);
let fragment = seed(&mut f.project, layer, 70, Kind::Occurrence, Some(f.owner));
let branches = [id(71), id(72)];
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator,
branches: branches.to_vec(),
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.call, f.reply], vec![f.inner, f.inner_reply]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
layer,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![fragment];
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_ne!(bar(&scene, f.call).scope, bar(&scene, f.inner).scope);
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn inherited_execution_anchors_a_self_call_inside_an_operand_before_returning_after_the_fragment() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.inner, f.inner_reply], vec![]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, fragment, f.reply];
let scene = scene(&f);
let parent = scene
.activations
.iter()
.find(|a| a.call == f.call && a.scope == id(71))
.unwrap();
assert_eq!(parent.finish, Finish::Continuation);
assert_eq!(bar(&scene, f.inner).column, parent.column + 1);
assert_eq!(line(&scene, f.inner).points[0].x, parent.edge(true));
assert_eq!(line(&scene, f.inner_reply).points[3].x, parent.edge(true));
assert!(
scene
.activations
.iter()
.any(|a| a.call == f.call && a.finish == Finish::Reply(f.reply))
);
}
#[test]
fn a_fragment_cannot_make_crossed_calls_an_unambiguous_caller_execution() {
for placement in ["plain", "before_fragment", "in_operand"] {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let mut ordered = vec![f.call, f.inner];
if placement != "in_operand" {
ordered.push(outgoing);
}
if placement != "plain" {
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 && placement == "in_operand" {
f.project.elements.get_mut(&outgoing).unwrap().parent = Some(branch);
vec![outgoing]
} else {
vec![]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
ordered.push(fragment);
}
ordered.extend([f.reply, f.inner_reply]);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = ordered;
let scene = scene(&f);
assert_eq!(
line(&scene, outgoing).points[0].x,
scene.lanes[&f.callee],
"{placement}"
);
}
}
#[test]
fn nested_correlations_returning_in_alternative_operands_remain_nested_before_the_fragment() {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 {
for reply in [f.inner_reply, f.reply] {
f.project.elements.get_mut(&reply).unwrap().parent = Some(branch);
}
vec![f.inner_reply, f.reply]
} else {
vec![
message(
&mut f.project,
Layer::Oa,
90,
branch,
(f.callee, f.caller),
Some(f.inner),
),
message(
&mut f.project,
Layer::Oa,
91,
branch,
(f.callee, f.caller),
Some(f.call),
),
]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, outgoing, fragment];
let scene = scene(&f);
let inner = scene
.activations
.iter()
.find(|a| a.call == f.inner && a.scope == f.owner)
.unwrap();
assert_eq!(line(&scene, outgoing).points[0].x, inner.edge(false));
}
@@ -0,0 +1,814 @@
//! 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<Id, f32>;
#[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<Id, Anchor>,
messages: BTreeMap<Id, MessageAnchors>,
pub(super) frames: BTreeMap<Id, Rectangle>,
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
pub(super) height: f32,
diagnostics: Vec<Diagnostic>,
}
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<Id, Anchor>,
seed: &BTreeMap<Id, Anchor>,
) -> BTreeMap<Id, Rectangle> {
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<Id, Rectangle>) {
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<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
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<Self, GeometryIssue> {
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))
&& let Some((previous, previous_y)) =
last.insert(*lane, (*id, message.receive_y))
&& 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 });
}
let old_covered = old_coverage[&id];
let padding =
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
// Preserve padding before the footprint itself reaches the old
// frame edge: operand scopes already retain their own padding.
rect.height = rect
.height
.max(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])
&& 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<Id, f32>,
pub(super) plan: Plan,
pub(super) latent_messages: Vec<Id>,
pub(super) bounds: Rectangle,
pub(super) scene: Scene,
}
pub(super) fn resolve(
p: &Project,
representation: Representation,
selected: Id,
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
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)
&& (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
&& (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
}
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,21 @@ use iced::{
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
use std::collections::BTreeMap;
// Test-only geometry input, deliberately upstream of rows, scopes, lifecycle
// and activations. This is not an authored placement or drag policy.
#[cfg(test)]
thread_local! {
static TEST_ROW_OFFSETS: std::cell::RefCell<BTreeMap<Id, f32>> = Default::default();
}
#[cfg(test)]
pub(in crate::behavior) fn apply_test_row_offset(id: Id, y: &mut f32) {
TEST_ROW_OFFSETS.with(|offsets| {
if let Some(offset) = offsets.borrow().get(&id) {
*y += offset;
}
});
}
#[derive(Debug, Clone, PartialEq)]
struct EventMarker {
event: Marker,
@@ -16,6 +31,7 @@ struct EventMarker {
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
self_message: bool,
}
#[derive(Debug, Default, Clone, PartialEq)]
@@ -74,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())
@@ -86,7 +102,7 @@ impl Rendering {
..
},
..
} => lanes.get(sender).copied(),
} => Some(*sender),
_ => None,
});
markers.push(EventMarker {
@@ -98,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),
});
}
}
@@ -123,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,
@@ -151,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<Rectangle> {
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,
@@ -3,6 +3,9 @@ use crate::{behavior::Scene, document::Document};
use iced::Size;
use oa_core::*;
#[path = "union_tests.rs"]
mod union_tests;
struct Fixture {
project: Project,
owner: Id,
@@ -100,6 +103,21 @@ impl Fixture {
},
)
}
fn reply(&mut self, parent: Id, call: Id) -> Id {
self.occurrence(
parent,
"Reply",
OccurrenceBody::Exchange {
exchange: Some(self.exchange),
sender: Some(self.receiver),
receiver: Some(self.sender),
message_kind: MessageKind::Reply,
operation: None,
reply_to: Some(call),
values: vec![],
},
)
}
fn fragment(&mut self, parent: Id, operator: FragmentOperator) -> Id {
self.occurrence(
parent,
@@ -488,9 +506,9 @@ fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_
let (left_start, left_end) = scene.sequence_bounds[&left];
let (right_start, _) = scene.sequence_bounds[&right];
let bars: Vec<_> = scene
.boxes
.activations
.iter()
.filter(|b| b.id == call && b.rect.width == 8.)
.filter(|b| b.call == call && b.receiver == f.receiver)
.map(|b| b.rect)
.collect();
assert!(
@@ -0,0 +1,498 @@
use super::*;
use crate::behavior::scenario_activation::{Finish, Segment};
fn line(scene: &Scene, id: Id) -> &crate::behavior::Line {
scene.lines.iter().find(|line| line.id == id).unwrap()
}
fn segments(scene: &Scene, call: Id) -> Vec<&Segment> {
scene
.activations
.iter()
.filter(|a| a.call == call)
.collect()
}
fn assert_visible_lifetime(scene: &Scene) {
for a in &scene.activations {
assert_eq!(
scene
.lifecycle
.alive_spans(a.receiver, a.scope, a.rect.y, a.rect.y + a.rect.height),
vec![Span {
start: a.rect.y,
end: a.rect.y + a.rect.height
}]
);
if let Finish::Reply(reply) = a.finish {
assert_eq!(a.rect.y + a.rect.height, line(scene, reply).points[0].y);
}
}
}
#[test]
fn admitted_delayed_create_nested_calls_replies_delete_share_one_geometry_pipeline() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let birth = f.message(f.owner, MessageKind::Create);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
let r2 = f.reply(f.owner, c2);
let r1 = f.reply(f.owner, c1);
let death = f.message(f.owner, MessageKind::Delete);
let scene = f.scene();
assert_eq!(scene.activations.len(), 2);
for (call, reply, column) in [(c1, r1, 0), (c2, r2, 1)] {
let a = segments(&scene, call)[0];
assert_eq!(
(a.receiver, a.scope, a.column, a.finish),
(f.receiver, f.owner, column, Finish::Reply(reply))
);
let received = *line(&scene, call).points.last().unwrap();
let sent = line(&scene, reply).points[0];
assert_eq!((a.rect.y, a.rect.y + a.rect.height), (received.y, sent.y));
assert_eq!(received.x, sent.x);
assert!(
a.rect.y > scene.rows[&birth].1 && a.rect.y + a.rect.height < scene.rows[&death].1
);
}
assert_visible_lifetime(&scene);
assert!(scene.open_calls.is_empty());
for event in [birth, death] {
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)),
Some(event)
);
}
}
}
#[test]
fn admitted_death_and_incomplete_later_reply_do_not_fabricate_an_answered_execution() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let call = f.message(f.owner, MessageKind::Call);
let death = f.message(f.owner, MessageKind::Delete);
let reply = f.reply(f.owner, call);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
sender, exchange, ..
},
..
} = f
.project
.elements
.get_mut(&reply)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = None;
*exchange = None;
}
let scene = f.scene();
let a = segments(&scene, call)[0];
assert_eq!(a.finish, Finish::Terminated(death));
assert_eq!(a.rect.y + a.rect.height, scene.rows[&death].1);
assert!(
scene.boxes.iter().any(|b| b.id == reply),
"The incomplete reply remains a repairable card"
);
assert_eq!(scene.open_calls.len(), 1);
assert_visible_lifetime(&scene);
}
}
#[test]
fn admitted_self_lifecycle_and_self_call_keep_distinct_send_receive_rows() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let action = f.occurrence(
f.owner,
"Sender preparation",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.sender),
},
);
let birth = f.message(f.owner, MessageKind::Create);
let call = f.message(f.owner, MessageKind::Call);
let reply = f.reply(f.owner, call);
let death = f.message(f.owner, MessageKind::Delete);
for event in [birth, call, reply, death] {
if let SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender,
receiver,
exchange,
..
},
..
} = f
.project
.elements
.get_mut(&event)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = Some(f.receiver);
*receiver = Some(f.receiver);
*exchange = None;
}
}
let scene = f.scene();
let preparation = scene.boxes.iter().find(|b| b.id == action).unwrap();
assert!(line(&scene, birth).label_at.y > preparation.rect.y + preparation.rect.height);
for event in [birth, death] {
let message = line(&scene, event);
assert_eq!(message.points[0].y, scene.rows[&event].1 - 24.);
assert_eq!(message.points.last().unwrap().y, scene.rows[&event].1);
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.receiver], scene.rows[&event].1)),
Some(event)
);
}
let activation = segments(&scene, call)[0];
assert_eq!(activation.rect.y, scene.rows[&call].1 + 24.);
assert_eq!(
activation.rect.y,
line(&scene, call).points.last().unwrap().y
);
assert_eq!(
activation.rect.y + activation.rect.height,
line(&scene, reply).points[0].y
);
assert_eq!(activation.finish, Finish::Reply(reply));
assert_visible_lifetime(&scene);
}
}
#[test]
fn admitted_unresolved_operand_keeps_unanswered_cue_without_any_visible_segment() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
let left = f.branch(fragment);
f.message(left, MessageKind::Create);
let right = f.branch(fragment);
let call = f.message(right, MessageKind::Call);
let scene = f.scene();
assert!(segments(&scene, call).is_empty());
assert!(
intervals(&scene, f.receiver, right)
.iter()
.all(|i| i.2 == Life::Unresolved)
);
assert_eq!(scene.open_calls.len(), 1);
assert!(scene.open_calls[0].y > scene.rows[&call].1);
assert_eq!(
line(&scene, call).points.last().unwrap().x,
scene.lanes[&f.receiver]
);
}
}
#[test]
fn admitted_calls_without_sender_polylines_still_have_logical_correlation_metadata() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
for call in [c1, c2] {
if let SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender, exchange, ..
},
..
} = f
.project
.elements
.get_mut(&call)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = None;
*exchange = None;
}
}
let outgoing = f.occurrence(
f.owner,
"Outgoing",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(f.receiver),
receiver: Some(f.sender),
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
);
f.reply(f.owner, c2);
f.reply(f.owner, c1);
let scene = f.scene();
assert!(scene.boxes.iter().any(|b| b.id == c1));
assert!(scene.boxes.iter().any(|b| b.id == c2));
let inner = segments(&scene, c2)[0];
let expected = if scene.lanes[&f.sender] > scene.lanes[&f.receiver] {
inner.rect.x + inner.rect.width
} else {
inner.rect.x
};
assert_eq!(line(&scene, outgoing).points[0].x, expected);
}
}
fn lifetime_rejected(f: &Fixture) {
assert!(validate_candidate(&f.project).is_err());
assert!(
validation::validate(&f.project)
.iter()
.any(|issue| issue.severity == validation::Severity::Error
&& issue.code == "lifeline_lifetime")
);
}
#[test]
fn invalid_prebirth_call_and_postdeath_reply_remain_rejected_with_defensive_scene_clipping() {
for layer in [Layer::Oa, Layer::Sa] {
for later_birth in [false, true] {
let mut f = Fixture::new(layer);
let call = f.message(f.owner, MessageKind::Call);
let death = f.message(f.owner, MessageKind::Delete);
if later_birth {
f.message(f.owner, MessageKind::Create);
}
let reply = f.reply(f.owner, call);
lifetime_rejected(&f);
// Deliberately bypass Document only for defensive renderer coverage
// of rejected data. This is not an admitted editing workflow.
let scene = Scene::sequence(&f.project, f.owner, f.owner);
assert!(
scene
.activations
.iter()
.all(|a| a.rect.y + a.rect.height <= scene.rows[&death].1)
);
assert!(
scene
.activations
.iter()
.all(|a| a.finish != Finish::Reply(reply))
);
assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]);
assert!(!scene.open_calls.is_empty());
assert_visible_lifetime(&scene);
}
let mut f = Fixture::new(layer);
let call = f.message(f.owner, MessageKind::Call);
let birth = f.message(f.owner, MessageKind::Create);
let reply = f.reply(f.owner, call);
lifetime_rejected(&f);
let scene = Scene::sequence(&f.project, f.owner, f.owner);
assert_eq!(segments(&scene, call)[0].rect.y, scene.rows[&birth].1);
assert_eq!(segments(&scene, call)[0].finish, Finish::Reply(reply));
assert_eq!(
line(&scene, call).points.last().unwrap().x,
scene.lanes[&f.receiver]
);
assert_visible_lifetime(&scene);
}
}
#[test]
fn conditional_death_then_recreate_never_revives_call_but_keeps_open_sibling() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
f.message(f.owner, MessageKind::Create);
let call = f.message(f.owner, MessageKind::Call);
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
let left = f.branch(fragment);
let death = f.message(left, MessageKind::Delete);
let right = f.branch(fragment);
let rebirth = f.message(f.owner, MessageKind::Create);
let reply = f.reply(f.owner, call);
lifetime_rejected(&f);
let scene = Scene::sequence(&f.project, f.owner, f.owner);
let bars = segments(&scene, call);
assert!(
bars.iter()
.any(|a| a.scope == left && a.finish == Finish::Terminated(death))
);
assert!(
bars.iter().any(|a| a.scope == right
&& a.rect.height == 26.
&& a.finish == Finish::Continuation)
);
assert!(
bars.iter()
.all(|a| a.rect.y + a.rect.height < scene.rows[&rebirth].1)
);
assert!(!bars.iter().any(|a| a.finish == Finish::Reply(reply)));
assert_eq!(line(&scene, reply).points[0].x, scene.lanes[&f.receiver]);
assert_eq!(
scene.open_calls.len(),
3,
"Terminated, open sibling and uncertain parent cues survive"
);
assert_visible_lifetime(&scene);
}
}
#[test]
fn clipped_operand_preserves_original_nested_or_crossed_correlation_distinction() {
for layer in [Layer::Oa, Layer::Sa] {
for nested in [false, true] {
let mut f = Fixture::new(layer);
f.message(f.owner, MessageKind::Create);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
let left = f.branch(fragment);
f.message(left, MessageKind::Delete);
let right = f.branch(fragment);
let outgoing = f.occurrence(
right,
"Outgoing",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(f.receiver),
receiver: Some(f.sender),
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
);
let r1 = f.reply(f.owner, if nested { c2 } else { c1 });
let r2 = f.reply(f.owner, if nested { c1 } else { c2 });
lifetime_rejected(&f);
let scene = Scene::sequence(&f.project, f.owner, f.owner);
let send = line(&scene, outgoing).points[0];
let expected = if nested {
let inner = segments(&scene, c2)
.into_iter()
.find(|a| a.scope == right)
.unwrap();
if scene.lanes[&f.sender] > scene.lanes[&f.receiver] {
inner.rect.x + inner.rect.width
} else {
inner.rect.x
}
} else {
scene.lanes[&f.receiver]
};
assert_eq!(send.x, expected);
for call in [c1, c2] {
assert!(segments(&scene, call).iter().any(|a| a.scope == right
&& a.rect.y <= send.y
&& send.y < a.rect.y + a.rect.height));
}
assert!(scene.rows[&r1].1 < scene.rows[&r2].1);
assert_visible_lifetime(&scene);
}
}
}
fn with_offset<T>(id: Id, offset: f32, f: impl FnOnce() -> T) -> T {
struct Restore(BTreeMap<Id, f32>);
impl Drop for Restore {
fn drop(&mut self) {
TEST_ROW_OFFSETS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0));
}
}
let _restore =
Restore(TEST_ROW_OFFSETS.with(|current| current.replace(BTreeMap::from([(id, offset)]))));
f()
}
fn snapshot(scene: &Scene) -> String {
format!(
"{:?}",
(
&scene.lifecycle,
&scene.activations,
&scene.open_calls,
&scene.rows,
&scene.sequence_bounds,
scene
.lines
.iter()
.map(|l| (l.id, &l.points, &l.label, l.label_at, l.dashed))
.collect::<Vec<_>>(),
scene
.boxes
.iter()
.map(|b| (b.id, b.rect, &b.title, &b.detail, b.marker))
.collect::<Vec<_>>(),
scene
.slots
.iter()
.map(|s| (s.slot, s.y, &s.label))
.collect::<Vec<_>>(),
scene.size,
)
)
}
#[test]
fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without_authoring() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let birth = f.message(f.owner, MessageKind::Create);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
let r2 = f.reply(f.owner, c2);
let r1 = 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 scene in [&baseline, &moved] {
assert_eq!(scene.activations.len(), 2);
for (call, reply) in [(c1, r1), (c2, r2)] {
assert!(scene.activations.iter().any(|segment| {
segment.call == call
&& segment.finish == Finish::Reply(reply)
}));
}
}
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());
}
}
+2
View File
@@ -20,6 +20,8 @@ mod semantic_transitions;
mod source6_views;
#[path = "http/source7_contracts.rs"]
mod source7_contracts;
#[path = "http/scenario_layout.rs"]
mod scenario_layout;
#[path = "http/source_projects.rs"]
mod source_projects;
@@ -0,0 +1,344 @@
//! Saved Scenario geometry uses the same atomic model/source HTTP transaction.
use super::*;
use oa_core::{
Diagram, DiagramLayout, MessageKind, OccurrenceBody, OccurrenceEndpoint, ProjectionKind,
ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition, ScenarioViewpoint, SharedBody,
source_project::{ProjectLimits, SourceBundle, compile_bundle, format_bundle},
};
use std::collections::BTreeMap;
struct Fixture {
bundle: SourceBundle,
views: [Diagram; 2],
lane: Id,
messages: [Id; 3],
foreign: Id,
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn fixture() -> Fixture {
let mut model = Project::empty();
let owner = model
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let other = model
.add_element(Kind::Scenario, Layer::Oa, "Other delivery", None)
.unwrap();
for id in [owner, other] {
let Some(SharedBody::Scenario { viewpoint, .. }) =
model.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!("Scenario body")
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
}
let sender = model
.add_element(Kind::Entity, Layer::Oa, "Sender", None)
.unwrap();
let receiver = model
.add_element(Kind::Entity, Layer::Oa, "Receiver", None)
.unwrap();
let lane = model
.add_element(Kind::Lifeline, Layer::Oa, "Sender lane", Some(owner))
.unwrap();
let destination = model
.add_element(Kind::Lifeline, Layer::Oa, "Receiver lane", Some(owner))
.unwrap();
for (id, represented) in [(lane, sender), (destination, receiver)] {
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = model.relate(
RelationKind::CommunicationMean,
"Delivery channel",
sender,
receiver,
);
let mut occurrences = Vec::new();
for (name, parent, complete) in [
("First delivery", owner, true),
("Repeated delivery", owner, true),
("Unfinished delivery", owner, false),
("Foreign delivery", other, false),
] {
let id = model
.add_element(Kind::Occurrence, Layer::Oa, name, Some(parent))
.unwrap();
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: complete.then_some(exchange),
sender: complete.then_some(lane),
receiver: complete.then_some(destination),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
occurrences.push(id);
}
*model.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = occurrences[..3].to_vec();
*model.elements.get_mut(&other).unwrap().steps_mut().unwrap() = vec![occurrences[3]];
let views = ["delivery_wide", "delivery_review"].map(|alias| Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
});
let mut bundle = format_bundle(&model);
let entry = bundle.files.get_mut(&bundle.entry).unwrap();
*entry = format!(
"// Transport fixture rationale\n{}",
entry.replacen("{\n", "{\n include \"notes.arcadia\";\n", 1)
);
bundle.files.insert(
"notes.arcadia".into(),
"// Unrelated rationale: 航海\r\n".into(),
);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, model);
assert!(
oa_core::validation::validate(&model)
.iter()
.all(|issue| issue.severity != oa_core::validation::Severity::Error)
);
Fixture {
bundle: compiled.bundle,
views,
lane,
messages: [occurrences[0], occurrences[1], occurrences[2]],
foreign: occurrences[3],
}
}
async fn post(
server: &TestServer,
current: &StoredProject,
etag: &str,
commands: Value,
dry_run: bool,
) -> (StatusCode, Option<String>, Value) {
let response = server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", etag)
.json(&json!({"commands": commands, "dry_run": dry_run}))
.send()
.await
.unwrap();
let status = response.status();
let etag = response
.headers()
.get("etag")
.map(|value| value.to_str().unwrap().to_owned());
(status, etag, response.json().await.unwrap())
}
async fn assert_unchanged(server: &TestServer, current: &StoredProject, disk: &Path, bytes: &[u8]) {
assert_eq!(
serde_json::to_value(server.get(current.resource_id).await).unwrap(),
serde_json::to_value(current).unwrap(),
"entire stored envelope, including Project, source and revision"
);
assert_eq!(
std::fs::read(disk).unwrap(),
bytes,
"physical stored envelope must not be rewritten"
);
}
fn assert_source_pair(current: &StoredProject, notes: &str) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, current.source_project);
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(current.source_project.files["notes.arcadia"], notes);
assert!(
current.source_project.files[&current.source_project.entry]
.starts_with("// Transport fixture rationale\n")
);
}
#[tokio::test]
async fn saved_scenario_positions_roundtrip_through_http_without_partial_or_noop_writes() {
let f = fixture();
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&f.bundle)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let summary: Value = response.json().await.unwrap();
let id = summary["resource_id"].as_str().unwrap().parse().unwrap();
let original = server.get(id).await;
assert_eq!(original.source_project, f.bundle);
let disk = directory.path().join(format!("{id}.json"));
let original_bytes = std::fs::read(&disk).unwrap();
let [wide, review] = f.views.clone();
// Raw JSON exercises the actual externally-tagged transport fields. Two
// messages deliberately share one engineering exchange but retain distinct IDs.
let commands = json!([
{"CreateDiagram": wide}, {"CreateDiagram": review},
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 200.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[1]}, "send_y": 275.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}},
{"SetScenarioLanePosition": {"diagram": review.id, "position": {"lifeline": f.lane, "x": 760.0}}},
{"SetScenarioMessagePosition": {"diagram": review.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 430.0}}}
]);
// Independent expected Project: populate the specified records directly;
// do not use execute_batch/prepare_command as the success oracle.
let mut expected = original.model.clone();
let mut expected_wide = wide.clone();
expected_wide.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: f.lane,
x: -120.0,
}],
messages: f
.messages
.into_iter()
.zip([200., 275., -35.])
.map(|(id, send_y)| ScenarioMessagePosition {
address: address(id),
send_y,
})
.collect(),
}));
let mut expected_review = review.clone();
expected_review.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: f.lane,
x: 760.0,
}],
messages: vec![ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 430.0,
}],
}));
expected.diagrams.insert(wide.id, expected_wide);
expected.diagrams.insert(review.id, expected_review.clone());
let (status, etag, preview) =
post(&server, &original, &original.etag(), commands.clone(), true).await;
assert_eq!(status, StatusCode::OK, "{preview}");
assert_eq!(etag, Some(original.etag()));
assert_eq!(preview["revision"], original.revision);
assert_eq!(preview["changed"], true);
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let preview_source: SourceBundle =
serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&preview_source, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original, &disk, &original_bytes).await;
let (status, etag, committed) =
post(&server, &original, &original.etag(), commands, false).await;
assert_eq!(status, StatusCode::OK, "{committed}");
assert_eq!(committed["changed"], true);
let current = server.get(id).await;
assert_eq!(etag, Some(current.etag()));
assert_eq!(current.revision, original.revision + 1);
assert_eq!(current.model, expected);
assert_eq!(
current.source_project, preview_source,
"preview and commit must agree exactly"
);
assert_source_pair(&current, &f.bundle.files["notes.arcadia"]);
let current_bytes = std::fs::read(&disk).unwrap();
let no_op = json!([
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}}
]);
for dry_run in [true, false] {
let (status, etag, result) =
post(&server, &current, &current.etag(), no_op.clone(), dry_run).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(etag, Some(current.etag()));
assert_eq!(result["changed"], false);
assert_eq!(result["revision"], current.revision);
assert_unchanged(&server, &current, &disk, &current_bytes).await;
}
// A stale request must fail even when its coordinate value is a no-op.
let (status, _, result) = post(&server, &current, &original.etag(), no_op, false).await;
assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{result}");
assert_eq!(result["code"], "revision_conflict");
assert_unchanged(&server, &current, &disk, &current_bytes).await;
// First command is valid. The second belongs to another Scenario; neither
// a dry-run nor a real transaction may expose/persist the partial candidate.
let invalid = json!([
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": 999.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.foreign}, "send_y": 400.0}}}
]);
for dry_run in [true, false] {
let (status, _, result) =
post(&server, &current, &current.etag(), invalid.clone(), dry_run).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}");
assert_eq!(result["code"], "command_rejected");
assert!(result.get("model").is_none());
assert_unchanged(&server, &current, &disk, &current_bytes).await;
}
let mut removals =
vec![json!({"RemoveScenarioLanePosition": {"diagram": wide.id, "lifeline": f.lane}})];
removals.extend(f.messages.map(|occurrence| json!({"RemoveScenarioMessagePosition": {"diagram": wide.id, "address": {"references": [], "occurrence": occurrence}}})));
let removals = Value::Array(removals);
let (status, _, result) =
post(&server, &current, &current.etag(), removals.clone(), false).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], true);
let removed = server.get(id).await;
assert_eq!(removed.revision, current.revision + 1);
expected.diagrams.get_mut(&wide.id).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
assert_eq!(
removed.model, expected,
"removal changes only the addressed saved view"
);
assert_eq!(removed.model.diagrams[&review.id], expected_review);
assert_source_pair(&removed, &f.bundle.files["notes.arcadia"]);
let removed_bytes = std::fs::read(&disk).unwrap();
let (status, _, result) = post(&server, &removed, &removed.etag(), removals, false).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], false);
assert_unchanged(&server, &removed, &disk, &removed_bytes).await;
let exported = server
.client
.get(server.url(&format!("/projects/{id}/source-project")))
.send()
.await
.unwrap();
assert_eq!(exported.status(), StatusCode::OK);
assert_eq!(exported.headers()["etag"].to_str().unwrap(), removed.etag());
assert_eq!(
exported.json::<SourceBundle>().await.unwrap(),
removed.source_project
);
server.stop().await;
let restarted = TestServer::start(directory.path(), None).await;
assert_unchanged(&restarted, &removed, &disk, &removed_bytes).await;
restarted.stop().await;
}
+8
View File
@@ -74,6 +74,14 @@ Compare these two user gestures before selecting the release behavior:
Evaluate ordinary spacing, intentional and accidental crossings, Call/Reply and Create/Delete validity, same-owner view switches, discoverability, cancellation and one-step Undo. Both candidates must preserve operand membership unless the user invokes an explicit structural move. A persistence-home choice does not settle this gesture choice.
The next bounded workflow prototype will evaluate **spacing through the arrow or label, with explicitly named Reorder available at the selection**. This is a candidate for runtime evaluation, not release acceptance. Spacing has a view-local purpose; reordering changes the shared Scenario and therefore affects its other representations. Inferring that shared semantic instruction merely from pointer travel is the principal reason to try separate actions first. Continuous spacing/reorder retains today's motor behavior and remains the alternative if the explicit action proves difficult to discover or use.
This deliberately changes the current body-drag behavior: both an occurrence hit and the selected handle currently capture Reorder, with a three-screen-pixel movement threshold. Retaining only the existing handle is insufficient. Its world rectangle at x6..28 becomes 1.1 screen pixels wide at 0.05 zoom and can pan offscreen while the selected message remains visible. The named action must remain reachable at the current selection and camera position.
The prototype must distinguish arrow/label, activation shoulder, lifecycle marker and reorder-handle hit roles; a shared occurrence UUID does not imply a shared drag action. It must preserve exact authored values, source, revision and redo on cancellation, stale view-visit context or return to the starting position. Geometry diagnostics are inputs to explicit preview feedback, not permission to rewrite steps, clamp stored values or manufacture missing arrows. The complete resolver's acceptance of source-admitted crossings does not itself select the gesture's crossing policy.
Acceptance needs one independent native workflow: make two close messages easier to read, then deliberately swap them without being taught the handle. Include negative/far-right selections, Fit zoom, self Call/Reply and Delete, activation-shoulder input, and two saved views of one owner. Compare complete steps, both layouts, source and one-step Undo. Failure to discover Reorder or understand a refused spacing preview requires revising the interaction before adoption. No full implementation of both candidates is required merely to compare their risks; this first experiment does not replace the broader direct-editing acceptance matrix below.
## Labels are part of the spacing outcome
Both reviewed renderers shorten each complete message label to 32 graphemes independently of lane spacing. Their 225-unit backplate estimate is not a matching text clip. Existing release1 call/reply names already expose this truncation. The navigation membership helper independently assumes the same shortened label; changing paint alone would make its visibility claims wrong.