Fix scenario scroll cancellation, operand activations and picker cost

This commit is contained in:
2026-09-06 04:53:23 +03:00
parent 524dabb9c3
commit 7a666076bf
3 changed files with 1065 additions and 111 deletions
+692 -75
View File
@@ -21,6 +21,297 @@ pub(crate) mod state_menu;
pub(crate) mod state_view;
pub(crate) mod states;
pub(crate) mod topology;
// The standard on_scroll callback only reports active scrolling. This local
// wrapper reads the existing Scrollable's actual translation as well, including
// initial mount and passive size clamping. It forwards layout, input and drawing.
mod scenario_scroll {
use super::*;
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget};
use iced::{Length, Vector, overlay};
pub(super) fn observe<'a>(
context: scenario::Context,
area: scenario::ScrollArea,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
Element::new(Observer {
content: content.into(),
context,
area,
})
}
struct Observer<'a, R = Renderer> {
content: Element<'a, Message, Theme, R>,
context: scenario::Context,
area: scenario::ScrollArea,
}
type Report = Option<(Id, Id, Vector)>;
#[derive(Default)]
struct Translation(Option<Vector>);
impl widget::Operation for Translation {
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
self.0 = Some(translation);
}
}
impl<R: renderer::Renderer> Observer<'_, R> {
fn report(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
shell: &mut Shell<'_, Message>,
) {
let mut offset = Translation::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut offset,
);
if let Some(offset) = offset.0 {
let next = Some((self.context.document, self.context.scenario, offset));
let previous = tree.state.downcast_mut::<Report>();
if *previous != next {
*previous = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
)));
}
}
}
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Observer<'_, R> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<Report>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(Report::None)
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &iced::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
// A passive clamp notification precedes any pointer result produced
// with the old context; App rejects that result after the epoch bump.
self.report(tree, layout, renderer, shell);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
self.report(tree, layout, renderer, shell);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
// Iced exposes its null renderer only in debug builds.
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
Observer {
context,
area: scenario::ScrollArea::Canvas,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.width(200.)
.height(200.)
.into(),
}
}
fn update(
observer: &mut Observer<'_, ()>,
tree: &mut widget::Tree,
event: iced::Event,
) -> (Vec<Message>, bool) {
let node = observer.layout(
tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
let mut messages = vec![];
let mut shell = Shell::new(&mut messages);
observer.update(
tree,
&event,
Layout::new(&node),
mouse::Cursor::Available(Point::new(50., 50.)),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(Size::new(800., 700.)),
);
let captured = shell.is_event_captured();
(messages, captured)
}
fn offsets(messages: &[Message]) -> Vec<Vector> {
messages
.iter()
.filter_map(|message| {
if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) =
message
{
Some(*offset)
} else {
None
}
})
.collect()
}
#[test]
fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() {
let context = scenario::direct_tests::context(Id::new_v4());
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let mut view = observer(600., context);
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::ZERO]
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
let (messages, captured) = update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
);
assert!(captured, "the observer forwards normal Scrollable capture");
assert_eq!(offsets(&messages), vec![Vector::new(0., 180.)]);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
view = observer(250., context);
view.diff(&mut tree);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::new(0., 50.)]
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
view = observer(600., context);
view.diff(&mut tree);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::new(0., 180.)]
);
let mut remounted = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
offsets(&update(&mut view, &mut remounted, redraw()).0),
vec![Vector::ZERO]
);
assert!(update(&mut view, &mut remounted, redraw()).0.is_empty());
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Topology(topology::Event),
@@ -254,11 +545,13 @@ struct Scene {
size: Size,
selected: Id,
scale: f32,
scroll_offset: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -270,6 +563,7 @@ struct SequenceInput {
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
@@ -299,6 +593,7 @@ impl Scene {
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
let mut scene = Self {
scale: 1.,
scroll_offset: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
@@ -308,6 +603,7 @@ impl Scene {
slots: vec![],
rows: BTreeMap::new(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
};
let mut lanes = p
.children(owner)
@@ -395,50 +691,28 @@ impl Scene {
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
if scenario {
for (&id, &(_, top)) in &scene.rows {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements[&id].shared()
else {
continue;
};
let Some(x) = lane_x.get(receiver) else {
continue;
};
let ends: Vec<_> = scene.rows.iter().filter_map(|(reply, (_, y))| {
matches!(p.elements[reply].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply, reply_to: Some(call), .. }, .. }) if *call == id && *y > top).then_some(*y)
}).collect();
let bottom = ends
.iter()
.copied()
.max_by(f32::total_cmp)
.unwrap_or(top + 26.);
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(*x - 4., top), Size::new(8., bottom - top)),
title: String::new(),
detail: String::new(),
marker: None,
});
if ends.is_empty() {
scene.open_calls.push(Point::new(*x + 8., bottom - 7.));
}
for end in ends {
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(*x - 7., end - 1.), Size::new(14., 2.)),
title: String::new(),
detail: String::new(),
marker: None,
});
}
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);
}
}
scene.size.height = (y + 45.).max(210.);
@@ -491,6 +765,7 @@ impl Scene {
})
.unwrap_or(&[]);
let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some();
let sequence_top = *y - 42.;
for (index, id) in steps.iter().enumerate() {
let Some(e) = p.elements.get(id) else {
continue;
@@ -696,6 +971,7 @@ impl Scene {
}
}
if ordered {
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
let slot = scenario::Slot {
parent: owner,
before: None,
@@ -707,6 +983,106 @@ impl Scene {
});
}
}
fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) {
if bottom <= top {
return;
}
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 4., top), Size::new(8., bottom - top)),
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 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::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
self.activation_segment(call, x, top, y);
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 7., y - 1.), Size::new(14., 2.)),
title: String::new(),
detail: String::new(),
marker: None,
});
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, 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, 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()
@@ -807,6 +1183,14 @@ impl canvas::Program<Message> for Scene {
state.context = context;
state.tool = tool;
}
// Scrolling either enclosing Scrollable changes the canvas origin. A
// captured insertion slot belongs to the previous view, even if the
// pointer itself did not move and the semantic context is unchanged.
if state.bounds.is_some_and(|previous| previous != bounds) {
state.cancel();
state.pointer = None;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
@@ -833,9 +1217,11 @@ impl canvas::Program<Message> for Scene {
}
}
}
mouse::Cursor::Available(*position)
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::CursorLeft)
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
| canvas::Event::Window(iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Window(iced::window::Event::Unfocused) => {
state.cancel();
state.pointer = None;
@@ -843,9 +1229,11 @@ impl canvas::Program<Message> for Scene {
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position();
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -879,7 +1267,7 @@ impl canvas::Program<Message> for Scene {
state.press = Some(SequencePress::Message {
source,
slot,
start: cursor.position()?,
start: cursor.position()? - self.scroll_offset,
first,
moved: false,
});
@@ -889,7 +1277,7 @@ impl canvas::Program<Message> for Scene {
state.press = Some(SequencePress::Reorder {
occurrence: self.selected,
parent: self.rows[&self.selected].0,
start: cursor.position()?,
start: cursor.position()? - self.scroll_offset,
moved: false,
});
return Some(Action::request_redraw().and_capture());
@@ -1295,54 +1683,164 @@ mod direct_canvas_tests {
}
}
#[test]
fn direct_scroll_origin_changes_do_not_create_motion_and_unavailable_release_cancels() {
fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() {
let (f, mut scene) = scene();
scene.scroll_offset = iced::Vector::new(35., 140.);
let bounds = Rectangle::new(Point::new(20., 230.), scene.size);
let slot = scene.slots.last().unwrap().slot;
let y = scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset;
let mut state = SequenceInput::default();
// Iced Scrollable passes raw event coordinates and a translated cursor.
for event in [
mouse::Event::CursorMoved { position: a },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
] {
send(&scene, &mut state, event, b + scene.scroll_offset, bounds);
}
assert!(
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
);
}
#[test]
fn direct_scroll_resize_and_bounds_changes_cancel_captured_slots() {
for change in 0..5 {
let (f, scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::new(20., 70.), scene.size);
let a = Point::new(
bounds.x + scene.lanes[&f.sender],
bounds.y + scene.slots[0].y,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds,
);
assert!(state.source.is_some());
let changed = match change {
0 => Rectangle::new(
bounds.position() - iced::Vector::new(15., 32.),
bounds.size(),
),
1 => Rectangle::new(bounds.position(), bounds.size() + Size::new(20., 10.)),
_ => bounds,
};
let event = match change {
2 => canvas::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
}),
3 => canvas::Event::Window(iced::window::Event::Resized(Size::new(1100., 700.))),
_ => canvas::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
};
let cursor = if change == 4 {
mouse::Cursor::Unavailable
} else {
mouse::Cursor::Available(a)
};
let event = if change == 4 {
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
} else {
event
};
canvas::Program::update(&scene, &mut state, &event, changed, cursor);
assert!(
state.source.is_none() && state.press.is_none(),
"change {change} must cancel the old slot"
);
let b = Point::new(
changed.x + scene.lanes[&f.receiver],
changed.y + scene.slots.last().unwrap().y,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
changed,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
changed,
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
changed
)
.is_none(),
"a click after change {change} must start a fresh gesture"
);
assert_eq!(state.source.unwrap().0, f.receiver);
assert_ne!(state.source.unwrap().1.before, Some(f.call));
}
}
#[test]
fn direct_bounds_change_during_held_drag_never_commits_old_slot() {
let (f, scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::new(20., 70.), scene.size);
let at = Point::new(
bounds.x + scene.lanes[&f.sender],
bounds.y + scene.slots[0].y,
);
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
let b = Point::new(scene.lanes[&f.receiver], a.y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: at },
at,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
a,
bounds,
);
let shifted = Rectangle::new(
bounds.position() - iced::Vector::new(15., 32.),
bounds.size(),
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
let shifted = Rectangle::new(Point::new(0., -40.), scene.size);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
at,
b,
shifted
)
.is_none()
);
assert!(
state.source.is_some(),
"stationary first click only arms its source"
);
canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
shifted,
mouse::Cursor::Unavailable,
);
assert!(state.source.is_none());
assert!(state.source.is_none() && state.press.is_none());
}
#[test]
fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() {
@@ -1478,6 +1976,125 @@ mod direct_canvas_tests {
);
}
#[test]
fn direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand() {
use super::topology::occurrence;
let f = scenario::direct_tests::fixture();
let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
let fragment = occurrence(
&p,
f.owner,
"Alternatives",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let fragment_id = fragment.id;
let mut commands = vec![Command::CreateElement(fragment)];
let mut branches = vec![];
let mut replies = vec![];
let mut actions = vec![];
for index in 0..3 {
let mut branch = occurrence(
&p,
f.owner,
&format!("Operand {index}"),
OccurrenceBody::Branch {
guard: format!("path {index}"),
occurrences: vec![],
},
);
branch.parent = Some(fragment_id);
let branch_id = branch.id;
branches.push(branch_id);
commands.push(Command::CreateElement(branch));
let mut steps = vec![];
if index < 2 {
let mut reply = occurrence(
&p,
f.owner,
&format!("Reply {index}"),
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: Some(f.receiver),
receiver: Some(f.sender),
message_kind: MessageKind::Reply,
reply_to: Some(f.call),
values: vec![],
},
);
reply.parent = Some(branch_id);
replies.push(reply.id);
steps.push(reply.id);
commands.push(Command::CreateElement(reply));
}
let mut action = occurrence(
&p,
f.owner,
&format!("Action {index}"),
OccurrenceBody::Action {
action: Some(p.relations[&f.exchange].target()),
lifeline: Some(f.receiver),
},
);
action.parent = Some(branch_id);
actions.push(action.id);
steps.push(action.id);
commands.push(Command::CreateElement(action));
commands.push(Command::SetSteps(branch_id, steps));
}
if let Command::CreateElement(element) = &mut commands[0]
&& let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Fragment {
branches: stored, ..
},
..
}) = element.shared_mut()
{
*stored = branches.clone();
}
p = execute_batch(&p, &commands).unwrap();
let scene = Scene::sequence(&p, f.owner, f.call);
let bars: Vec<_> = scene
.boxes
.iter()
.filter(|b| b.id == f.call && b.rect.width == 8.)
.map(|b| b.rect)
.collect();
for reply in &replies {
let y = scene.rows[reply].1;
assert!(
bars.iter().any(|r| r.y <= y && r.y + r.height == y),
"every reply terminates its own operand segment"
);
}
for action in &actions[..2] {
let y = scene.rows[action].1;
assert!(
!bars.iter().any(|r| r.y <= y && r.y + r.height > y),
"a returned operand must not remain active through its following action"
);
}
let last_action = scene.rows[&actions[2]].1;
assert!(
scene.open_calls.iter().any(|p| p.y > last_action),
"the reply-free operand remains explicitly open"
);
let header = scene
.boxes
.iter()
.find(|b| b.id == branches[1] && !b.title.is_empty())
.unwrap()
.rect;
assert!(
!bars
.iter()
.any(|r| r.y < header.y + header.height && r.y + r.height > header.y),
"segments do not join mutually exclusive operand headers"
);
}
#[test]
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
let (f, scene) = scene();
let bar = scene