Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2851 lines
108 KiB
Rust
2851 lines
108 KiB
Rust
//! Readable behavioral projections. Selection edits the same typed records as text.
|
|
use crate::{
|
|
app::{App, Choice, Message},
|
|
style,
|
|
views::{action, heading, label, short},
|
|
};
|
|
use iced::{
|
|
Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse,
|
|
widget::{
|
|
Action, button,
|
|
canvas::{self, Frame, Path, Stroke, Text},
|
|
column, container, pick_list, row, scrollable, text,
|
|
},
|
|
};
|
|
use oa_core::*;
|
|
use std::collections::BTreeMap;
|
|
pub(crate) mod scenario;
|
|
pub(crate) mod state_canvas;
|
|
pub(crate) mod state_edit;
|
|
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,
|
|
process: false,
|
|
})
|
|
}
|
|
pub(super) fn observe_topology<'a>(
|
|
context: topology::Context,
|
|
area: scenario::ScrollArea,
|
|
content: impl Into<Element<'a, Message>>,
|
|
) -> Element<'a, Message> {
|
|
Element::new(Observer {
|
|
content: content.into(),
|
|
context: scenario::Context {
|
|
document: context.document,
|
|
scenario: context.owner,
|
|
revision: context.revision,
|
|
epoch: context.epoch,
|
|
},
|
|
area,
|
|
process: true,
|
|
})
|
|
}
|
|
struct Observer<'a, R = Renderer> {
|
|
content: Element<'a, Message, Theme, R>,
|
|
context: scenario::Context,
|
|
area: scenario::ScrollArea,
|
|
process: bool,
|
|
}
|
|
type Report = Option<(bool, Id, Id, Vector)>;
|
|
struct ObservedScroll {
|
|
identity: (Id, Id),
|
|
report: Report,
|
|
}
|
|
#[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.process,
|
|
self.context.document,
|
|
self.context.scenario,
|
|
offset,
|
|
));
|
|
let previous = &mut tree.state.downcast_mut::<ObservedScroll>().report;
|
|
if *previous != next {
|
|
*previous = next;
|
|
shell.publish(if self.process {
|
|
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
|
|
topology::Context {
|
|
document: self.context.document,
|
|
owner: self.context.scenario,
|
|
revision: self.context.revision,
|
|
epoch: self.context.epoch,
|
|
},
|
|
self.area,
|
|
offset,
|
|
))
|
|
} else {
|
|
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::<ObservedScroll>()
|
|
}
|
|
fn state(&self) -> widget::tree::State {
|
|
widget::tree::State::new(ObservedScroll {
|
|
identity: (self.context.document, self.context.scenario),
|
|
report: None,
|
|
})
|
|
}
|
|
fn children(&self) -> Vec<widget::Tree> {
|
|
vec![widget::Tree::new(self.content.as_widget())]
|
|
}
|
|
fn diff(&self, tree: &mut widget::Tree) {
|
|
if tree.state.downcast_ref::<ObservedScroll>().identity
|
|
!= (self.context.document, self.context.scenario)
|
|
{
|
|
// A new owner/document needs fresh real scroll state, not merely
|
|
// cleared mirrored offsets. Edits and tool/form changes retain it.
|
|
tree.state = self.state();
|
|
tree.children = self.children();
|
|
} else {
|
|
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,
|
|
process: false,
|
|
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
|
.id("observer-test")
|
|
.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 independent_process_owner_change_resets_actual_scroll_offsets() {
|
|
let context = scenario::direct_tests::context(Id::new_v4());
|
|
let redraw = || {
|
|
iced::Event::Window(iced::window::Event::RedrawRequested(
|
|
std::time::Instant::now(),
|
|
))
|
|
};
|
|
let process_offsets = |messages: Vec<Message>| {
|
|
messages
|
|
.into_iter()
|
|
.filter_map(|message| {
|
|
if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
|
|
_,
|
|
_,
|
|
offset,
|
|
)) = message
|
|
{
|
|
Some(offset)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let mut view = observer(600., context);
|
|
view.process = true;
|
|
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
|
assert_eq!(
|
|
process_offsets(update(&mut view, &mut tree, redraw()).0),
|
|
vec![Vector::ZERO]
|
|
);
|
|
let (messages, captured) = update(
|
|
&mut view,
|
|
&mut tree,
|
|
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
|
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
|
|
}),
|
|
);
|
|
assert!(captured);
|
|
assert_eq!(process_offsets(messages), vec![Vector::new(0., 180.)]);
|
|
let mut other = context;
|
|
other.scenario = Id::new_v4();
|
|
let mut state = topology::State::default();
|
|
state.enter(context.document, context.scenario);
|
|
state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.));
|
|
state.enter(other.document, other.scenario);
|
|
assert_eq!(state.scroll_offset(), Vector::ZERO);
|
|
view = observer(600., other);
|
|
view.process = true;
|
|
view.diff(&mut tree);
|
|
let reported = process_offsets(update(&mut view, &mut tree, redraw()).0);
|
|
assert_eq!(
|
|
reported,
|
|
vec![Vector::ZERO],
|
|
"The new process immediately reacquires its predecessor's scroll offset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report()
|
|
{
|
|
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
|
|
for process in [false, true] {
|
|
for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] {
|
|
for change in 0..4 {
|
|
let context = scenario::direct_tests::context(Id::new_v4());
|
|
let mut view = observer(600., context);
|
|
view.process = process;
|
|
view.area = area;
|
|
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
|
let node = view.layout(
|
|
&mut tree,
|
|
&(),
|
|
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
|
|
);
|
|
// Model a programmatic reveal before the observer has reported.
|
|
// Mounted identity must not depend on its last scroll notification.
|
|
let mut operation = scroll_to::<()>(
|
|
widget::Id::new("observer-test"),
|
|
AbsoluteOffset {
|
|
x: None,
|
|
y: Some(180.),
|
|
},
|
|
);
|
|
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
|
|
let mut next = context;
|
|
match change {
|
|
0 => next.scenario = Id::new_v4(),
|
|
1 => next.document = Id::new_v4(),
|
|
2 => {
|
|
next.revision += 1;
|
|
next.epoch += 5;
|
|
}
|
|
_ => {}
|
|
}
|
|
view = observer(if change == 3 { 800. } else { 600. }, next);
|
|
view.process = process;
|
|
view.area = area;
|
|
view.diff(&mut tree);
|
|
let redraw = || {
|
|
iced::Event::Window(iced::window::Event::RedrawRequested(
|
|
std::time::Instant::now(),
|
|
))
|
|
};
|
|
let (messages, _) = update(&mut view, &mut tree, redraw());
|
|
let actual = messages
|
|
.into_iter()
|
|
.filter_map(|message| match message {
|
|
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
|
|
c,
|
|
reported_area,
|
|
offset,
|
|
)) => {
|
|
assert_eq!(
|
|
(c.document, c.owner, c.revision, c.epoch),
|
|
(next.document, next.scenario, next.revision, next.epoch)
|
|
);
|
|
assert_eq!(
|
|
std::mem::discriminant(&reported_area),
|
|
std::mem::discriminant(&area)
|
|
);
|
|
Some(offset)
|
|
}
|
|
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
|
|
c,
|
|
reported_area,
|
|
offset,
|
|
)) => {
|
|
assert_eq!(c, next);
|
|
assert_eq!(
|
|
std::mem::discriminant(&reported_area),
|
|
std::mem::discriminant(&area)
|
|
);
|
|
Some(offset)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
actual,
|
|
vec![if change < 2 {
|
|
Vector::ZERO
|
|
} else {
|
|
Vector::new(0., 180.)
|
|
}],
|
|
"process={process} area={area:?} change={change}"
|
|
);
|
|
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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),
|
|
Scenario(scenario::Event),
|
|
States(states::Event),
|
|
Submit,
|
|
}
|
|
impl Event {
|
|
pub fn starts_form(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Topology(topology::Event::Start(..))
|
|
| Self::Scenario(scenario::Event::Start(..))
|
|
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
|
|
)
|
|
}
|
|
pub fn is_submit(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Submit
|
|
| Self::Topology(topology::Event::Submit)
|
|
| Self::Scenario(scenario::Event::Submit)
|
|
| Self::States(states::Event::Submit)
|
|
)
|
|
}
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
pub active_owner: Option<Id>,
|
|
pub diagram: state_view::State,
|
|
pub(crate) topology: topology::State,
|
|
pub(crate) scenario: scenario::State,
|
|
states: states::State,
|
|
}
|
|
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RecoveryDraft {
|
|
version: u32,
|
|
kind: RecoveryKind,
|
|
draft: serde_json::Value,
|
|
}
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
enum RecoveryKind {
|
|
State,
|
|
Scenario,
|
|
Process,
|
|
}
|
|
impl State {
|
|
/// Capture only the pending authored form. Pointer gestures, cached choices,
|
|
/// viewport geometry and document/revision tokens never cross a restart.
|
|
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
|
let drafts = [
|
|
(RecoveryKind::State, self.states.recovery_snapshot()?),
|
|
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
|
|
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
|
|
];
|
|
let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| {
|
|
draft.map(|draft| RecoveryDraft {
|
|
version: 1,
|
|
kind,
|
|
draft,
|
|
})
|
|
});
|
|
let first = drafts.next();
|
|
if drafts.next().is_some() {
|
|
return Err(
|
|
"Several behavior forms are pending; recovery cannot choose one to discard.".into(),
|
|
);
|
|
}
|
|
first
|
|
.map(serde_json::to_value)
|
|
.transpose()
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Restore raw fields without planning/executing them. Rejected values and
|
|
/// incomplete endpoints must remain available for repair after a crash.
|
|
pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> {
|
|
let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
|
if saved.version != 1 {
|
|
return Err(format!(
|
|
"Unsupported behavior recovery version {}",
|
|
saved.version
|
|
));
|
|
}
|
|
let mut restored = Self::default();
|
|
match saved.kind {
|
|
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
|
|
RecoveryKind::Scenario => {
|
|
restored.scenario = scenario::State::from_recovery(saved.draft)?
|
|
}
|
|
RecoveryKind::Process => {
|
|
restored.topology = topology::State::from_recovery(saved.draft)?
|
|
}
|
|
}
|
|
restored.active_owner = restored.pending_owner();
|
|
*self = restored;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn has_pending(&self) -> bool {
|
|
self.pending_owner().is_some()
|
|
}
|
|
|
|
pub fn pending_owner(&self) -> Option<Id> {
|
|
self.topology
|
|
.pending_owner()
|
|
.or_else(|| self.scenario.pending_owner())
|
|
.or_else(|| self.states.pending_owner())
|
|
}
|
|
|
|
/// Prepare the active form without discarding inputs. The caller must execute
|
|
/// this exact command and acknowledge only a successful document transaction.
|
|
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
|
|
if self.topology.pending_owner().is_some() {
|
|
self.topology.prepare_pending(p).map(Some)
|
|
} else if self.scenario.pending_owner().is_some() {
|
|
self.scenario.prepare_pending(p).map(Some)
|
|
} else if self.states.pending_owner().is_some() {
|
|
self.states.prepare_pending(p).map(Some)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Clear the active form after its prepared command commits successfully.
|
|
/// Rejected commands must leave the form available for repair and retry.
|
|
pub fn acknowledge_commit(&mut self) {
|
|
self.topology.acknowledge_commit();
|
|
self.scenario.acknowledge_commit();
|
|
self.states.acknowledge_commit();
|
|
}
|
|
|
|
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
|
if self.has_pending()
|
|
&& matches!(
|
|
&event,
|
|
Event::Topology(topology::Event::Start(..))
|
|
| Event::Scenario(scenario::Event::Start(..))
|
|
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
|
|
)
|
|
{
|
|
return Err(PENDING_DRAFT.into());
|
|
}
|
|
let cancels_pending = match &event {
|
|
Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(),
|
|
Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(),
|
|
Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(),
|
|
_ => false,
|
|
};
|
|
if cancels_pending {
|
|
self.acknowledge_commit();
|
|
return Ok(None);
|
|
}
|
|
match event {
|
|
Event::Topology(event) => self.topology.update(p, event),
|
|
Event::Scenario(event) => self.scenario.update(p, event),
|
|
Event::States(event) => self.states.update(p, event),
|
|
Event::Submit => self
|
|
.prepare_pending(p)?
|
|
.map(Some)
|
|
.ok_or_else(|| "Choose a behavior modeling tool.".into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn view(app: &App) -> Element<'_, Message> {
|
|
let p = app.project();
|
|
let choices = p
|
|
.elements
|
|
.values()
|
|
.filter(|e| {
|
|
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
|
|
})
|
|
.map(|e| Choice {
|
|
id: e.id,
|
|
label: format!("{} · {}", e.kind(), e.name),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let owner = selected_owner(app);
|
|
let selected = owner
|
|
.and_then(|id| choices.iter().find(|c| c.id == id))
|
|
.or(choices.first())
|
|
.cloned();
|
|
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
|
|
let Some(selected) = selected else {
|
|
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
|
};
|
|
let e = &p.elements[&selected.id];
|
|
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain)
|
|
&& (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty));
|
|
if e.kind() == Kind::StateMachine {
|
|
out = column![text("States & modes").size(20)].spacing(8);
|
|
} else if compact_process {
|
|
// The process canvas owns its compact header and remaining-height layout.
|
|
out = column![].spacing(6);
|
|
} else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
|
out = column![text("Processes & functional chains").size(20)].spacing(8);
|
|
}
|
|
out = out.push(
|
|
pick_list(choices, Some(selected), Message::BehaviorOwner)
|
|
.width(Fill)
|
|
.text_size(13)
|
|
.padding(if compact_process { 6 } else { 10 }),
|
|
);
|
|
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
|
return topology::view(app, e.id, out);
|
|
}
|
|
if e.kind() == Kind::Scenario {
|
|
return scenario::view(app, e.id, out);
|
|
}
|
|
state_view::view(app, e.id, out)
|
|
}
|
|
|
|
pub fn selected_owner(app: &App) -> Option<Id> {
|
|
let valid = |id: &Id| {
|
|
app.project().elements.get(id).is_some_and(|e| {
|
|
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
|
|
})
|
|
};
|
|
app.behavior
|
|
.pending_owner()
|
|
.or(app.behavior.active_owner.filter(valid))
|
|
.or_else(|| selected_behavior(app.project(), app.selected).filter(valid))
|
|
.or_else(|| app.project().elements.keys().find(|id| valid(id)).copied())
|
|
}
|
|
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
|
|
/// exchange-only lists still describe ordered paths until explicitly migrated.
|
|
fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
|
|
let Some(e) = p.elements.get(&owner) else {
|
|
return false;
|
|
};
|
|
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
|
return false;
|
|
}
|
|
p.relations.values().any(|r| {
|
|
r.kind() == RelationKind::SequenceLink
|
|
&& p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner)
|
|
&& p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner)
|
|
}) || e.steps().into_iter().flatten().any(|id| {
|
|
matches!(
|
|
p.elements.get(id).and_then(|e| e.shared()),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Action { .. }
|
|
| OccurrenceBody::Control { .. }
|
|
| OccurrenceBody::SequenceReference { .. },
|
|
..
|
|
})
|
|
)
|
|
})
|
|
}
|
|
pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
|
|
if let Some(Relation {
|
|
body: RelationBody::ContextualLink { owner, .. },
|
|
..
|
|
}) = p.relations.get(&selected)
|
|
{
|
|
return p
|
|
.elements
|
|
.get(owner)
|
|
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
|
.map(|e| e.id);
|
|
}
|
|
let mut current = Some(
|
|
p.relations
|
|
.get(&selected)
|
|
.filter(|r| {
|
|
matches!(
|
|
r.kind(),
|
|
RelationKind::StateTransition
|
|
| RelationKind::SequenceLink
|
|
| RelationKind::StateAction
|
|
)
|
|
})
|
|
.map(|r| r.source())
|
|
.unwrap_or(selected),
|
|
);
|
|
while let Some(id) = current {
|
|
let e = p.elements.get(&id)?;
|
|
if e.kind().is_sequence() || e.kind() == Kind::StateMachine {
|
|
return Some(id);
|
|
}
|
|
current = e.parent;
|
|
}
|
|
None
|
|
}
|
|
fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
|
let mut current = Some(id);
|
|
while let Some(id) = current {
|
|
if id == machine {
|
|
return true;
|
|
}
|
|
current = p.elements.get(&id).and_then(|e| e.parent);
|
|
}
|
|
false
|
|
}
|
|
|
|
struct BoxItem {
|
|
id: Id,
|
|
rect: Rectangle,
|
|
title: String,
|
|
detail: String,
|
|
marker: Option<Kind>,
|
|
}
|
|
struct Line {
|
|
id: Id,
|
|
points: Vec<Point>,
|
|
label: String,
|
|
label_at: Point,
|
|
dashed: bool,
|
|
}
|
|
struct Scene {
|
|
boxes: Vec<BoxItem>,
|
|
lines: Vec<Line>,
|
|
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,
|
|
y: f32,
|
|
label: String,
|
|
}
|
|
#[derive(Default)]
|
|
struct SequenceInput {
|
|
context: Option<scenario::Context>,
|
|
tool: scenario::CanvasTool,
|
|
pointer: Option<Point>,
|
|
bounds: Option<Rectangle>,
|
|
source: Option<(Id, scenario::Slot)>,
|
|
press: Option<SequencePress>,
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
enum SequencePress {
|
|
Message {
|
|
source: Id,
|
|
slot: scenario::Slot,
|
|
start: Point,
|
|
first: bool,
|
|
moved: bool,
|
|
},
|
|
Reorder {
|
|
occurrence: Id,
|
|
parent: Id,
|
|
start: Point,
|
|
moved: bool,
|
|
},
|
|
}
|
|
impl SequenceInput {
|
|
fn cancel(&mut self) {
|
|
self.source = None;
|
|
self.press = None;
|
|
}
|
|
}
|
|
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.),
|
|
selected,
|
|
interaction: None,
|
|
lanes: BTreeMap::new(),
|
|
slots: vec![],
|
|
rows: BTreeMap::new(),
|
|
open_calls: vec![],
|
|
sequence_bounds: BTreeMap::new(),
|
|
};
|
|
let mut lanes = p
|
|
.children(owner)
|
|
.filter(|e| e.kind() == Kind::Lifeline)
|
|
.map(|e| {
|
|
(
|
|
e.id,
|
|
e.name.clone(),
|
|
match e.shared() {
|
|
Some(SharedBody::Lifeline {
|
|
represented: Some(id),
|
|
}) => p.named(*id).to_owned(),
|
|
_ => "Unassigned participant".into(),
|
|
},
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let scenario = p.elements[&owner].kind() == Kind::Scenario;
|
|
if !scenario {
|
|
fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec<Id>) {
|
|
for id in steps {
|
|
if let Some(SharedBody::Occurrence { body, .. }) =
|
|
p.elements.get(id).and_then(|e| e.shared())
|
|
{
|
|
match body {
|
|
OccurrenceBody::Exchange {
|
|
exchange: Some(id), ..
|
|
} => {
|
|
if let Some(r) = p.relations.get(id) {
|
|
for endpoint in [r.source(), r.target()] {
|
|
if !ids.contains(&endpoint) {
|
|
ids.push(endpoint);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
OccurrenceBody::Fragment { branches, .. } => {
|
|
endpoints(p, branches, ids)
|
|
}
|
|
OccurrenceBody::Branch { occurrences, .. } => {
|
|
endpoints(p, occurrences, ids)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut ids = vec![];
|
|
endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids);
|
|
lanes = ids
|
|
.into_iter()
|
|
.map(|id| {
|
|
(
|
|
id,
|
|
p.named(id).to_owned(),
|
|
p.elements
|
|
.get(&id)
|
|
.map(|e| e.kind().label())
|
|
.unwrap_or("Endpoint")
|
|
.into(),
|
|
)
|
|
})
|
|
.collect();
|
|
}
|
|
let lane_x = lanes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
|
|
.collect::<BTreeMap<_, _>>();
|
|
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
|
|
if scenario {
|
|
scene.lanes = lane_x
|
|
.iter()
|
|
.filter(|(id, _)| {
|
|
matches!(
|
|
p.elements.get(id).and_then(|e| e.shared()),
|
|
Some(SharedBody::Lifeline {
|
|
represented: Some(_)
|
|
})
|
|
)
|
|
})
|
|
.map(|(id, x)| (*id, *x))
|
|
.collect();
|
|
}
|
|
let mut y = if scenario { 148. } else { 120. };
|
|
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
|
|
if scenario {
|
|
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.);
|
|
for (id, name, detail) in lanes {
|
|
let x = lane_x[&id];
|
|
scene.lines.insert(
|
|
0,
|
|
Line {
|
|
id,
|
|
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
|
|
label: String::new(),
|
|
label_at: Point::ORIGIN,
|
|
dashed: false,
|
|
},
|
|
);
|
|
scene.boxes.push(BoxItem {
|
|
id,
|
|
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
|
|
title: name,
|
|
detail,
|
|
marker: None,
|
|
});
|
|
}
|
|
scene
|
|
}
|
|
fn occurrences(
|
|
&mut self,
|
|
p: &Project,
|
|
owner: Id,
|
|
scenario: bool,
|
|
lanes: &BTreeMap<Id, f32>,
|
|
y: &mut f32,
|
|
depth: usize,
|
|
) {
|
|
let steps = p
|
|
.elements
|
|
.get(&owner)
|
|
.and_then(|e| {
|
|
e.steps().map(Vec::as_slice).or_else(|| {
|
|
if let Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Fragment { branches, .. },
|
|
..
|
|
}) = e.shared()
|
|
{
|
|
Some(branches.as_slice())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.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;
|
|
};
|
|
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
|
|
continue;
|
|
};
|
|
if ordered {
|
|
let slot = scenario::Slot {
|
|
parent: owner,
|
|
before: Some(*id),
|
|
};
|
|
self.slots.push(SequenceSlot {
|
|
slot,
|
|
y: *y - 42.,
|
|
label: scenario::slot_label(p, slot),
|
|
});
|
|
self.rows.insert(*id, (owner, *y));
|
|
}
|
|
match body {
|
|
OccurrenceBody::Fragment { operator, .. } => {
|
|
let top = *y;
|
|
*y += 55.;
|
|
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
|
let rect = Rectangle::new(
|
|
Point::new(18. + depth as f32 * 10., top),
|
|
Size::new(
|
|
self.size.width - 36. - depth as f32 * 20.,
|
|
(*y - top).max(95.),
|
|
),
|
|
);
|
|
self.boxes.insert(
|
|
0,
|
|
BoxItem {
|
|
id: *id,
|
|
rect,
|
|
title: format!("{operator:?} · {}", e.name),
|
|
detail: String::new(),
|
|
marker: Some(Kind::Occurrence),
|
|
},
|
|
);
|
|
*y += if scenario { 64. } else { 20. };
|
|
}
|
|
OccurrenceBody::Branch { guard, .. } => {
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(
|
|
Point::new(36. + depth as f32 * 10., *y),
|
|
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
|
|
),
|
|
title: format!(
|
|
"[{}]",
|
|
if guard.is_empty() {
|
|
"guard not specified"
|
|
} else {
|
|
guard
|
|
}
|
|
),
|
|
detail: String::new(),
|
|
marker: Some(Kind::Occurrence),
|
|
});
|
|
*y += if scenario { 90. } else { 62. };
|
|
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
|
}
|
|
OccurrenceBody::Exchange {
|
|
exchange,
|
|
sender,
|
|
receiver,
|
|
message_kind,
|
|
..
|
|
} => {
|
|
let r = exchange.and_then(|id| p.relations.get(&id));
|
|
let source = if scenario {
|
|
*sender
|
|
} else {
|
|
r.map(|r| r.source())
|
|
};
|
|
let target = if scenario {
|
|
if matches!(message_kind, MessageKind::Timer { .. }) {
|
|
*sender
|
|
} else {
|
|
*receiver
|
|
}
|
|
} else {
|
|
r.map(|r| r.target())
|
|
};
|
|
if let (Some(a), Some(b)) = (
|
|
source.and_then(|id| lanes.get(&id)),
|
|
target.and_then(|id| lanes.get(&id)),
|
|
) {
|
|
let points = if a == b {
|
|
vec![
|
|
Point::new(*a, *y),
|
|
Point::new(a + 75., *y),
|
|
Point::new(a + 75., *y + 24.),
|
|
Point::new(*a, *y + 24.),
|
|
]
|
|
} else {
|
|
vec![Point::new(*a, *y), Point::new(*b, *y)]
|
|
};
|
|
let kind = if !scenario {
|
|
""
|
|
} else {
|
|
match message_kind {
|
|
MessageKind::Asynchronous => "signal · ",
|
|
MessageKind::Call => "call · ",
|
|
MessageKind::Reply => "reply · ",
|
|
MessageKind::Create => "create · ",
|
|
MessageKind::Delete => "delete · ",
|
|
MessageKind::Timer { .. } => "timer · ",
|
|
}
|
|
};
|
|
let mut message_label = if !scenario {
|
|
r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone())
|
|
} else if let MessageKind::Timer { expression } = message_kind {
|
|
format!("{} · {expression}", e.name)
|
|
} else if let Some(exchange) = r.filter(|r| r.name != e.name) {
|
|
format!("{} · {}", e.name, exchange.name)
|
|
} else {
|
|
e.name.clone()
|
|
};
|
|
if scenario
|
|
&& let Some(operation) = crate::data_values::operation_label(p, e)
|
|
{
|
|
message_label.push_str(" · ");
|
|
message_label.push_str(&operation);
|
|
}
|
|
self.lines.push(Line {
|
|
id: *id,
|
|
points,
|
|
label: format!("{}. {kind}{}", index + 1, message_label),
|
|
label_at: Point::new(a.min(*b) + 12., *y - 26.),
|
|
dashed: matches!(message_kind, MessageKind::Reply),
|
|
});
|
|
} else {
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(
|
|
Point::new(65., *y - 20.),
|
|
Size::new(self.size.width - 130., 65.),
|
|
),
|
|
title: e.name.clone(),
|
|
detail: "Choose an exchange and its lifelines in Properties".into(),
|
|
marker: None,
|
|
});
|
|
}
|
|
*y += 92.;
|
|
}
|
|
OccurrenceBody::Action { action, lifeline }
|
|
| OccurrenceBody::State {
|
|
state: action,
|
|
lifeline,
|
|
} => {
|
|
let x = lifeline
|
|
.and_then(|id| lanes.get(&id))
|
|
.copied()
|
|
.unwrap_or(180.);
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
|
|
title: e.name.clone(),
|
|
detail: action
|
|
.map(|id| p.named(id).to_owned())
|
|
.unwrap_or("Choose observed behavior".into()),
|
|
marker: None,
|
|
});
|
|
*y += 95.;
|
|
}
|
|
OccurrenceBody::Control { kind } => {
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
|
|
title: format!("{kind:?} · {}", e.name),
|
|
detail: "Contextual process control".into(),
|
|
marker: None,
|
|
});
|
|
*y += 95.;
|
|
}
|
|
OccurrenceBody::SequenceReference { sequence } => {
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
|
|
title: e.name.clone(),
|
|
detail: sequence
|
|
.map(|id| format!("Use {}", p.named(id)))
|
|
.unwrap_or("Choose a reusable process or chain".into()),
|
|
marker: None,
|
|
});
|
|
*y += 95.;
|
|
}
|
|
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
|
self.boxes.push(BoxItem {
|
|
id: *id,
|
|
rect: Rectangle::new(
|
|
Point::new(65., *y - 20.),
|
|
Size::new(self.size.width - 130., 70.),
|
|
),
|
|
title: format!("ref · {}", e.name),
|
|
detail: scenario
|
|
.map(|id| {
|
|
format!(
|
|
"{} · {} explicit lifeline bindings",
|
|
p.named(id),
|
|
bindings.len()
|
|
)
|
|
})
|
|
.unwrap_or("Choose a scenario".into()),
|
|
marker: Some(Kind::Occurrence),
|
|
});
|
|
*y += 95.;
|
|
}
|
|
}
|
|
}
|
|
if ordered {
|
|
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
|
let slot = scenario::Slot {
|
|
parent: owner,
|
|
before: None,
|
|
};
|
|
self.slots.push(SequenceSlot {
|
|
slot,
|
|
y: *y - 42.,
|
|
label: scenario::slot_label(p, slot),
|
|
});
|
|
}
|
|
}
|
|
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()
|
|
.min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs()))
|
|
.filter(|slot| parent.is_none_or(|id| id == slot.slot.parent))
|
|
}
|
|
fn lane_at(&self, point: Point) -> Option<Id> {
|
|
if let Some(header) = self
|
|
.boxes
|
|
.iter()
|
|
.find(|b| self.lanes.contains_key(&b.id) && b.rect.contains(point))
|
|
{
|
|
return Some(header.id);
|
|
}
|
|
self.lanes
|
|
.iter()
|
|
.filter(|(_, x)| (**x - point.x).abs() < 12. / self.scale)
|
|
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
|
|
.map(|(id, _)| *id)
|
|
}
|
|
fn reorder_handle(&self) -> Option<Rectangle> {
|
|
let (_, y) = self.rows.get(&self.selected)?;
|
|
Some(Rectangle::new(
|
|
Point::new(6., *y - 12.),
|
|
Size::new(22., 24.),
|
|
))
|
|
}
|
|
fn hit(&self, point: Point) -> Option<Id> {
|
|
self.boxes
|
|
.iter()
|
|
.rev()
|
|
.find(|b| {
|
|
!b.title.is_empty()
|
|
&& b.rect.contains(point)
|
|
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
|
|
})
|
|
.map(|b| b.id)
|
|
.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)
|
|
|| line.points.windows(2).any(|p| {
|
|
crate::diagram::distance_to_segment(point, p[0], p[1])
|
|
< 8. / self.scale
|
|
}))
|
|
})
|
|
.map(|line| line.id)
|
|
})
|
|
.or_else(|| {
|
|
self.boxes
|
|
.iter()
|
|
.rev()
|
|
.find(|b| b.rect.contains(point) && b.title.is_empty())
|
|
.map(|b| b.id)
|
|
})
|
|
.or_else(|| self.lane_at(point))
|
|
.or_else(|| {
|
|
self.lines
|
|
.iter()
|
|
.find(|line| {
|
|
line.label.is_empty()
|
|
&& line.points.windows(2).any(|p| {
|
|
crate::diagram::distance_to_segment(point, p[0], p[1])
|
|
< 8. / self.scale
|
|
})
|
|
})
|
|
.map(|line| line.id)
|
|
})
|
|
}
|
|
}
|
|
impl canvas::Program<Message> for Scene {
|
|
type State = SequenceInput;
|
|
fn update(
|
|
&self,
|
|
state: &mut SequenceInput,
|
|
event: &canvas::Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<Message>> {
|
|
use scenario::{CanvasEvent, CanvasTool};
|
|
let (context, tool, editable) = self
|
|
.interaction
|
|
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
|
(Some(c), t, e)
|
|
});
|
|
if state.context != context || state.tool != tool {
|
|
state.cancel();
|
|
state.pointer = None;
|
|
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();
|
|
}
|
|
// Scrollable marks a pointer outside its visible viewport unavailable,
|
|
// even when it lies inside the full (scrolled) canvas rectangle.
|
|
let visible = cursor.position().is_some();
|
|
if !visible
|
|
&& matches!(
|
|
event,
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
|
)
|
|
{
|
|
state.cancel();
|
|
return Some(Action::request_redraw());
|
|
}
|
|
let cursor = match event {
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
|
state.pointer = Some(*position);
|
|
if let Some(press) = &mut state.press {
|
|
match press {
|
|
SequencePress::Message { start, moved, .. }
|
|
| SequencePress::Reorder { start, moved, .. } => {
|
|
*moved |= position.distance(*start) >= 3.
|
|
}
|
|
}
|
|
}
|
|
mouse::Cursor::Available(*position + self.scroll_offset)
|
|
}
|
|
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;
|
|
return Some(Action::request_redraw());
|
|
}
|
|
canvas::Event::Mouse(
|
|
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
|
) => state
|
|
.pointer
|
|
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
|
|
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
|
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
|
cursor
|
|
}
|
|
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
..
|
|
}) if state.source.is_some() || state.press.is_some() => {
|
|
state.cancel();
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
_ => cursor,
|
|
};
|
|
let world = cursor
|
|
.position_in(bounds)
|
|
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
|
match event {
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
if !visible {
|
|
return None;
|
|
}
|
|
let point = world?;
|
|
if let Some(context) = context {
|
|
if !editable {
|
|
return None;
|
|
}
|
|
if let CanvasTool::Message(_) = tool {
|
|
let first = state.source.is_none();
|
|
let (source, slot) = state.source.or_else(|| {
|
|
Some((self.lane_at(point)?, self.slot_at(point, None)?.slot))
|
|
})?;
|
|
state.source = Some((source, slot));
|
|
state.press = Some(SequencePress::Message {
|
|
source,
|
|
slot,
|
|
start: cursor.position()? - self.scroll_offset,
|
|
first,
|
|
moved: false,
|
|
});
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
|
|
state.press = Some(SequencePress::Reorder {
|
|
occurrence: self.selected,
|
|
parent: self.rows[&self.selected].0,
|
|
start: cursor.position()? - self.scroll_offset,
|
|
moved: false,
|
|
});
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
return self.hit(point).map(|id| {
|
|
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, id)))
|
|
.and_capture()
|
|
});
|
|
}
|
|
self.hit(point)
|
|
.map(|id| Action::publish(Message::Select(id)).and_capture())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
let press = state.press.take()?;
|
|
let context = context?;
|
|
match press {
|
|
SequencePress::Message { source, slot, .. }
|
|
if tool == CanvasTool::Message(scenario::MessageStyle::Timer)
|
|
&& world.is_some() =>
|
|
{
|
|
state.cancel();
|
|
return Some(
|
|
Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin {
|
|
context,
|
|
slot,
|
|
sender: source,
|
|
receiver: source,
|
|
style: scenario::MessageStyle::Timer,
|
|
}))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
SequencePress::Message {
|
|
source,
|
|
slot,
|
|
first,
|
|
moved,
|
|
..
|
|
} if moved || !first => {
|
|
if let Some(receiver) = world.and_then(|p| self.lane_at(p)) {
|
|
let CanvasTool::Message(style) = tool else {
|
|
return None;
|
|
};
|
|
state.cancel();
|
|
return Some(
|
|
Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin {
|
|
context,
|
|
slot,
|
|
sender: source,
|
|
receiver,
|
|
style,
|
|
}))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
}
|
|
SequencePress::Reorder {
|
|
occurrence,
|
|
parent,
|
|
moved: true,
|
|
..
|
|
} => {
|
|
if let Some(slot) = world.and_then(|p| self.slot_at(p, Some(parent))) {
|
|
return Some(
|
|
Action::publish(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
|
context,
|
|
occurrence,
|
|
slot: slot.slot,
|
|
}))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Some(Action::request_redraw().and_capture())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
|
|
Some(Action::request_redraw())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
fn draw(
|
|
&self,
|
|
state: &SequenceInput,
|
|
renderer: &Renderer,
|
|
_: &Theme,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Vec<canvas::Geometry> {
|
|
let mut f = Frame::new(renderer, bounds.size());
|
|
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
|
f.scale(self.scale);
|
|
for b in &self.boxes {
|
|
let outline = matches!(
|
|
b.marker,
|
|
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
|
);
|
|
let color = if b.id == self.selected {
|
|
style::TEAL
|
|
} else {
|
|
style::LINE
|
|
};
|
|
if outline {
|
|
f.stroke(
|
|
&Path::rectangle(b.rect.position(), b.rect.size()),
|
|
Stroke::default().with_color(color),
|
|
);
|
|
} else {
|
|
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
|
|
f.fill(&path, Color::from_rgb8(243, 248, 248));
|
|
f.stroke(
|
|
&path,
|
|
Stroke::default()
|
|
.with_color(color)
|
|
.with_width(if b.id == self.selected { 2.5 } else { 1. }),
|
|
);
|
|
}
|
|
}
|
|
for l in &self.lines {
|
|
let color = if l.id == self.selected {
|
|
style::TEAL
|
|
} else {
|
|
style::MUTED
|
|
};
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(l.points[0]);
|
|
for point in &l.points[1..] {
|
|
p.line_to(*point);
|
|
}
|
|
}),
|
|
Stroke {
|
|
line_dash: canvas::LineDash {
|
|
segments: if l.dashed { &[6., 4.] } else { &[] },
|
|
offset: 0,
|
|
},
|
|
..Stroke::default()
|
|
.with_color(color)
|
|
.with_width(if l.id == self.selected { 2.5 } else { 1.2 })
|
|
},
|
|
);
|
|
if !l.label.is_empty() {
|
|
let end = l.points[l.points.len() - 1];
|
|
let prev = l.points[l.points.len() - 2];
|
|
let delta = end - prev;
|
|
let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
|
|
let d = delta / len;
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.);
|
|
p.line_to(end);
|
|
p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.);
|
|
}),
|
|
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,
|
|
color,
|
|
size: 12.into(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
for b in &self.boxes {
|
|
f.fill_text(Text {
|
|
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
|
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
|
size: 13.into(),
|
|
color: if b.id == self.selected {
|
|
style::TEAL
|
|
} else {
|
|
style::INK
|
|
},
|
|
..Default::default()
|
|
});
|
|
if !b.detail.is_empty() {
|
|
f.fill_text(Text {
|
|
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
|
|
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
|
|
size: 11.into(),
|
|
color: style::MUTED,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
for point in &self.open_calls {
|
|
f.fill_text(Text {
|
|
content: "…".into(),
|
|
position: *point,
|
|
size: 11.into(),
|
|
color: style::MUTED,
|
|
..Default::default()
|
|
});
|
|
}
|
|
if let Some((context, tool, true)) = self.interaction {
|
|
let active = state.context == Some(context) && state.tool == tool;
|
|
let point = cursor
|
|
.position_in(bounds)
|
|
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
|
let source = active.then_some(state.source).flatten();
|
|
let reorder = active.then_some(state.press).flatten().and_then(|press| {
|
|
if let SequencePress::Reorder { parent, .. } = press {
|
|
Some(parent)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if tool == scenario::CanvasTool::Select
|
|
&& let Some(handle) = self.reorder_handle()
|
|
{
|
|
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
|
f.fill_text(Text {
|
|
content: "↕".into(),
|
|
position: handle.position() + iced::Vector::new(5., 2.),
|
|
size: 16.into(),
|
|
color: Color::WHITE,
|
|
..Default::default()
|
|
});
|
|
}
|
|
let slot = if let Some((_, slot)) = source {
|
|
self.slots.iter().find(|s| s.slot == slot)
|
|
} else if matches!(tool, scenario::CanvasTool::Message(_)) || reorder.is_some() {
|
|
point.and_then(|p| self.slot_at(p, reorder))
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(slot) = slot {
|
|
f.stroke(
|
|
&Path::line(
|
|
Point::new(32., slot.y),
|
|
Point::new(self.size.width - 24., slot.y),
|
|
),
|
|
Stroke::default().with_color(style::TEAL).with_width(1.5),
|
|
);
|
|
f.fill_rectangle(
|
|
Point::new(34., slot.y - 20.),
|
|
Size::new((self.size.width - 70.).min(650.), 19.),
|
|
Color::WHITE,
|
|
);
|
|
f.fill_text(Text {
|
|
content: short(&format!("Insert: {}", slot.label), 88),
|
|
position: Point::new(38., slot.y - 19.),
|
|
size: 11.into(),
|
|
color: style::TEAL,
|
|
..Default::default()
|
|
});
|
|
if let Some((source, _)) = source
|
|
&& let Some(x) = self.lanes.get(&source)
|
|
&& let Some(point) = point
|
|
{
|
|
let to = self
|
|
.lane_at(point)
|
|
.and_then(|id| self.lanes.get(&id))
|
|
.copied()
|
|
.unwrap_or(point.x);
|
|
f.stroke(
|
|
&Path::line(Point::new(*x, slot.y), Point::new(to, slot.y)),
|
|
Stroke::default().with_color(style::TEAL).with_width(3.),
|
|
);
|
|
f.fill(&Path::circle(Point::new(to, slot.y), 5.), style::TEAL);
|
|
}
|
|
}
|
|
}
|
|
vec![f.into_geometry()]
|
|
}
|
|
fn mouse_interaction(
|
|
&self,
|
|
_: &SequenceInput,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> mouse::Interaction {
|
|
if cursor.is_over(bounds) {
|
|
mouse::Interaction::Pointer
|
|
} else {
|
|
mouse::Interaction::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod direct_canvas_tests {
|
|
use super::*;
|
|
use scenario::{CanvasEvent, CanvasTool, MessageStyle};
|
|
fn scene() -> (scenario::direct_tests::Fixture, Scene) {
|
|
let f = scenario::direct_tests::fixture();
|
|
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
|
|
scene.interaction = Some((
|
|
scenario::direct_tests::context(f.owner),
|
|
CanvasTool::Message(MessageStyle::CallReply),
|
|
true,
|
|
));
|
|
(f, scene)
|
|
}
|
|
fn send(
|
|
scene: &Scene,
|
|
state: &mut SequenceInput,
|
|
event: mouse::Event,
|
|
final_cursor: Point,
|
|
bounds: Rectangle,
|
|
) -> Option<CanvasEvent> {
|
|
canvas::Program::update(
|
|
scene,
|
|
state,
|
|
&canvas::Event::Mouse(event),
|
|
bounds,
|
|
mouse::Cursor::Available(final_cursor),
|
|
)
|
|
.and_then(|action| action.into_inner().0)
|
|
.and_then(|message| {
|
|
if let Message::ScenarioCanvas(event) = message {
|
|
Some(event)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
#[test]
|
|
fn reused_exchange_messages_display_their_distinct_occurrence_names() {
|
|
let mut f = scenario::direct_tests::fixture();
|
|
f.project.elements.get_mut(&f.call).unwrap().name = "Route request".into();
|
|
f.project.elements.get_mut(&f.reply).unwrap().name = "Route response".into();
|
|
let scene = Scene::sequence(&f.project, f.owner, f.call);
|
|
let call = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
|
let reply = scene.lines.iter().find(|line| line.id == f.reply).unwrap();
|
|
assert!(call.label.starts_with("1. call · Route request"));
|
|
assert!(reply.label.starts_with("2. reply · Route response"));
|
|
assert!(call.label.ends_with(&f.project.relations[&f.exchange].name));
|
|
assert!(
|
|
reply
|
|
.label
|
|
.ends_with(&f.project.relations[&f.exchange].name)
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() {
|
|
for drag in [false, true] {
|
|
let (f, scene) = scene();
|
|
let mut state = SequenceInput::default();
|
|
let bounds = Rectangle::new(Point::new(30., 50.), scene.size);
|
|
let a = Point::new(
|
|
bounds.x + scene.lanes[&f.sender],
|
|
bounds.y + scene.slots[0].y,
|
|
);
|
|
let b = Point::new(bounds.x + scene.lanes[&f.receiver], a.y);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: a },
|
|
b,
|
|
bounds,
|
|
);
|
|
assert!(
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
b,
|
|
bounds
|
|
)
|
|
.is_none()
|
|
);
|
|
if !drag {
|
|
assert!(
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
b,
|
|
bounds
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(state.source.unwrap().0, f.sender);
|
|
}
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: b },
|
|
b,
|
|
bounds,
|
|
);
|
|
if !drag {
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
b,
|
|
bounds,
|
|
);
|
|
}
|
|
let result = send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
b,
|
|
bounds,
|
|
);
|
|
assert!(
|
|
matches!(result, Some(CanvasEvent::Begin { sender, receiver, slot, .. }) if sender == f.sender && receiver == f.receiver && slot.before == Some(f.call))
|
|
);
|
|
assert!(
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
b,
|
|
bounds
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.source.is_none() && state.press.is_none());
|
|
}
|
|
}
|
|
#[test]
|
|
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::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: a },
|
|
a,
|
|
bounds,
|
|
);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
a,
|
|
bounds,
|
|
);
|
|
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),
|
|
b,
|
|
shifted
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.source.is_none() && state.press.is_none());
|
|
}
|
|
#[test]
|
|
fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() {
|
|
for cancellation in 0..5 {
|
|
let (f, mut scene) = scene();
|
|
let mut state = SequenceInput::default();
|
|
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: a },
|
|
a,
|
|
bounds,
|
|
);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
a,
|
|
bounds,
|
|
);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: b },
|
|
b,
|
|
bounds,
|
|
);
|
|
match cancellation {
|
|
0 => {
|
|
scene.interaction.as_mut().unwrap().0.revision += 1;
|
|
}
|
|
1 => {
|
|
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
|
|
}
|
|
2 => {
|
|
scene.interaction.as_mut().unwrap().2 = false;
|
|
}
|
|
3 => {
|
|
canvas::Program::update(
|
|
&scene,
|
|
&mut state,
|
|
&canvas::Event::Window(iced::window::Event::Unfocused),
|
|
bounds,
|
|
mouse::Cursor::Unavailable,
|
|
);
|
|
}
|
|
_ => {
|
|
canvas::Program::update(
|
|
&scene,
|
|
&mut state,
|
|
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
modified_key: iced::keyboard::Key::Named(
|
|
iced::keyboard::key::Named::Escape,
|
|
),
|
|
physical_key: iced::keyboard::key::Physical::Code(
|
|
iced::keyboard::key::Code::Escape,
|
|
),
|
|
location: iced::keyboard::Location::Standard,
|
|
modifiers: iced::keyboard::Modifiers::empty(),
|
|
text: None,
|
|
repeat: false,
|
|
}),
|
|
bounds,
|
|
mouse::Cursor::Available(b),
|
|
);
|
|
}
|
|
}
|
|
assert!(
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
b,
|
|
bounds
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.press.is_none() && state.source.is_none());
|
|
}
|
|
}
|
|
#[test]
|
|
fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() {
|
|
let (f, mut scene) = scene();
|
|
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
|
|
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
|
|
let header = scene
|
|
.boxes
|
|
.iter()
|
|
.find(|b| b.id == f.sender)
|
|
.unwrap()
|
|
.rect
|
|
.center();
|
|
assert_eq!(scene.hit(header), Some(f.sender));
|
|
assert_eq!(
|
|
scene.hit(Point::new(scene.lanes[&f.sender], scene.size.height - 28.)),
|
|
Some(f.sender)
|
|
);
|
|
let line = scene.lines.iter().find(|line| line.id == f.reply).unwrap();
|
|
assert_eq!(
|
|
scene.hit(line.label_at + iced::Vector::new(4., 4.)),
|
|
Some(f.reply)
|
|
);
|
|
let mut state = SequenceInput::default();
|
|
let start = scene.reorder_handle().unwrap().center();
|
|
let end = Point::new(start.x, scene.slots.last().unwrap().y);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: start },
|
|
start,
|
|
bounds,
|
|
);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
start,
|
|
bounds,
|
|
);
|
|
send(
|
|
&scene,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: end },
|
|
end,
|
|
bounds,
|
|
);
|
|
assert!(
|
|
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, bounds), Some(CanvasEvent::Reorder { occurrence, slot, .. }) if occurrence == f.call && slot.parent == f.owner && slot.before.is_none())
|
|
);
|
|
}
|
|
#[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 {
|
|
operation: None,
|
|
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
|
|
.boxes
|
|
.iter()
|
|
.find(|b| b.id == f.call && b.rect.width == 8.)
|
|
.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();
|
|
let scene = Scene::sequence(&p, f.owner, f.call);
|
|
assert_eq!(scene.open_calls.len(), 1);
|
|
assert_eq!(
|
|
scene
|
|
.boxes
|
|
.iter()
|
|
.find(|b| b.id == f.call && b.rect.width == 8.)
|
|
.unwrap()
|
|
.rect
|
|
.height,
|
|
26.
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn recovery_retains_each_incomplete_behavior_form_without_executing_it() {
|
|
let p = Project::demo();
|
|
let before = p.clone();
|
|
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
|
|
let machine = owner(Kind::StateMachine);
|
|
let scenario = owner(Kind::Scenario);
|
|
let process = owner(Kind::Process);
|
|
let cases = [
|
|
(
|
|
machine,
|
|
vec![
|
|
Event::States(states::Event::Start(
|
|
machine,
|
|
states::Tool::Transition,
|
|
machine,
|
|
)),
|
|
Event::States(states::Event::Name("Unfinished timeout".into())),
|
|
Event::States(states::Event::Timed(true)),
|
|
Event::States(states::Event::Expression("-".into())),
|
|
Event::States(states::Event::Guard("temperature >".into())),
|
|
],
|
|
),
|
|
(
|
|
scenario,
|
|
vec![
|
|
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
|
|
Event::Scenario(scenario::Event::Name("Unfinished request".into())),
|
|
Event::Scenario(scenario::Event::Expression("-".into())),
|
|
],
|
|
),
|
|
(
|
|
process,
|
|
vec![
|
|
Event::Topology(topology::Event::Start(
|
|
process,
|
|
topology::Tool::Connect,
|
|
None,
|
|
)),
|
|
Event::Topology(topology::Event::Name("Unfinished path".into())),
|
|
Event::Topology(topology::Event::Guard("available &&".into())),
|
|
],
|
|
),
|
|
];
|
|
for (owner, events) in cases {
|
|
let mut original = State::default();
|
|
for event in events {
|
|
original.update(&p, event).unwrap();
|
|
}
|
|
assert!(original.prepare_pending(&p).is_err());
|
|
let snapshot = original.recovery_snapshot().unwrap().unwrap();
|
|
let bytes = serde_json::to_vec(&snapshot).unwrap();
|
|
let mut recovered = State::default();
|
|
recovered
|
|
.restore_recovery(serde_json::from_slice(&bytes).unwrap())
|
|
.unwrap();
|
|
assert_eq!(recovered.pending_owner(), Some(owner));
|
|
assert_eq!(recovered.active_owner, Some(owner));
|
|
assert!(
|
|
recovered.prepare_pending(&p).is_err(),
|
|
"An incomplete recovered form is not applied or silently repaired"
|
|
);
|
|
assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot));
|
|
assert_eq!(p, before);
|
|
recovered.acknowledge_commit();
|
|
assert!(recovered.recovery_snapshot().unwrap().is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() {
|
|
let p = Project::demo();
|
|
let scenario = p
|
|
.elements
|
|
.values()
|
|
.find(|e| e.kind() == Kind::Scenario)
|
|
.unwrap()
|
|
.id;
|
|
let field = Id::new_v4();
|
|
let paths = [Id::new_v4(), Id::new_v4()];
|
|
let mut state = State::default();
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
|
|
)
|
|
.unwrap();
|
|
for (path, value) in paths.into_iter().zip(["-", "3.4e"]) {
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::Scenario(scenario::Event::Value(
|
|
crate::data_values::FieldPathKey {
|
|
path: vec![path],
|
|
field,
|
|
},
|
|
value.into(),
|
|
)),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let original = state.recovery_snapshot().unwrap().unwrap();
|
|
let mut recovered = State::default();
|
|
recovered.restore_recovery(original.clone()).unwrap();
|
|
assert_eq!(
|
|
recovered.recovery_snapshot().unwrap(),
|
|
Some(original.clone())
|
|
);
|
|
let values = original["draft"]["values"].as_array().unwrap();
|
|
assert_eq!(values.len(), 2);
|
|
assert_ne!(values[0][0], values[1][0]);
|
|
assert_eq!(values[0][1], values[1][1]);
|
|
for bad in [
|
|
{
|
|
let mut value = original.clone();
|
|
value["version"] = 99.into();
|
|
value
|
|
},
|
|
{
|
|
let mut value = original.clone();
|
|
value["draft"]["unknown"] = true.into();
|
|
value
|
|
},
|
|
{
|
|
let mut value = original.clone();
|
|
value["draft"]["values"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(values[0].clone());
|
|
value
|
|
},
|
|
] {
|
|
assert!(recovered.restore_recovery(bad).is_err());
|
|
assert_eq!(
|
|
recovered.recovery_snapshot().unwrap(),
|
|
Some(original.clone()),
|
|
"Malformed recovery cannot discard the currently retained form"
|
|
);
|
|
}
|
|
}
|
|
use crate::document::{Document, DocumentError};
|
|
use oa_core::source_project::{self, ProjectLimits};
|
|
|
|
#[test]
|
|
fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() {
|
|
let p = Project::demo();
|
|
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
|
|
let process = owner(Kind::Process);
|
|
let scenario = owner(Kind::Scenario);
|
|
let machine = owner(Kind::StateMachine);
|
|
let transition = p
|
|
.relations
|
|
.values()
|
|
.find(|r| r.kind() == RelationKind::StateTransition)
|
|
.unwrap()
|
|
.id;
|
|
let starts = [
|
|
Event::Topology(topology::Event::Start(
|
|
process,
|
|
topology::Tool::Control,
|
|
None,
|
|
)),
|
|
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)),
|
|
Event::States(states::Event::Start(machine, states::Tool::Region, machine)),
|
|
];
|
|
let names = [
|
|
Event::Topology(topology::Event::Name("Keep this draft".into())),
|
|
Event::Scenario(scenario::Event::Name("Keep this draft".into())),
|
|
Event::States(states::Event::Name("Keep this draft".into())),
|
|
];
|
|
let cancels = [
|
|
Event::Topology(topology::Event::Cancel),
|
|
Event::Scenario(scenario::Event::Cancel),
|
|
Event::States(states::Event::Cancel),
|
|
];
|
|
for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() {
|
|
let mut state = State::default();
|
|
assert!(!state.has_pending());
|
|
assert!(state.prepare_pending(&p).unwrap().is_none());
|
|
state.update(&p, starts[index].clone()).unwrap();
|
|
state.update(&p, names[index].clone()).unwrap();
|
|
if index == 1 {
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::Scenario(scenario::Event::Expression("approved".into())),
|
|
)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(state.pending_owner(), Some(expected_owner));
|
|
for replacement in starts
|
|
.iter()
|
|
.cloned()
|
|
.chain([Event::States(states::Event::Edit(transition))])
|
|
{
|
|
assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT);
|
|
assert_eq!(state.pending_owner(), Some(expected_owner));
|
|
}
|
|
state.update(&p, cancels[(index + 1) % 3].clone()).unwrap();
|
|
assert!(state.has_pending(), "Another editor's cancel is stale");
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
assert!(state.has_pending(), "Preparation must retain the form");
|
|
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
|
assert!(expected.elements.values().any(|e| {
|
|
!p.elements.contains_key(&e.id)
|
|
&& e.name == "Keep this draft"
|
|
&& e.parent == Some(expected_owner)
|
|
}));
|
|
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
|
document.execute(command).unwrap();
|
|
assert_eq!(document.project(), &expected);
|
|
state.acknowledge_commit();
|
|
assert!(!state.has_pending());
|
|
assert!(state.prepare_pending(document.project()).unwrap().is_none());
|
|
assert_eq!(document.revision(), 1);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), &p);
|
|
assert!(!document.can_undo());
|
|
|
|
state.update(&p, starts[index].clone()).unwrap();
|
|
state.update(&p, cancels[index].clone()).unwrap();
|
|
assert!(!state.has_pending(), "Explicit cancel discards this form");
|
|
state.update(&p, starts[(index + 1) % 3].clone()).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() {
|
|
let mut p = Project::empty();
|
|
let owner = p
|
|
.add_element(Kind::System, Layer::Sa, "System", None)
|
|
.unwrap();
|
|
let machine = p
|
|
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
|
|
.unwrap();
|
|
let region = p
|
|
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
|
|
.unwrap();
|
|
let source = p
|
|
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
|
.unwrap();
|
|
let target = p
|
|
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
|
|
.unwrap();
|
|
let work = p
|
|
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
|
|
.unwrap();
|
|
let choice = |id| Choice {
|
|
id,
|
|
label: p.named(id).into(),
|
|
};
|
|
let mut state = State::default();
|
|
for event in [
|
|
states::Event::Start(machine, states::Tool::Transition, source),
|
|
states::Event::Name("Transition".repeat(1024)),
|
|
states::Event::Trigger(choice(work)),
|
|
states::Event::Effect(choice(work)),
|
|
states::Event::TriggerNotes("On a monitor result".into()),
|
|
states::Event::Guard("connection unavailable".into()),
|
|
states::Event::EffectNotes("Record the timeout".into()),
|
|
states::Event::Timed(true),
|
|
states::Event::TimeKind("At".into()),
|
|
states::Event::Expression("12:30 UTC".into()),
|
|
] {
|
|
state.update(&p, Event::States(event)).unwrap();
|
|
}
|
|
assert!(
|
|
state.prepare_pending(&p).is_err(),
|
|
"A target is still missing"
|
|
);
|
|
assert_eq!(state.pending_owner(), Some(machine));
|
|
state
|
|
.update(&p, Event::States(states::Event::Target(choice(target))))
|
|
.unwrap();
|
|
let command = state.prepare_pending(&p).unwrap().unwrap();
|
|
let bundle = source_project::format_bundle(&p);
|
|
let mut limits = ProjectLimits::default();
|
|
limits.model.max_source_bytes =
|
|
bundle.files.values().map(String::len).sum::<usize>() + 2000;
|
|
let mut document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
|
assert!(matches!(
|
|
document.execute(command),
|
|
Err(DocumentError::Source(_))
|
|
));
|
|
assert_eq!(document.project(), &p);
|
|
assert_eq!(document.bundle(), &bundle);
|
|
assert_eq!(document.revision(), 0);
|
|
assert!(!document.can_undo());
|
|
assert!(state.has_pending());
|
|
|
|
state
|
|
.update(
|
|
&p,
|
|
Event::States(states::Event::Name("Wait for recovery".into())),
|
|
)
|
|
.unwrap();
|
|
let command = state.prepare_pending(&p).unwrap().unwrap();
|
|
let Command::Batch(commands) = &command else {
|
|
panic!("Expected one atomic transition batch")
|
|
};
|
|
assert_eq!(commands.len(), 2);
|
|
let Command::CreateElement(event) = &commands[0] else {
|
|
panic!("Expected a typed time event")
|
|
};
|
|
assert!(
|
|
matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC")
|
|
);
|
|
let Command::CreateRelation(relation) = &commands[1] else {
|
|
panic!("Expected the transition")
|
|
};
|
|
let RelationBody::StateTransition {
|
|
triggers,
|
|
effects,
|
|
trigger,
|
|
guard,
|
|
effect,
|
|
..
|
|
} = &relation.body
|
|
else {
|
|
panic!()
|
|
};
|
|
assert_eq!(triggers, &[work, event.id]);
|
|
assert_eq!(effects, &[work]);
|
|
assert_eq!(trigger, "On a monitor result");
|
|
assert_eq!(guard, "connection unavailable");
|
|
assert_eq!(effect, "Record the timeout");
|
|
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
|
document.execute(command).unwrap();
|
|
assert_eq!(
|
|
document.project(),
|
|
&expected,
|
|
"Commit the exact prepared identities"
|
|
);
|
|
state.acknowledge_commit();
|
|
assert!(!state.has_pending());
|
|
assert_eq!(document.revision(), 1);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &bundle);
|
|
assert_eq!(document.project(), &p);
|
|
assert!(!document.can_undo());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
|
|
let p = Project::demo();
|
|
let owners: Vec<_> = p
|
|
.elements
|
|
.values()
|
|
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
|
.collect();
|
|
assert!(owners.iter().any(|e| e.kind() == Kind::Process));
|
|
assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain));
|
|
for owner in owners {
|
|
assert!(!uses_explicit_topology(&p, owner.id));
|
|
let expected: Vec<_> = owner
|
|
.steps()
|
|
.unwrap()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, id)| {
|
|
let Some(SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::Exchange {
|
|
exchange: Some(exchange),
|
|
..
|
|
},
|
|
..
|
|
}) = p.elements[id].shared()
|
|
else {
|
|
panic!("Demo legacy sequence should contain exchange occurrences");
|
|
};
|
|
format!("{}. {}", index + 1, p.relations[exchange].name)
|
|
})
|
|
.collect();
|
|
assert!(!expected.is_empty());
|
|
let scene = Scene::sequence(&p, owner.id, owner.id);
|
|
let actual: Vec<_> = scene
|
|
.lines
|
|
.iter()
|
|
.filter(|line| !line.label.is_empty())
|
|
.map(|line| line.label.clone())
|
|
.collect();
|
|
assert_eq!(
|
|
actual, expected,
|
|
"Legacy rendering must preserve the model's ordered exchange path"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn selecting_state_transition_keeps_its_behavior_context() {
|
|
let p = Project::demo();
|
|
for r in p
|
|
.relations
|
|
.values()
|
|
.filter(|r| r.kind() == RelationKind::StateTransition)
|
|
{
|
|
let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine");
|
|
assert_eq!(p.elements[&machine].kind(), Kind::StateMachine);
|
|
assert!(in_machine(&p, r.source(), machine));
|
|
assert!(in_machine(&p, r.target(), machine));
|
|
}
|
|
}
|
|
}
|