4111 lines
154 KiB
Rust
4111 lines
154 KiB
Rust
//! Staged scenario authoring. UI picks semantic identities; the core validates
|
|
//! the complete transaction, including correlation, lifecycle and reuse rules.
|
|
use super::topology::{occurrence, unique_batch};
|
|
use super::*;
|
|
use iced::widget::{Column, column, text_input};
|
|
use std::{cell::RefCell, collections::BTreeSet};
|
|
mod camera;
|
|
pub(crate) mod focus;
|
|
pub(crate) mod spacing;
|
|
use super::scenario_layout::{
|
|
Representation,
|
|
presentation::{self, ScenarioPresentation},
|
|
};
|
|
#[cfg(test)]
|
|
mod presentation_tests;
|
|
mod usage;
|
|
mod workspace;
|
|
pub(crate) use workspace::NAME_INPUT;
|
|
pub use workspace::{Shelf, view};
|
|
|
|
#[cfg(test)]
|
|
#[path = "scenario_operation_tests.rs"]
|
|
mod operation_tests;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub enum Tool {
|
|
Lifeline,
|
|
Message,
|
|
Reuse,
|
|
Action,
|
|
Observation,
|
|
Fragment,
|
|
}
|
|
impl Tool {
|
|
pub(super) fn canvas_occurrence(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Reuse | Self::Action | Self::Observation | Self::Fragment
|
|
)
|
|
}
|
|
pub(super) fn uses_lifeline(self) -> bool {
|
|
matches!(self, Self::Action | Self::Observation)
|
|
}
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Lifeline => "Add lifeline",
|
|
Self::Message => "Add message",
|
|
Self::Reuse => "Reuse scenario",
|
|
Self::Action => "Add action",
|
|
Self::Observation => "Observe state",
|
|
Self::Fragment => "Add fragment",
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub enum MessageStyle {
|
|
Asynchronous,
|
|
Call,
|
|
CallReply,
|
|
Reply,
|
|
Create,
|
|
Delete,
|
|
Timer,
|
|
}
|
|
impl std::fmt::Display for MessageStyle {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
Self::Asynchronous => "Asynchronous message",
|
|
Self::Call => "Synchronous call",
|
|
Self::CallReply => "Call + correlated reply",
|
|
Self::Reply => "Reply to existing call",
|
|
Self::Create => "Create participant",
|
|
Self::Delete => "Delete participant",
|
|
Self::Timer => "Local timer",
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Context {
|
|
pub document: Id,
|
|
pub scenario: Id,
|
|
pub representation: Representation,
|
|
pub visit: Id,
|
|
pub revision: u64,
|
|
pub epoch: u64,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Slot {
|
|
pub parent: Id,
|
|
pub before: Option<Id>,
|
|
}
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CanvasTool {
|
|
#[default]
|
|
Select,
|
|
Message(MessageStyle),
|
|
Add(Tool),
|
|
}
|
|
impl std::fmt::Display for CanvasTool {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Select => f.write_str("Select / reorder"),
|
|
Self::Message(style) => write!(f, "{style}"),
|
|
Self::Add(tool) => f.write_str(tool.label()),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum CanvasEvent {
|
|
Spacing(spacing::Event),
|
|
Scrolled(Context, ScrollArea, iced::Vector),
|
|
Restored(Context, Id, ScrollArea, iced::Vector),
|
|
Viewport(Context, Size),
|
|
FormShown(Context, Id),
|
|
Zoom(Context, Option<f32>),
|
|
Camera(Context, u64, super::canvas_workspace::Event),
|
|
Focus(Context),
|
|
Shelf(Context, Shelf),
|
|
OpenUse(Context, Id),
|
|
Back(Context),
|
|
Choose(Context, CanvasTool),
|
|
Representation(Context, Representation),
|
|
Select(Context, Id),
|
|
Begin {
|
|
context: Context,
|
|
slot: Slot,
|
|
sender: Id,
|
|
receiver: Id,
|
|
style: MessageStyle,
|
|
},
|
|
BeginOccurrence {
|
|
context: Context,
|
|
tool: Tool,
|
|
slot: Slot,
|
|
lifeline: Option<Id>,
|
|
},
|
|
Reorder {
|
|
context: Context,
|
|
occurrence: Id,
|
|
slot: Slot,
|
|
},
|
|
}
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ScrollArea {
|
|
// The independent Details/outline scroller; viewport motion uses Camera.
|
|
Page,
|
|
}
|
|
impl CanvasEvent {
|
|
pub fn context(&self) -> Context {
|
|
match self {
|
|
Self::Spacing(event) => event.stamp().context,
|
|
Self::Scrolled(context, _, _)
|
|
| Self::Restored(context, ..)
|
|
| Self::Viewport(context, _)
|
|
| Self::FormShown(context, _)
|
|
| Self::Zoom(context, _)
|
|
| Self::Camera(context, ..)
|
|
| Self::Focus(context)
|
|
| Self::Shelf(context, _)
|
|
| Self::OpenUse(context, _)
|
|
| Self::Back(context)
|
|
| Self::Choose(context, _)
|
|
| Self::Representation(context, _)
|
|
| Self::Select(context, _)
|
|
| Self::Begin { context, .. }
|
|
| Self::BeginOccurrence { context, .. }
|
|
| Self::Reorder { context, .. } => *context,
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Operator(pub FragmentOperator);
|
|
impl std::fmt::Display for Operator {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self.0 {
|
|
FragmentOperator::Alternative => "ALT · alternatives",
|
|
FragmentOperator::Parallel => "PAR · parallel",
|
|
FragmentOperator::Loop => "LOOP · repetition",
|
|
FragmentOperator::Optional => "OPT · optional",
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Source(super::source_draft::Event),
|
|
Start(Id, Tool),
|
|
Cancel,
|
|
Submit,
|
|
Name(String),
|
|
Target(Choice),
|
|
Exchange(Choice),
|
|
Sender(Choice),
|
|
Receiver(Choice),
|
|
Call(Choice),
|
|
Style(MessageStyle),
|
|
OperationIntent(bool),
|
|
Operation(Choice),
|
|
ClearOperation,
|
|
Parent(Choice),
|
|
Expression(String),
|
|
SecondGuard(String),
|
|
Operator(Operator),
|
|
Binding(Id, Choice),
|
|
Value(crate::data_values::FieldPathKey, String),
|
|
ValueFocus(crate::data_values::FieldPathKey),
|
|
ValueBrowse(Option<crate::data_values::FieldPathKey>),
|
|
ValuePage(ValueSection, usize),
|
|
ChoiceSearch(String),
|
|
ChoicePage(usize),
|
|
AppendInstead,
|
|
}
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ValueSection {
|
|
Fields,
|
|
Authored,
|
|
}
|
|
const VALUE_PAGE_SIZE: usize = 24;
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
spacing: Option<spacing::controller::Session>,
|
|
draft: Option<Draft>,
|
|
form_session: Id,
|
|
form_mount: Id,
|
|
form_focus_pending: bool,
|
|
canvas_tool: Option<(Id, CanvasTool)>,
|
|
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
|
details_offset: iced::Vector,
|
|
return_to: Vec<Caller>,
|
|
restore: Option<DetailsRestore>,
|
|
presentation: workspace::Presentation,
|
|
}
|
|
const CALLER_LIMIT: usize = 32;
|
|
pub(crate) struct Caller {
|
|
document: Id,
|
|
pub owner: Id,
|
|
pub representation: Representation,
|
|
pub occurrence: Id,
|
|
target: Id,
|
|
tool: CanvasTool,
|
|
details_offset: iced::Vector,
|
|
presentation: workspace::Presentation,
|
|
}
|
|
struct DetailsRestore {
|
|
document: Id,
|
|
owner: Id,
|
|
representation: Representation,
|
|
visit: Id,
|
|
token: Id,
|
|
offset: iced::Vector,
|
|
}
|
|
#[derive(Clone, Copy)]
|
|
pub(super) struct ScrollRestore {
|
|
pub document: Id,
|
|
pub owner: Id,
|
|
pub representation: Representation,
|
|
pub visit: Id,
|
|
pub token: Id,
|
|
pub offset: iced::Vector,
|
|
}
|
|
#[derive(PartialEq, Eq)]
|
|
struct CallChoiceContext {
|
|
document: Id,
|
|
revision: u64,
|
|
owner: Id,
|
|
parent: Id,
|
|
before: Option<Id>,
|
|
endpoints: Option<(Option<Id>, Option<Id>)>,
|
|
}
|
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Draft {
|
|
#[serde(default)]
|
|
source: super::source_draft::State,
|
|
#[serde(default = "Id::new_v4")]
|
|
invocation: Id,
|
|
#[serde(default)]
|
|
source_origin: Option<(Id, oa_core::Element)>,
|
|
|
|
owner: Id,
|
|
parent: Id,
|
|
before: Option<Id>,
|
|
fixed_endpoints: bool,
|
|
tool: Tool,
|
|
name: String,
|
|
target: Option<Id>,
|
|
exchange: Option<Id>,
|
|
sender: Option<Id>,
|
|
receiver: Option<Id>,
|
|
call: Option<Id>,
|
|
style: MessageStyle,
|
|
#[serde(default)]
|
|
operation: Option<OperationUse>,
|
|
#[serde(default)]
|
|
disabled_operation: Option<OperationUse>,
|
|
expression: String,
|
|
second_guard: String,
|
|
operator: FragmentOperator,
|
|
bindings: BTreeMap<Id, Id>,
|
|
#[serde(with = "recovery_values")]
|
|
values: BTreeMap<crate::data_values::FieldPathKey, String>,
|
|
#[serde(with = "recovery_field_key")]
|
|
value_focus: Option<crate::data_values::FieldPathKey>,
|
|
#[serde(with = "recovery_field_key")]
|
|
value_browse: Option<crate::data_values::FieldPathKey>,
|
|
value_browse_page: usize,
|
|
value_authored_page: usize,
|
|
choice_search: String,
|
|
choice_page: usize,
|
|
}
|
|
impl State {
|
|
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
|
(
|
|
self.canvas_tool,
|
|
self.presentation.focused,
|
|
self.presentation.shelf,
|
|
self.presentation.viewport,
|
|
self.presentation.pan,
|
|
self.presentation.zoom,
|
|
self.presentation.fit,
|
|
)
|
|
}
|
|
|
|
pub(super) fn pending_name(&self) -> Option<&str> {
|
|
self.draft.as_ref().map(|d| d.name.as_str())
|
|
}
|
|
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
|
self.draft
|
|
.as_ref()?
|
|
.source
|
|
.view(p)
|
|
.map(|v| v.map(Event::Source))
|
|
}
|
|
|
|
pub fn source_active(&self) -> bool {
|
|
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
|
}
|
|
|
|
pub fn source_open(&self) -> bool {
|
|
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
|
}
|
|
pub fn source_back(&mut self) -> bool {
|
|
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
|
|
if returned {
|
|
self.remount_form();
|
|
}
|
|
returned
|
|
}
|
|
|
|
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
|
self.enter_representation(document, owner, Representation::Derived { owner });
|
|
}
|
|
/// App validates requested identity with checked_owner before entering.
|
|
pub(crate) fn enter_representation(
|
|
&mut self,
|
|
document: Id,
|
|
owner: Id,
|
|
representation: Representation,
|
|
) -> bool {
|
|
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
|
|
{
|
|
return false;
|
|
}
|
|
let changed = self.presentation.identity != Some((document, owner))
|
|
|| self.presentation.representation != Some(representation);
|
|
if changed {
|
|
self.presentation = workspace::Presentation {
|
|
identity: Some((document, owner)),
|
|
representation: Some(representation),
|
|
focused: self.presentation.focused,
|
|
fit: matches!(representation, Representation::Saved { .. }),
|
|
..Default::default()
|
|
};
|
|
self.details_offset = iced::Vector::ZERO;
|
|
self.canvas_tool = Some((owner, CanvasTool::Select));
|
|
}
|
|
if changed || self.presentation.visit.is_nil() {
|
|
self.spacing = None;
|
|
self.presentation.visit = Id::new_v4();
|
|
self.restore = None;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
/// Suspend captured events while preserving this destination's camera state.
|
|
pub(crate) fn leave(&mut self) {
|
|
self.spacing = None;
|
|
self.presentation.visit = Id::nil();
|
|
self.restore = None;
|
|
}
|
|
pub(crate) fn forget_view(&mut self) {
|
|
self.spacing = None;
|
|
self.presentation.identity = None;
|
|
self.presentation.representation = None;
|
|
self.presentation.visit = Id::nil();
|
|
self.restore = None;
|
|
}
|
|
pub(crate) fn representation(&self) -> Option<Representation> {
|
|
self.presentation.representation
|
|
}
|
|
pub(crate) fn owner(&self) -> Option<Id> {
|
|
self.presentation.identity.map(|(_, owner)| owner)
|
|
}
|
|
pub(crate) fn context(
|
|
&self,
|
|
document: Id,
|
|
owner: Id,
|
|
revision: u64,
|
|
epoch: u64,
|
|
) -> Option<Context> {
|
|
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
|
|
{
|
|
return None;
|
|
}
|
|
Some(Context {
|
|
document,
|
|
scenario: owner,
|
|
representation: self.representation()?,
|
|
visit: self.presentation.visit,
|
|
revision,
|
|
epoch,
|
|
})
|
|
}
|
|
pub(crate) fn display(
|
|
&self,
|
|
p: &Project,
|
|
owner: Id,
|
|
selected: Id,
|
|
) -> Option<ScenarioPresentation> {
|
|
let (_, current_owner) = self.presentation.identity?;
|
|
let representation = self.representation()?;
|
|
(current_owner == owner).then(|| presentation::present(p, representation, selected))
|
|
}
|
|
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
|
|
self.context(
|
|
context.document,
|
|
context.scenario,
|
|
context.revision,
|
|
context.epoch,
|
|
) == Some(context)
|
|
&& self
|
|
.display(p, context.scenario, context.scenario)
|
|
.is_some_and(|shown| shown.capabilities().coordinate_input)
|
|
}
|
|
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
|
self.presentation.viewport(size)
|
|
}
|
|
pub(crate) fn camera(
|
|
&mut self,
|
|
project: &Project,
|
|
owner: Id,
|
|
event: super::canvas_workspace::Event,
|
|
) -> bool {
|
|
let Some(bounds) = self
|
|
.display(project, owner, owner)
|
|
.and_then(|shown| shown.bounds())
|
|
else {
|
|
return false;
|
|
};
|
|
let p = &mut self.presentation;
|
|
camera::change(
|
|
&mut p.pan,
|
|
&mut p.zoom,
|
|
&mut p.fit,
|
|
p.viewport,
|
|
bounds,
|
|
event,
|
|
)
|
|
}
|
|
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
|
self.presentation.zoom(scale)
|
|
}
|
|
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
|
let Some(bounds) = self
|
|
.display(p, owner, owner)
|
|
.and_then(|shown| shown.bounds())
|
|
else {
|
|
return false;
|
|
};
|
|
if let Some(next) = zoom {
|
|
let current = self.presentation.camera(bounds);
|
|
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
|
self.camera(
|
|
p,
|
|
owner,
|
|
super::canvas_workspace::Event::Zoom {
|
|
factor: next / current.scale,
|
|
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
|
base: current.scale,
|
|
},
|
|
)
|
|
} else {
|
|
self.zoom(None)
|
|
}
|
|
}
|
|
pub(crate) fn focused(&self) -> bool {
|
|
self.presentation.focused
|
|
}
|
|
pub(crate) fn toggle_focus(&mut self) {
|
|
self.presentation.focused = !self.presentation.focused;
|
|
}
|
|
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
|
|
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
|
|
}
|
|
pub(crate) fn open_use(
|
|
&mut self,
|
|
p: &Project,
|
|
context: Context,
|
|
occurrence: Id,
|
|
) -> Result<Id, String> {
|
|
let target = referenced_scenario(p, context.scenario, occurrence)
|
|
.ok_or("Choose a scenario reference in the current scenario.")?;
|
|
self.return_to
|
|
.retain(|frame| frame.document == context.document);
|
|
if self.return_to.len() == CALLER_LIMIT {
|
|
self.return_to.remove(0);
|
|
}
|
|
self.return_to.push(Caller {
|
|
document: context.document,
|
|
owner: context.scenario,
|
|
representation: context.representation,
|
|
occurrence,
|
|
target,
|
|
tool: self.canvas_tool(context.scenario),
|
|
details_offset: self.details_offset,
|
|
presentation: self.presentation.clone(),
|
|
});
|
|
self.canvas_tool = Some((target, CanvasTool::Select));
|
|
self.details_offset = iced::Vector::ZERO;
|
|
self.restore = None;
|
|
Ok(target)
|
|
}
|
|
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
|
|
frame.document == context.document
|
|
&& frame.target == context.scenario
|
|
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
|
|
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
|
|
}
|
|
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
|
|
self.return_to
|
|
.iter()
|
|
.any(|frame| Self::valid_caller(p, context, frame))
|
|
}
|
|
pub(crate) fn back(&mut self, p: &Project, context: Context) -> Option<Caller> {
|
|
while let Some(frame) = self.return_to.pop() {
|
|
if Self::valid_caller(p, context, &frame) {
|
|
return Some(frame);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
|
|
if context.document != frame.document
|
|
|| context.scenario != frame.owner
|
|
|| context.representation != frame.representation
|
|
{
|
|
return;
|
|
}
|
|
self.canvas_tool = Some((frame.owner, frame.tool));
|
|
self.details_offset = frame.details_offset;
|
|
self.presentation = frame.presentation;
|
|
self.presentation.identity = Some((context.document, context.scenario));
|
|
self.presentation.representation = Some(frame.representation);
|
|
self.presentation.visit = context.visit;
|
|
self.restore = Some(DetailsRestore {
|
|
document: context.document,
|
|
owner: frame.owner,
|
|
representation: frame.representation,
|
|
visit: context.visit,
|
|
token: Id::new_v4(),
|
|
offset: frame.details_offset,
|
|
});
|
|
}
|
|
pub(super) fn scroll_restore(
|
|
&self,
|
|
context: Context,
|
|
ScrollArea::Page: ScrollArea,
|
|
) -> Option<ScrollRestore> {
|
|
let restore = self.restore.as_ref().filter(|r| {
|
|
r.document == context.document
|
|
&& r.owner == context.scenario
|
|
&& r.representation == context.representation
|
|
&& r.visit == context.visit
|
|
})?;
|
|
Some(ScrollRestore {
|
|
document: restore.document,
|
|
owner: restore.owner,
|
|
representation: restore.representation,
|
|
visit: restore.visit,
|
|
token: restore.token,
|
|
offset: restore.offset,
|
|
})
|
|
}
|
|
pub(crate) fn restored(
|
|
&mut self,
|
|
context: Context,
|
|
token: Id,
|
|
area: ScrollArea,
|
|
offset: iced::Vector,
|
|
) -> bool {
|
|
if !self.restore.as_ref().is_some_and(|r| {
|
|
r.document == context.document
|
|
&& r.owner == context.scenario
|
|
&& r.representation == context.representation
|
|
&& r.visit == context.visit
|
|
&& r.token == token
|
|
}) {
|
|
return false;
|
|
}
|
|
self.restore = None;
|
|
self.scrolled(area, offset)
|
|
}
|
|
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
|
self.draft
|
|
.as_ref()
|
|
.map(serde_json::to_value)
|
|
.transpose()
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
|
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
|
let session = Id::new_v4();
|
|
Ok(Self {
|
|
draft: Some(draft),
|
|
form_session: session,
|
|
form_mount: session,
|
|
form_focus_pending: true,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
pub(crate) fn form_session(&self) -> Option<Id> {
|
|
self.draft.as_ref().map(|_| self.form_session)
|
|
}
|
|
|
|
pub(crate) fn form_mount(&self) -> Option<Id> {
|
|
self.draft
|
|
.as_ref()
|
|
.filter(|d| !d.source.is_open())
|
|
.map(|_| self.form_mount)
|
|
}
|
|
|
|
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
|
|
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
|
|
context,
|
|
self.form_session,
|
|
self.form_mount()?,
|
|
))
|
|
}
|
|
|
|
fn remount_form(&mut self) {
|
|
self.form_mount = Id::new_v4();
|
|
self.form_focus_pending = true;
|
|
}
|
|
|
|
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
|
|
if self.form_mount() == Some(mount) && self.form_focus_pending {
|
|
self.form_focus_pending = false;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
|
|
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
|
|
return false;
|
|
}
|
|
let changed = self.details_offset != offset;
|
|
self.details_offset = offset;
|
|
changed
|
|
}
|
|
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
|
let key = CallChoiceContext {
|
|
document: context.document,
|
|
revision: context.revision,
|
|
owner: draft.owner,
|
|
parent: draft.parent,
|
|
before: draft.before,
|
|
endpoints: draft
|
|
.fixed_endpoints
|
|
.then_some((draft.sender, draft.receiver)),
|
|
};
|
|
let mut cached = self.call_choices.borrow_mut();
|
|
if cached.as_ref().is_none_or(|(previous, _)| *previous != key) {
|
|
*cached = Some((key, calls(p, draft)));
|
|
}
|
|
cached.as_ref().unwrap().1.clone()
|
|
}
|
|
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
|
let draft = self.draft.as_ref()?;
|
|
if self
|
|
.draft
|
|
.as_ref()
|
|
.is_some_and(|d| d.source.label(d.target).is_some())
|
|
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
|
{
|
|
return None;
|
|
}
|
|
fn created(command: &Command, parent: Id) -> Option<Id> {
|
|
match command {
|
|
Command::CreateElement(element) if element.parent == Some(parent) => {
|
|
Some(element.id)
|
|
}
|
|
Command::Batch(commands) => {
|
|
commands.iter().find_map(|command| created(command, parent))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
created(command, draft.parent)
|
|
}
|
|
pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool {
|
|
self.canvas_tool
|
|
.filter(|(id, _)| *id == owner)
|
|
.map(|(_, tool)| tool)
|
|
.unwrap_or_default()
|
|
}
|
|
pub(crate) fn choose_tool(&mut self, owner: Id, tool: CanvasTool) {
|
|
self.canvas_tool = Some((owner, tool));
|
|
}
|
|
pub(crate) fn begin_message(
|
|
&mut self,
|
|
p: &Project,
|
|
context: Context,
|
|
slot: Slot,
|
|
sender: Id,
|
|
receiver: Id,
|
|
style: MessageStyle,
|
|
) -> Result<(), String> {
|
|
slot_index(p, context.scenario, slot)?;
|
|
for line in [sender, receiver] {
|
|
if p.elements
|
|
.get(&line)
|
|
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(context.scenario))
|
|
|| represented(p, line).is_none()
|
|
{
|
|
return Err("Choose represented lifelines in this scenario.".into());
|
|
}
|
|
}
|
|
self.update(p, Event::Start(context.scenario, Tool::Message))?;
|
|
let draft = self.draft.as_mut().expect("Start installed the draft");
|
|
draft.parent = slot.parent;
|
|
draft.before = slot.before;
|
|
draft.fixed_endpoints = true;
|
|
draft.sender = Some(sender);
|
|
draft.receiver = (style != MessageStyle::Timer).then_some(receiver);
|
|
draft.style = style;
|
|
Ok(())
|
|
}
|
|
pub(crate) fn begin_occurrence(
|
|
&mut self,
|
|
p: &Project,
|
|
context: Context,
|
|
tool: Tool,
|
|
slot: Slot,
|
|
lifeline: Option<Id>,
|
|
) -> Result<(), String> {
|
|
if !tool.canvas_occurrence() {
|
|
return Err(
|
|
"Choose an action, state observation, scenario reference or fragment.".into(),
|
|
);
|
|
}
|
|
slot_index(p, context.scenario, slot)?;
|
|
if let Some(line) = lifeline
|
|
&& (!tool.uses_lifeline()
|
|
|| p.elements.get(&line).is_none_or(|e| {
|
|
e.kind() != Kind::Lifeline || e.parent != Some(context.scenario)
|
|
})
|
|
|| represented(p, line).is_none())
|
|
{
|
|
return Err("Choose a represented lifeline in this scenario.".into());
|
|
}
|
|
self.update(p, Event::Start(context.scenario, tool))?;
|
|
let draft = self.draft.as_mut().expect("Start installed the draft");
|
|
draft.parent = slot.parent;
|
|
draft.before = slot.before;
|
|
draft.sender = lifeline;
|
|
Ok(())
|
|
}
|
|
pub(super) fn pending_placement(&self, owner: Id) -> Option<(Slot, Option<Id>)> {
|
|
let draft = self
|
|
.draft
|
|
.as_ref()
|
|
.filter(|d| d.owner == owner && d.tool != Tool::Lifeline)?;
|
|
Some((
|
|
Slot {
|
|
parent: draft.parent,
|
|
before: draft.before,
|
|
},
|
|
draft.sender,
|
|
))
|
|
}
|
|
pub(super) fn pending_owner(&self) -> Option<Id> {
|
|
self.draft.as_ref().map(|d| d.owner)
|
|
}
|
|
|
|
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
|
let command = self
|
|
.draft
|
|
.as_ref()
|
|
.ok_or("Choose a scenario tool first.")?
|
|
.command(p)?;
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
|
Ok(command)
|
|
}
|
|
|
|
pub(super) fn acknowledge_commit(&mut self) {
|
|
self.draft = None;
|
|
*self.call_choices.get_mut() = None;
|
|
}
|
|
|
|
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
|
if let Event::Source(event) = event {
|
|
if self
|
|
.draft
|
|
.as_mut()
|
|
.ok_or("Start the consumer form first.")?
|
|
.source
|
|
.dismiss(&event)?
|
|
{
|
|
self.remount_form();
|
|
return Ok(None);
|
|
}
|
|
let d = self
|
|
.draft
|
|
.as_ref()
|
|
.ok_or("Start the consumer form first.")?;
|
|
if d.tool != Tool::Lifeline {
|
|
return Err("This reference does not support staged source creation.".into());
|
|
}
|
|
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
|
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
|
}) {
|
|
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
|
}
|
|
if d.source_origin.is_none() && d.source.engaged() {
|
|
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
|
}
|
|
let mut next = d.clone();
|
|
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
|
{
|
|
next.source_origin = Some((
|
|
p.id,
|
|
p.elements
|
|
.get(&d.owner)
|
|
.ok_or("The consumer owner no longer exists.")?
|
|
.clone(),
|
|
));
|
|
}
|
|
if let Some(id) = next.source.update(
|
|
p,
|
|
super::source_draft::Scope::Lifeline {
|
|
owner: d.owner,
|
|
viewpoint: viewpoint(p, d.owner),
|
|
},
|
|
d.invocation,
|
|
event,
|
|
)? {
|
|
next.target = Some(id);
|
|
next.command(p)?;
|
|
}
|
|
let returned = d.source.is_open() && !next.source.is_open();
|
|
self.draft = Some(next);
|
|
if returned {
|
|
self.remount_form();
|
|
}
|
|
return Ok(None);
|
|
}
|
|
if self.source_open() && !matches!(&event, Event::Cancel) {
|
|
return Err("Use the new source or go Back before editing the parent form.".into());
|
|
}
|
|
|
|
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
|
return Err(PENDING_DRAFT.into());
|
|
}
|
|
match event {
|
|
Event::Start(owner, tool) => {
|
|
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
|
return Err("Choose a scenario.".into());
|
|
}
|
|
self.form_session = Id::new_v4();
|
|
self.form_mount = self.form_session;
|
|
self.form_focus_pending = true;
|
|
self.draft = Some(Draft {
|
|
source: Default::default(),
|
|
invocation: Id::new_v4(),
|
|
source_origin: Some((p.id, p.elements[&owner].clone())),
|
|
owner,
|
|
parent: owner,
|
|
before: None,
|
|
fixed_endpoints: false,
|
|
tool,
|
|
name: String::new(),
|
|
target: None,
|
|
exchange: None,
|
|
sender: None,
|
|
receiver: None,
|
|
call: None,
|
|
style: MessageStyle::CallReply,
|
|
operation: None,
|
|
disabled_operation: None,
|
|
expression: String::new(),
|
|
second_guard: "else".into(),
|
|
operator: FragmentOperator::Alternative,
|
|
bindings: BTreeMap::new(),
|
|
values: BTreeMap::new(),
|
|
value_focus: None,
|
|
value_browse: None,
|
|
value_browse_page: 0,
|
|
value_authored_page: 0,
|
|
choice_search: String::new(),
|
|
choice_page: 0,
|
|
});
|
|
}
|
|
Event::Cancel => self.acknowledge_commit(),
|
|
Event::Submit => return self.prepare_pending(p).map(Some),
|
|
other => {
|
|
if let Some(d) = &mut self.draft {
|
|
match other {
|
|
Event::Name(s) => d.name = s,
|
|
Event::Expression(s) => d.expression = s,
|
|
Event::SecondGuard(s) => d.second_guard = s,
|
|
Event::OperationIntent(enabled) => {
|
|
if enabled {
|
|
d.operation = Some(
|
|
d.operation
|
|
.clone()
|
|
.or_else(|| d.disabled_operation.take())
|
|
.unwrap_or(OperationUse { allocation: None }),
|
|
);
|
|
} else {
|
|
d.disabled_operation =
|
|
d.operation.take().or_else(|| d.disabled_operation.take());
|
|
}
|
|
}
|
|
Event::Operation(c) => {
|
|
d.operation = Some(OperationUse {
|
|
allocation: Some(c.id),
|
|
})
|
|
}
|
|
Event::ClearOperation => {
|
|
d.operation = Some(OperationUse { allocation: None })
|
|
}
|
|
Event::Target(c) => {
|
|
d.target = Some(c.id);
|
|
d.bindings.clear();
|
|
}
|
|
Event::Exchange(c) => {
|
|
if d.exchange != Some(c.id) {
|
|
d.exchange = Some(c.id);
|
|
if !d.has_operation_intent(p) {
|
|
d.reset_values();
|
|
}
|
|
if !d.fixed_endpoints {
|
|
d.sender = None;
|
|
d.receiver = None;
|
|
}
|
|
}
|
|
}
|
|
Event::Sender(c) => d.sender = Some(c.id),
|
|
Event::Receiver(c) => d.receiver = Some(c.id),
|
|
Event::Parent(c) => {
|
|
if d.parent != c.id {
|
|
d.parent = c.id;
|
|
d.before = None;
|
|
}
|
|
}
|
|
Event::ChoiceSearch(value) => {
|
|
d.choice_search = value;
|
|
d.choice_page = 0;
|
|
}
|
|
Event::ChoicePage(page) => d.choice_page = page,
|
|
Event::AppendInstead => d.before = None,
|
|
Event::Style(style) => {
|
|
let previous = d.style;
|
|
d.style = style;
|
|
if previous != style {
|
|
if previous == MessageStyle::Timer || style == MessageStyle::Timer {
|
|
d.exchange = None;
|
|
d.sender = None;
|
|
d.receiver = None;
|
|
d.call = None;
|
|
} else if (previous == MessageStyle::Reply)
|
|
!= (style == MessageStyle::Reply)
|
|
{
|
|
std::mem::swap(&mut d.sender, &mut d.receiver);
|
|
d.call = None;
|
|
}
|
|
if !d.has_operation_intent(p) {
|
|
d.retain_value_paths(p);
|
|
}
|
|
}
|
|
}
|
|
Event::Call(c) => {
|
|
d.call = Some(c.id);
|
|
if let Some(SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::Exchange {
|
|
exchange,
|
|
sender,
|
|
receiver,
|
|
..
|
|
},
|
|
..
|
|
}) = p.elements.get(&c.id).and_then(|e| e.shared())
|
|
{
|
|
if d.exchange != *exchange && !d.has_operation_intent(p) {
|
|
d.reset_values();
|
|
}
|
|
d.exchange = *exchange;
|
|
d.sender = *receiver;
|
|
d.receiver = *sender;
|
|
}
|
|
}
|
|
Event::Operator(operator) => d.operator = operator.0,
|
|
Event::Binding(referenced, c) => {
|
|
d.bindings.insert(referenced, c.id);
|
|
}
|
|
Event::Value(id, s) => {
|
|
d.values.insert(id, s);
|
|
}
|
|
Event::ValueFocus(key)
|
|
if d.signature(p)
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|r| {
|
|
crate::data_values::resolve(p, &r, &d.value_kind(), &key)
|
|
})
|
|
.is_some() =>
|
|
{
|
|
d.value_focus = Some(key);
|
|
}
|
|
Event::ValueBrowse(key) => {
|
|
if key.as_ref().is_none_or(|key| {
|
|
d.signature(p)
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|r| {
|
|
crate::data_values::resolve(p, &r, &d.value_kind(), key)
|
|
})
|
|
.is_some()
|
|
}) {
|
|
d.value_browse = key;
|
|
d.value_browse_page = 0;
|
|
}
|
|
}
|
|
Event::ValuePage(section, page) => match section {
|
|
ValueSection::Fields => d.value_browse_page = page,
|
|
ValueSection::Authored => d.value_authored_page = page,
|
|
},
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
mod recovery_values {
|
|
use super::*;
|
|
use serde::{Deserialize, Serialize};
|
|
pub fn serialize<S: serde::Serializer>(
|
|
values: &BTreeMap<crate::data_values::FieldPathKey, String>,
|
|
serializer: S,
|
|
) -> Result<S::Ok, S::Error> {
|
|
values
|
|
.iter()
|
|
.map(|(key, value)| (&key.path, key.field, value))
|
|
.collect::<Vec<_>>()
|
|
.serialize(serializer)
|
|
}
|
|
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
|
|
deserializer: D,
|
|
) -> Result<BTreeMap<crate::data_values::FieldPathKey, String>, D::Error> {
|
|
let values = Vec::<(Vec<Id>, Id, String)>::deserialize(deserializer)?;
|
|
let mut result = BTreeMap::new();
|
|
for (path, field, value) in values {
|
|
if result
|
|
.insert(crate::data_values::FieldPathKey { path, field }, value)
|
|
.is_some()
|
|
{
|
|
return Err(serde::de::Error::custom(
|
|
"Duplicate recovered scenario value path",
|
|
));
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
mod recovery_field_key {
|
|
use super::*;
|
|
use serde::{Deserialize, Serialize};
|
|
pub fn serialize<S: serde::Serializer>(
|
|
key: &Option<crate::data_values::FieldPathKey>,
|
|
serializer: S,
|
|
) -> Result<S::Ok, S::Error> {
|
|
key.as_ref()
|
|
.map(|key| (&key.path, key.field))
|
|
.serialize(serializer)
|
|
}
|
|
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
|
|
deserializer: D,
|
|
) -> Result<Option<crate::data_values::FieldPathKey>, D::Error> {
|
|
Ok(Option::<(Vec<Id>, Id)>::deserialize(deserializer)?
|
|
.map(|(path, field)| crate::data_values::FieldPathKey { path, field }))
|
|
}
|
|
}
|
|
fn viewpoint(p: &Project, owner: Id) -> ScenarioViewpoint {
|
|
match p.elements.get(&owner).and_then(|e| e.shared()) {
|
|
Some(SharedBody::Scenario { viewpoint, .. }) => *viewpoint,
|
|
_ => ScenarioViewpoint::MixedLegacy,
|
|
}
|
|
}
|
|
pub(crate) fn referenced_scenario(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
|
|
if p.elements.get(&owner)?.kind() != Kind::Scenario
|
|
|| validation::ancestor_of_kind(p, occurrence, Kind::Scenario) != Some(owner)
|
|
{
|
|
return None;
|
|
}
|
|
let SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::ScenarioUse {
|
|
scenario: Some(target),
|
|
..
|
|
},
|
|
..
|
|
} = p.elements.get(&occurrence)?.shared()?
|
|
else {
|
|
return None;
|
|
};
|
|
p.elements
|
|
.get(target)
|
|
.filter(|e| e.kind() == Kind::Scenario)
|
|
.map(|e| e.id)
|
|
}
|
|
fn represented(p: &Project, line: Id) -> Option<Id> {
|
|
match p.elements.get(&line)?.shared()? {
|
|
SharedBody::Lifeline { represented } => *represented,
|
|
_ => None,
|
|
}
|
|
}
|
|
fn named_choice(p: &Project, id: Id) -> Choice {
|
|
Choice {
|
|
id,
|
|
label: format!(
|
|
"{} · {}",
|
|
p.named(id),
|
|
p.elements
|
|
.get(&id)
|
|
.map(|e| e.alias.as_str())
|
|
.or_else(|| p.relations.get(&id).map(|r| r.alias.as_str()))
|
|
.unwrap_or("")
|
|
),
|
|
}
|
|
}
|
|
fn lines(p: &Project, owner: Id) -> Vec<Choice> {
|
|
p.children(owner)
|
|
.filter(|e| e.kind() == Kind::Lifeline)
|
|
.map(|e| named_choice(p, e.id))
|
|
.collect()
|
|
}
|
|
fn parents(p: &Project, owner: Id) -> Vec<Choice> {
|
|
let mut out = vec![Choice {
|
|
id: owner,
|
|
label: "Main scenario sequence".into(),
|
|
}];
|
|
out.extend(
|
|
p.elements
|
|
.values()
|
|
.filter(|e| {
|
|
matches!(
|
|
e.shared(),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Branch { .. },
|
|
..
|
|
})
|
|
) && validation::ancestor_of_kind(p, e.id, Kind::Scenario) == Some(owner)
|
|
})
|
|
.map(|e| named_choice(p, e.id)),
|
|
);
|
|
out
|
|
}
|
|
fn slot_index(p: &Project, owner: Id, slot: Slot) -> Result<usize, String> {
|
|
if !parents(p, owner)
|
|
.iter()
|
|
.any(|choice| choice.id == slot.parent)
|
|
{
|
|
return Err("Choose an insertion slot in this scenario or one of its operands.".into());
|
|
}
|
|
let steps = p
|
|
.elements
|
|
.get(&slot.parent)
|
|
.and_then(oa_core::Element::steps)
|
|
.ok_or("This sequence no longer exists.")?;
|
|
slot.before.map_or(Ok(steps.len()), |id| {
|
|
steps
|
|
.iter()
|
|
.position(|step| *step == id)
|
|
.ok_or_else(|| "The insertion slot changed. Choose its position again.".into())
|
|
})
|
|
}
|
|
pub(super) fn slot_label(p: &Project, slot: Slot) -> String {
|
|
match slot.before {
|
|
Some(id) => format!(
|
|
"{} · before {}",
|
|
named_choice(p, slot.parent).label,
|
|
named_choice(p, id).label
|
|
),
|
|
None => format!("{} · append", named_choice(p, slot.parent).label),
|
|
}
|
|
}
|
|
pub(crate) fn reorder(
|
|
p: &Project,
|
|
owner: Id,
|
|
occurrence: Id,
|
|
slot: Slot,
|
|
) -> Result<Command, String> {
|
|
let to = slot_index(p, owner, slot)?;
|
|
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
|
|
return Err(
|
|
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
|
|
.into(),
|
|
);
|
|
}
|
|
let mut steps = p.elements[&slot.parent]
|
|
.steps()
|
|
.expect("slot validated")
|
|
.clone();
|
|
let from = steps
|
|
.iter()
|
|
.position(|id| *id == occurrence)
|
|
.ok_or("This occurrence is not in the sequence.")?;
|
|
steps.remove(from);
|
|
steps.insert(to - usize::from(from < to), occurrence);
|
|
let command = Command::SetSteps(slot.parent, steps);
|
|
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
|
|
Ok(command)
|
|
}
|
|
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
|
|
represented(p, referenced)
|
|
.zip(represented(p, local))
|
|
.is_some_and(|(a, b)| validation::semantic_representation_compatible(p, b, a) == Some(true))
|
|
}
|
|
fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
|
let e = occurrence(p, owner, "Scenario selection preview", body);
|
|
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
|
}
|
|
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
|
let Some(owner) = p
|
|
.elements
|
|
.get(&d.owner)
|
|
.filter(|e| e.kind() == Kind::Scenario)
|
|
else {
|
|
return vec![];
|
|
};
|
|
let layer = owner.layer();
|
|
let mut values: Vec<_> = p
|
|
.elements
|
|
.values()
|
|
.filter(|e| {
|
|
e.layer() == layer
|
|
&& match d.tool {
|
|
Tool::Lifeline => {
|
|
validation::viewpoint_represents(viewpoint(p, d.owner), e.kind())
|
|
}
|
|
Tool::Action => e.kind().is_work(),
|
|
Tool::Observation => e.kind().is_state(),
|
|
Tool::Reuse => {
|
|
e.kind() == Kind::Scenario
|
|
&& valid_body_choice(
|
|
p,
|
|
d.owner,
|
|
OccurrenceBody::ScenarioUse {
|
|
scenario: Some(e.id),
|
|
bindings: vec![],
|
|
},
|
|
)
|
|
}
|
|
_ => false,
|
|
}
|
|
})
|
|
.map(|e| named_choice(p, e.id))
|
|
.collect();
|
|
values.sort_by(|a, b| a.label.cmp(&b.label));
|
|
values
|
|
}
|
|
fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
|
// Candidate browsing concerns the declared viewpoint and represented
|
|
// endpoints. The complete transaction still validates the chosen slot,
|
|
// lifecycle, payload and paired reply on Apply.
|
|
let viewpoint = viewpoint(p, d.owner);
|
|
p.relations
|
|
.values()
|
|
.filter(|r| {
|
|
p.layer_of(r.id) == p.layer_of(d.owner)
|
|
&& matches!(
|
|
r.kind(),
|
|
RelationKind::Interaction
|
|
| RelationKind::CommunicationMean
|
|
| RelationKind::FunctionalExchange
|
|
| RelationKind::BoundaryExchange
|
|
)
|
|
&& match viewpoint {
|
|
ScenarioViewpoint::OperationalActivity => r.kind() == RelationKind::Interaction,
|
|
ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange,
|
|
ScenarioViewpoint::ParticipantInteraction => matches!(
|
|
r.kind(),
|
|
RelationKind::Interaction
|
|
| RelationKind::CommunicationMean
|
|
| RelationKind::BoundaryExchange
|
|
),
|
|
ScenarioViewpoint::BoundaryInterface => matches!(
|
|
r.kind(),
|
|
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
|
),
|
|
ScenarioViewpoint::MixedLegacy => true,
|
|
}
|
|
&& (!d.fixed_endpoints
|
|
|| d.sender.zip(d.receiver).is_some_and(|(sender, receiver)| {
|
|
let mut body = d.message_body();
|
|
if let OccurrenceBody::Exchange { exchange, .. } = &mut body {
|
|
*exchange = Some(r.id);
|
|
}
|
|
validation::message_endpoints(p, &body).is_some_and(|(source, target)| {
|
|
validation::lifeline_matches_endpoint(p, sender, source) == Some(true)
|
|
&& validation::lifeline_matches_endpoint(p, receiver, target)
|
|
== Some(true)
|
|
})
|
|
}))
|
|
})
|
|
.map(|r| Choice {
|
|
id: r.id,
|
|
label: format!(
|
|
"{} · {} {} {}",
|
|
r.name,
|
|
p.named(r.source()),
|
|
if matches!(
|
|
r.body,
|
|
RelationBody::CommunicationMean {
|
|
oriented: false,
|
|
..
|
|
} | RelationBody::BoundaryExchange {
|
|
oriented: false,
|
|
..
|
|
}
|
|
) {
|
|
"↔"
|
|
} else {
|
|
"→"
|
|
},
|
|
p.named(r.target())
|
|
),
|
|
})
|
|
.collect()
|
|
}
|
|
fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
|
p.elements
|
|
.values()
|
|
.filter(|e| validation::ancestor_of_kind(p, e.id, Kind::Scenario) == Some(d.owner))
|
|
.filter_map(|e| {
|
|
let Some(SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::Exchange {
|
|
message_kind: MessageKind::Call,
|
|
exchange,
|
|
sender,
|
|
receiver,
|
|
..
|
|
},
|
|
..
|
|
}) = e.shared()
|
|
else {
|
|
return None;
|
|
};
|
|
if d.fixed_endpoints && (d.sender != *receiver || d.receiver != *sender) {
|
|
return None;
|
|
}
|
|
let mut candidate = d.clone();
|
|
candidate.style = MessageStyle::Reply;
|
|
candidate.call = Some(e.id);
|
|
// Candidate availability must not fluctuate while typing names or
|
|
// expressions. These draft values are validated only on Apply.
|
|
candidate.reset_values();
|
|
candidate.name.clear();
|
|
candidate.exchange = *exchange;
|
|
candidate.sender = *receiver;
|
|
candidate.receiver = *sender;
|
|
candidate
|
|
.command(p)
|
|
.ok()
|
|
.and_then(|command| execute_batch(p, &[command]).ok())
|
|
.map(|_| named_choice(p, e.id))
|
|
})
|
|
.collect()
|
|
}
|
|
impl Draft {
|
|
fn has_operation_intent(&self, p: &Project) -> bool {
|
|
self.operation.is_some()
|
|
|| self
|
|
.call
|
|
.and_then(|id| p.elements.get(&id))
|
|
.is_some_and(|e| {
|
|
matches!(
|
|
e.shared(),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange {
|
|
operation: Some(_),
|
|
..
|
|
},
|
|
..
|
|
})
|
|
)
|
|
})
|
|
}
|
|
fn proposed_message(&self, p: &Project) -> oa_core::Element {
|
|
let mut message = self.append(p, "Operation preview", self.message_body());
|
|
// This is a transient resolver input; no generated identity reaches a command.
|
|
message.id = Id::nil();
|
|
message
|
|
}
|
|
fn signature(&self, p: &Project) -> Result<Option<Relation>, String> {
|
|
crate::data_values::message_signature(p, &self.proposed_message(p))
|
|
}
|
|
fn reset_values(&mut self) {
|
|
self.values.clear();
|
|
self.value_focus = None;
|
|
self.value_browse = None;
|
|
self.value_browse_page = 0;
|
|
self.value_authored_page = 0;
|
|
}
|
|
fn retain_value_paths(&mut self, p: &Project) {
|
|
let kind = self.kind();
|
|
let exchange = self.exchange.and_then(|id| p.relations.get(&id));
|
|
self.values.retain(|key, _| {
|
|
exchange
|
|
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
|
.is_some()
|
|
});
|
|
if self.value_focus.as_ref().is_some_and(|key| {
|
|
exchange
|
|
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
|
.is_none()
|
|
}) {
|
|
self.value_focus = None;
|
|
}
|
|
if self.value_browse.as_ref().is_some_and(|key| {
|
|
exchange
|
|
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
|
.is_none()
|
|
}) {
|
|
self.value_browse = None;
|
|
self.value_browse_page = 0;
|
|
}
|
|
}
|
|
fn browsed_values(
|
|
&self,
|
|
p: &Project,
|
|
exchange: &Relation,
|
|
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
|
|
match &self.value_browse {
|
|
Some(key) => crate::data_values::children(p, exchange, &self.value_kind(), key),
|
|
None => Ok(crate::data_values::roots(p, exchange, &self.value_kind())),
|
|
}
|
|
}
|
|
fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> {
|
|
self.values
|
|
.iter()
|
|
.filter(|(_, value)| !value.trim().is_empty())
|
|
.map(|(key, value)| (key, value.as_str()))
|
|
.collect()
|
|
}
|
|
fn value_kind(&self) -> MessageKind {
|
|
crate::data_values::operation_value_phase(&self.kind(), self.operation.as_ref())
|
|
}
|
|
fn kind(&self) -> MessageKind {
|
|
match self.style {
|
|
MessageStyle::Asynchronous => MessageKind::Asynchronous,
|
|
MessageStyle::Call | MessageStyle::CallReply => MessageKind::Call,
|
|
MessageStyle::Reply => MessageKind::Reply,
|
|
MessageStyle::Create => MessageKind::Create,
|
|
MessageStyle::Delete => MessageKind::Delete,
|
|
MessageStyle::Timer => MessageKind::Timer {
|
|
expression: self.expression.clone(),
|
|
},
|
|
}
|
|
}
|
|
fn message_body(&self) -> OccurrenceBody {
|
|
OccurrenceBody::Exchange {
|
|
exchange: if self.style == MessageStyle::Timer {
|
|
None
|
|
} else {
|
|
self.exchange
|
|
},
|
|
sender: self.sender,
|
|
receiver: if self.style == MessageStyle::Timer {
|
|
None
|
|
} else {
|
|
self.receiver
|
|
},
|
|
message_kind: self.kind(),
|
|
operation: if matches!(
|
|
self.style,
|
|
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
|
) {
|
|
self.operation.clone()
|
|
} else {
|
|
None
|
|
},
|
|
reply_to: self.call,
|
|
values: self
|
|
.values
|
|
.iter()
|
|
.filter(|(_, v)| !v.trim().is_empty())
|
|
.map(|(field, expression)| MessageValue {
|
|
path: field.path.clone(),
|
|
field: field.field,
|
|
expression: expression.clone(),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
fn line_choices(&self, p: &Project, sender: bool) -> Vec<Choice> {
|
|
let endpoint = validation::message_endpoints(p, &self.message_body())
|
|
.map(|(a, b)| if sender { a } else { b });
|
|
lines(p, self.owner)
|
|
.into_iter()
|
|
.filter(|line| {
|
|
if self
|
|
.exchange
|
|
.and_then(|id| p.relations.get(&id))
|
|
.is_some_and(|r| {
|
|
matches!(
|
|
r.body,
|
|
RelationBody::CommunicationMean { .. }
|
|
| RelationBody::BoundaryExchange {
|
|
oriented: false,
|
|
..
|
|
}
|
|
)
|
|
})
|
|
{
|
|
let mut body = self.message_body();
|
|
if let OccurrenceBody::Exchange {
|
|
sender: from,
|
|
receiver: to,
|
|
..
|
|
} = &mut body
|
|
{
|
|
if sender {
|
|
*from = Some(line.id);
|
|
} else {
|
|
*to = Some(line.id);
|
|
}
|
|
}
|
|
return !matches!(
|
|
validation::resolve_message_endpoints(p, &body),
|
|
validation::MessageEndpointResolution::Conflict
|
|
);
|
|
}
|
|
endpoint.is_none_or(|endpoint| {
|
|
validation::lifeline_matches_endpoint(p, line.id, endpoint) == Some(true)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
fn append(&self, p: &Project, name: &str, body: OccurrenceBody) -> oa_core::Element {
|
|
let mut e = occurrence(p, self.owner, name, body);
|
|
e.parent = Some(self.parent);
|
|
e
|
|
}
|
|
fn command(&self, p: &Project) -> Result<Command, String> {
|
|
if self.source.engaged()
|
|
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
|
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
|
})
|
|
{
|
|
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
|
}
|
|
self.source.check_scope(
|
|
super::source_draft::Scope::Lifeline {
|
|
owner: self.owner,
|
|
viewpoint: viewpoint(p, self.owner),
|
|
},
|
|
self.target,
|
|
)?;
|
|
let preview = self.source.preview(p, self.target)?;
|
|
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
|
self.source.combine(p, self.target, command)
|
|
}
|
|
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
|
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
|
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
|
}
|
|
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
|
return Err("Choose this scenario or one of its operands as destination.".into());
|
|
}
|
|
let default = self
|
|
.target
|
|
.or(self.exchange)
|
|
.map(|id| p.named(id))
|
|
.unwrap_or(self.tool.label());
|
|
let name = if self.name.trim().is_empty() {
|
|
default
|
|
} else if self.source.label(self.target).is_some() {
|
|
self.name.as_str()
|
|
} else {
|
|
self.name.trim()
|
|
};
|
|
let mut commands = vec![];
|
|
match self.tool {
|
|
Tool::Lifeline => {
|
|
let target = self
|
|
.target
|
|
.ok_or("Choose the work or participant represented by this lifeline.")?;
|
|
if !sources(p, self).iter().any(|c| c.id == target) {
|
|
return Err(
|
|
"Choose a representation compatible with the scenario viewpoint.".into(),
|
|
);
|
|
}
|
|
let mut e = p
|
|
.make_element(
|
|
Kind::Lifeline,
|
|
p.elements[&self.owner].layer(),
|
|
name,
|
|
Some(self.owner),
|
|
)
|
|
.unwrap();
|
|
*e.shared_mut().unwrap() = SharedBody::Lifeline {
|
|
represented: Some(target),
|
|
};
|
|
commands.push(Command::CreateElement(e));
|
|
}
|
|
Tool::Message => {
|
|
let sender = self.sender.ok_or("Choose the sending lifeline.")?;
|
|
if !self.line_choices(p, true).iter().any(|c| c.id == sender) {
|
|
return Err("The sender must represent the exchange source.".into());
|
|
}
|
|
if self.style == MessageStyle::Timer {
|
|
if self.expression.trim().is_empty() {
|
|
return Err("Enter the timer duration or deadline.".into());
|
|
}
|
|
} else {
|
|
if self.exchange.is_none() {
|
|
return Err("Choose the exchange carried by this message.".into());
|
|
}
|
|
let receiver = self.receiver.ok_or("Choose the receiving lifeline.")?;
|
|
if !self.line_choices(p, false).iter().any(|c| c.id == receiver) {
|
|
return Err("The receiver must represent the exchange target.".into());
|
|
}
|
|
}
|
|
if self.style == MessageStyle::Reply && self.call.is_none() {
|
|
return Err("Choose the earlier call answered by this reply.".into());
|
|
}
|
|
let call = self.append(p, name, self.message_body());
|
|
let call_id = call.id;
|
|
commands.push(Command::CreateElement(call));
|
|
if self.style == MessageStyle::CallReply {
|
|
commands.push(Command::CreateElement(self.append(
|
|
p,
|
|
&format!("Reply · {name}"),
|
|
OccurrenceBody::Exchange {
|
|
exchange: self.exchange,
|
|
sender: self.receiver,
|
|
receiver: self.sender,
|
|
message_kind: MessageKind::Reply,
|
|
operation: None,
|
|
reply_to: Some(call_id),
|
|
values: vec![],
|
|
},
|
|
)));
|
|
}
|
|
}
|
|
Tool::Reuse => {
|
|
let target = self.target.ok_or("Choose a scenario to reuse.")?;
|
|
if !sources(p, self).iter().any(|c| c.id == target) {
|
|
return Err("Choose a compatible scenario without recursive reuse.".into());
|
|
}
|
|
let mut used = BTreeSet::new();
|
|
let mut bindings = vec![];
|
|
for line in lines(p, target) {
|
|
let local = *self
|
|
.bindings
|
|
.get(&line.id)
|
|
.ok_or("Bind every referenced lifeline to a local lifeline.")?;
|
|
if !lines(p, self.owner).iter().any(|c| c.id == local)
|
|
|| !compatible_binding(p, line.id, local)
|
|
|| !used.insert(local)
|
|
{
|
|
return Err(
|
|
"Each binding needs a distinct compatible local lifeline.".into()
|
|
);
|
|
}
|
|
bindings.push(LifelineBinding {
|
|
referenced: line.id,
|
|
local,
|
|
});
|
|
}
|
|
commands.push(Command::CreateElement(self.append(
|
|
p,
|
|
name,
|
|
OccurrenceBody::ScenarioUse {
|
|
scenario: Some(target),
|
|
bindings,
|
|
},
|
|
)));
|
|
}
|
|
Tool::Action | Tool::Observation => {
|
|
let target = self
|
|
.target
|
|
.ok_or("Choose the action or state to observe.")?;
|
|
if !sources(p, self).iter().any(|c| c.id == target) {
|
|
return Err("Choose compatible work or a state in this layer.".into());
|
|
}
|
|
let line = self.sender.ok_or("Choose the observed lifeline.")?;
|
|
if !lines(p, self.owner).iter().any(|c| c.id == line) {
|
|
return Err("Choose a lifeline in this scenario.".into());
|
|
}
|
|
let body = if self.tool == Tool::Action {
|
|
OccurrenceBody::Action {
|
|
action: Some(target),
|
|
lifeline: Some(line),
|
|
}
|
|
} else {
|
|
OccurrenceBody::State {
|
|
state: Some(target),
|
|
lifeline: Some(line),
|
|
}
|
|
};
|
|
commands.push(Command::CreateElement(self.append(p, name, body)));
|
|
}
|
|
Tool::Fragment => {
|
|
let two = matches!(
|
|
self.operator,
|
|
FragmentOperator::Alternative | FragmentOperator::Parallel
|
|
);
|
|
if self.operator != FragmentOperator::Parallel && self.expression.trim().is_empty()
|
|
{
|
|
return Err("Describe the operand condition or loop guard.".into());
|
|
}
|
|
let fragment = self.append(
|
|
p,
|
|
name,
|
|
OccurrenceBody::Fragment {
|
|
operator: self.operator,
|
|
branches: vec![],
|
|
},
|
|
);
|
|
let id = fragment.id;
|
|
commands.push(Command::CreateElement(fragment));
|
|
for (i, guard) in [&self.expression, &self.second_guard]
|
|
.into_iter()
|
|
.take(if two { 2 } else { 1 })
|
|
.enumerate()
|
|
{
|
|
let mut branch = self.append(
|
|
p,
|
|
&format!("{} · operand {}", name, i + 1),
|
|
OccurrenceBody::Branch {
|
|
guard: if self.operator == FragmentOperator::Parallel {
|
|
String::new()
|
|
} else {
|
|
guard.clone()
|
|
},
|
|
occurrences: vec![],
|
|
},
|
|
);
|
|
branch.parent = Some(id);
|
|
commands.push(Command::CreateElement(branch));
|
|
}
|
|
}
|
|
}
|
|
if self.tool != Tool::Lifeline {
|
|
let slot = Slot {
|
|
parent: self.parent,
|
|
before: self.before,
|
|
};
|
|
let index = slot_index(p, self.owner, slot)?;
|
|
let added: Vec<_> = commands
|
|
.iter()
|
|
.filter_map(|command| match command {
|
|
Command::CreateElement(e)
|
|
if e.parent == Some(self.parent) && e.kind() == Kind::Occurrence =>
|
|
{
|
|
Some(e.id)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let mut steps = p.elements[&self.parent]
|
|
.steps()
|
|
.expect("slot validated")
|
|
.clone();
|
|
steps.splice(index..index, added);
|
|
commands.push(Command::SetSteps(self.parent, steps));
|
|
}
|
|
Ok(unique_batch(p, commands))
|
|
}
|
|
}
|
|
fn event(e: Event) -> Message {
|
|
Message::Behavior(super::Event::Scenario(e))
|
|
}
|
|
fn value_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
|
let start = requested.min(len.saturating_sub(1) / VALUE_PAGE_SIZE) * VALUE_PAGE_SIZE;
|
|
start..(start + VALUE_PAGE_SIZE).min(len)
|
|
}
|
|
fn value_pager(len: usize, requested: usize, section: ValueSection) -> Element<'static, Message> {
|
|
let range = value_page(len, requested);
|
|
let page = range.start / VALUE_PAGE_SIZE;
|
|
row![
|
|
button("Previous")
|
|
.on_press_maybe((page > 0).then(|| event(Event::ValuePage(section, page - 1)))),
|
|
text(format!(
|
|
"Page {} of {} · {} fields",
|
|
page + 1,
|
|
len.div_ceil(VALUE_PAGE_SIZE).max(1),
|
|
len
|
|
))
|
|
.size(11),
|
|
button("Next")
|
|
.on_press_maybe((range.end < len).then(|| event(Event::ValuePage(section, page + 1))))
|
|
]
|
|
.spacing(6)
|
|
.into()
|
|
}
|
|
fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a, Message> {
|
|
use crate::data_values;
|
|
let mut out = column![text("Message values").size(14)].spacing(7);
|
|
if let Some(browse) = &d.value_browse {
|
|
let title = data_values::resolve(p, exchange, &d.value_kind(), browse)
|
|
.map(|choice| format!("Fields inside {}", choice.label))
|
|
.unwrap_or_else(|| "This record path is no longer available in this message.".into());
|
|
out = out.push(text(title).size(11)).push(
|
|
row![
|
|
button("Root fields").on_press(event(Event::ValueBrowse(None))),
|
|
button("Up one level")
|
|
.on_press(event(Event::ValueBrowse(data_values::parent(browse))))
|
|
]
|
|
.spacing(6),
|
|
);
|
|
} else {
|
|
out = out.push(text("Root fields · choose a record to browse its fields").size(11));
|
|
}
|
|
match d.browsed_values(p, exchange) {
|
|
Ok(choices) if !choices.is_empty() => {
|
|
let range = value_page(choices.len(), d.value_browse_page);
|
|
let visible = choices[range].to_vec();
|
|
let selected = visible
|
|
.iter()
|
|
.find(|choice| Some(&choice.key) == d.value_focus.as_ref())
|
|
.cloned();
|
|
out = out
|
|
.push(value_pager(
|
|
choices.len(),
|
|
d.value_browse_page,
|
|
ValueSection::Fields,
|
|
))
|
|
.push(
|
|
pick_list(visible, selected, |choice| {
|
|
event(Event::ValueFocus(choice.key))
|
|
})
|
|
.placeholder("Choose a field on this page…")
|
|
.text_size(12)
|
|
.padding(8)
|
|
.width(Fill),
|
|
);
|
|
}
|
|
Ok(_) => {
|
|
out = out.push(text("No fields at this level for this message phase.").size(11));
|
|
}
|
|
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
|
}
|
|
match d
|
|
.value_focus
|
|
.as_ref()
|
|
.map(|key| data_values::resolve(p, exchange, &d.value_kind(), key))
|
|
{
|
|
Some(Some(focus)) => {
|
|
let key = focus.key;
|
|
out = out
|
|
.push(text(focus.label).size(12))
|
|
.push(
|
|
text_input(
|
|
"Optional value expression",
|
|
d.values.get(&key).map(String::as_str).unwrap_or(""),
|
|
)
|
|
.on_input({
|
|
let key = key.clone();
|
|
move |value| event(Event::Value(key.clone(), value))
|
|
})
|
|
.padding(8)
|
|
.size(12),
|
|
)
|
|
.push(text("An empty expression omits this value.").size(11));
|
|
let mut actions = row![
|
|
button("Show enclosing fields")
|
|
.on_press(event(Event::ValueBrowse(data_values::parent(&key))))
|
|
]
|
|
.spacing(6);
|
|
if matches!(p.elements.get(&key.field).and_then(oa_core::Element::shared),
|
|
Some(SharedBody::DataField { type_ref: TypeRef::Named(record),
|
|
storage: FieldStorage::Value, .. }) if data::is_record(p, *record))
|
|
{
|
|
actions = actions.push(
|
|
button("Browse record fields").on_press(event(Event::ValueBrowse(Some(key)))),
|
|
);
|
|
}
|
|
out = out.push(actions);
|
|
}
|
|
Some(None) => {
|
|
out = out.push(text("The selected path is unavailable in this message. Choose a compatible field; entered values remain in the draft.")
|
|
.size(11).color(style::MUTED));
|
|
}
|
|
None => out = out.push(text("Choose a field to enter its value expression.").size(11)),
|
|
}
|
|
let authored = d.authored_values();
|
|
out = out.push(text(format!("Authored values ({})", authored.len())).size(12));
|
|
if authored.is_empty() {
|
|
out = out.push(text("No value expressions entered.").size(11));
|
|
} else {
|
|
out = out.push(value_pager(
|
|
authored.len(),
|
|
d.value_authored_page,
|
|
ValueSection::Authored,
|
|
));
|
|
for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] {
|
|
let title = data_values::resolve(p, exchange, &d.value_kind(), key)
|
|
.map(|choice| choice.label)
|
|
.unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field));
|
|
out = out.push(
|
|
button(
|
|
column![text(title).size(11), text(short(expression, 100)).size(11)].spacing(3),
|
|
)
|
|
.on_press(event(Event::ValueFocus((*key).clone())))
|
|
.width(Fill),
|
|
);
|
|
}
|
|
}
|
|
out.into()
|
|
}
|
|
fn picker<'a>(
|
|
choices: Vec<Choice>,
|
|
selected: Option<Id>,
|
|
placeholder: &'a str,
|
|
change: impl Fn(Choice) -> Event + 'a,
|
|
) -> Element<'a, Message> {
|
|
let selected = selected
|
|
.and_then(|id| choices.iter().find(|c| c.id == id))
|
|
.cloned();
|
|
pick_list(choices, selected, move |c| event(change(c)))
|
|
.placeholder(placeholder)
|
|
.width(Fill)
|
|
.padding(9)
|
|
.text_size(12)
|
|
.into()
|
|
}
|
|
fn bounded_picker<'a>(
|
|
mut choices: Vec<Choice>,
|
|
d: &'a Draft,
|
|
selected: Option<Id>,
|
|
placeholder: &'a str,
|
|
change: impl Fn(Choice) -> Event + 'a,
|
|
) -> Element<'a, Message> {
|
|
let current = choices
|
|
.iter()
|
|
.find(|choice| Some(choice.id) == selected)
|
|
.cloned();
|
|
let query = d.choice_search.trim().to_lowercase();
|
|
choices.retain(|choice| choice.label.to_lowercase().contains(&query));
|
|
choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id)));
|
|
let len = choices.len();
|
|
let range = value_page(len, d.choice_page);
|
|
let page = range.start / VALUE_PAGE_SIZE;
|
|
let mut out = column![
|
|
text_input("Search compatible exchanges / calls…", &d.choice_search)
|
|
.on_input(|value| event(Event::ChoiceSearch(value)))
|
|
.padding(8)
|
|
.size(12)
|
|
]
|
|
.spacing(6);
|
|
if let Some(choice) = current.as_ref().filter(|choice| {
|
|
!choices[range.clone()]
|
|
.iter()
|
|
.any(|visible| visible.id == choice.id)
|
|
}) {
|
|
out = out.push(label(format!("Selected: {}", choice.label)));
|
|
}
|
|
if len == 0 {
|
|
out = out.push(label("No matching exchange or eligible earlier call. Change the search, endpoints or destination, or cancel."));
|
|
} else {
|
|
out = out
|
|
.push(
|
|
row![
|
|
button("Previous")
|
|
.on_press_maybe((page > 0).then(|| event(Event::ChoicePage(page - 1)))),
|
|
text(format!(
|
|
"{} choices · page {}/{}",
|
|
len,
|
|
page + 1,
|
|
len.div_ceil(VALUE_PAGE_SIZE)
|
|
))
|
|
.size(11),
|
|
button("Next").on_press_maybe(
|
|
(range.end < len).then(|| event(Event::ChoicePage(page + 1)))
|
|
),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.push(
|
|
pick_list(choices[range].to_vec(), current, move |choice| {
|
|
event(change(choice))
|
|
})
|
|
.placeholder(placeholder)
|
|
.padding(9)
|
|
.text_size(12)
|
|
.width(Fill),
|
|
);
|
|
}
|
|
out.into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod direct_tests {
|
|
use super::*;
|
|
pub struct Fixture {
|
|
pub project: Project,
|
|
pub owner: Id,
|
|
pub exchange: Id,
|
|
pub sender: Id,
|
|
pub receiver: Id,
|
|
pub call: Id,
|
|
pub reply: Id,
|
|
}
|
|
pub fn fixture() -> Fixture {
|
|
let f = crate::data_values::tests::payload();
|
|
let mut p = f.project;
|
|
let mut lines = vec![];
|
|
for (name, represented) in [
|
|
("Caller", p.relations[&f.exchange].source()),
|
|
("Callee", p.relations[&f.exchange].target()),
|
|
] {
|
|
let id = p
|
|
.add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario))
|
|
.unwrap();
|
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
|
represented: Some(represented),
|
|
};
|
|
lines.push(id);
|
|
}
|
|
let mut state = State::default();
|
|
for event in [
|
|
Event::Start(f.scenario, Tool::Message),
|
|
Event::Exchange(named_choice(&p, f.exchange)),
|
|
Event::Sender(named_choice(&p, lines[0])),
|
|
Event::Receiver(named_choice(&p, lines[1])),
|
|
] {
|
|
state.update(&p, event).unwrap();
|
|
}
|
|
p = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
|
|
let steps = p.elements[&f.scenario].steps().unwrap();
|
|
let (call, reply) = (steps[0], steps[1]);
|
|
Fixture {
|
|
project: p,
|
|
owner: f.scenario,
|
|
exchange: f.exchange,
|
|
sender: lines[0],
|
|
receiver: lines[1],
|
|
call,
|
|
reply,
|
|
}
|
|
}
|
|
pub fn context(owner: Id) -> Context {
|
|
Context {
|
|
document: Id::new_v4(),
|
|
scenario: owner,
|
|
representation: Representation::Derived { owner },
|
|
visit: Id::new_v4(),
|
|
revision: 0,
|
|
epoch: 0,
|
|
}
|
|
}
|
|
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
|
let mut f = fixture();
|
|
let p = &mut f.project;
|
|
let machine = p
|
|
.add_element(Kind::StateMachine, Layer::Oa, "Context", None)
|
|
.unwrap();
|
|
let region = p
|
|
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
|
|
.unwrap();
|
|
let state = p
|
|
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
|
.unwrap();
|
|
let reused = p
|
|
.add_element(Kind::Scenario, Layer::Oa, "Reusable check", None)
|
|
.unwrap();
|
|
let remote = p
|
|
.add_element(Kind::Lifeline, Layer::Oa, "Reused caller", Some(reused))
|
|
.unwrap();
|
|
let represented = represented(p, f.sender);
|
|
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
|
|
SharedBody::Lifeline { represented };
|
|
let fragment = occurrence(
|
|
p,
|
|
f.owner,
|
|
"Outer alternative",
|
|
OccurrenceBody::Fragment {
|
|
operator: FragmentOperator::Alternative,
|
|
branches: vec![],
|
|
},
|
|
);
|
|
let fragment_id = fragment.id;
|
|
*p = execute_batch(p, &[Command::CreateElement(fragment)]).unwrap();
|
|
let branch = occurrence(
|
|
p,
|
|
fragment_id,
|
|
"Chosen operand",
|
|
OccurrenceBody::Branch {
|
|
guard: "ready".into(),
|
|
occurrences: vec![],
|
|
},
|
|
);
|
|
let branch_id = branch.id;
|
|
*p = execute_batch(p, &[Command::CreateElement(branch)]).unwrap();
|
|
let anchor = occurrence(
|
|
p,
|
|
branch_id,
|
|
"Existing check",
|
|
OccurrenceBody::Action {
|
|
action: represented,
|
|
lifeline: Some(f.sender),
|
|
},
|
|
);
|
|
let anchor_id = anchor.id;
|
|
*p = execute_batch(p, &[Command::CreateElement(anchor)]).unwrap();
|
|
(f, branch_id, anchor_id, state, reused, remote)
|
|
}
|
|
fn canvas_mouse(
|
|
scene: &Scene,
|
|
input: &mut SequenceInput,
|
|
event: mouse::Event,
|
|
point: Point,
|
|
bounds: Rectangle,
|
|
) -> Option<Message> {
|
|
canvas::Program::update(
|
|
scene,
|
|
input,
|
|
&canvas::Event::Mouse(event),
|
|
bounds,
|
|
mouse::Cursor::Available(point),
|
|
)
|
|
.and_then(|action| action.into_inner().0)
|
|
}
|
|
fn form(app: &mut App, event: Event) {
|
|
let _ = app.update(Message::Behavior(super::super::Event::Scenario(event)));
|
|
}
|
|
pub fn navigation_fixture() -> (Project, [Id; 3], [Id; 3]) {
|
|
let mut p = Project::empty();
|
|
let owners = ["A caller", "B definition", "C definition"].map(|name| {
|
|
p.add_element(Kind::Scenario, Layer::Oa, name, None)
|
|
.unwrap()
|
|
});
|
|
let uses = [
|
|
(owners[0], owners[1]),
|
|
(owners[0], owners[1]),
|
|
(owners[1], owners[2]),
|
|
]
|
|
.map(|(owner, target)| {
|
|
let element = occurrence(
|
|
&p,
|
|
owner,
|
|
"Reference",
|
|
OccurrenceBody::ScenarioUse {
|
|
scenario: Some(target),
|
|
bindings: vec![],
|
|
},
|
|
);
|
|
let id = element.id;
|
|
p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap();
|
|
id
|
|
});
|
|
(p, owners, uses)
|
|
}
|
|
pub fn navigation_event(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
|
|
let context = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(event(context)));
|
|
}
|
|
#[test]
|
|
fn direct_scenario_reference_navigation_retains_each_exact_caller_without_history() {
|
|
let (p, [a, b, c], [first, second, nested]) = navigation_fixture();
|
|
let mut app = App::with_project(p.clone());
|
|
let _ = app.update(Message::Select(a));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
let source = app.document.bundle().clone();
|
|
let revision = app.document.revision();
|
|
for caller in [second, first] {
|
|
navigation_event(&mut app, |ctx| {
|
|
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
|
|
});
|
|
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
|
let camera_epoch = app.camera_epoch;
|
|
navigation_event(&mut app, |ctx| {
|
|
CanvasEvent::Camera(
|
|
ctx,
|
|
camera_epoch,
|
|
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
|
|
)
|
|
});
|
|
navigation_event(&mut app, |ctx| {
|
|
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
|
|
});
|
|
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
|
|
assert_eq!(app.selected, b);
|
|
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
|
navigation_event(&mut app, |ctx| {
|
|
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
|
});
|
|
let camera_epoch = app.camera_epoch;
|
|
navigation_event(&mut app, |ctx| {
|
|
CanvasEvent::Camera(
|
|
ctx,
|
|
camera_epoch,
|
|
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
|
|
)
|
|
});
|
|
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
|
|
assert_eq!(app.selected, c);
|
|
navigation_event(&mut app, CanvasEvent::Back);
|
|
assert_eq!(app.selected, nested);
|
|
assert_eq!(
|
|
app.behavior.scenario.canvas_tool(b),
|
|
CanvasTool::Message(MessageStyle::Timer)
|
|
);
|
|
assert_eq!(
|
|
app.behavior.scenario.presentation.pan,
|
|
iced::Vector::new(0., 115.)
|
|
);
|
|
navigation_event(&mut app, CanvasEvent::Back);
|
|
assert_eq!(app.selected, caller);
|
|
assert_eq!(app.scenario_context().unwrap().scenario, a);
|
|
assert_eq!(
|
|
app.behavior.scenario.canvas_tool(a),
|
|
CanvasTool::Add(Tool::Reuse)
|
|
);
|
|
assert_eq!(
|
|
app.behavior.scenario.presentation.pan,
|
|
iced::Vector::new(25., 170.)
|
|
);
|
|
assert_eq!(
|
|
app.behavior.scenario.details_offset,
|
|
iced::Vector::new(0., 80.),
|
|
"The caller's independent outline scroll is restored separately"
|
|
);
|
|
assert!(
|
|
!app.behavior
|
|
.scenario
|
|
.can_back(app.project(), app.scenario_context().unwrap())
|
|
);
|
|
assert_eq!(app.project(), &p);
|
|
assert_eq!(app.document.bundle(), &source);
|
|
assert_eq!(app.document.revision(), revision);
|
|
assert!(!app.document.can_undo());
|
|
assert!(!app.document.can_redo());
|
|
}
|
|
}
|
|
#[test]
|
|
fn direct_scenario_reference_navigation_blocks_pending_drafts_and_stale_contexts() {
|
|
let (p, [a, b, _], [first, _, _]) = navigation_fixture();
|
|
for back in [false, true] {
|
|
for pending in 0..3 {
|
|
let mut app = App::with_project(p.clone());
|
|
let _ = app.update(Message::Select(a));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
if back {
|
|
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, first));
|
|
}
|
|
match pending {
|
|
0 => {
|
|
let _ = app.update(Message::Field("/name".into(), "".into()));
|
|
}
|
|
1 => {
|
|
let _ = app.update(Message::SourceEdit(
|
|
iced::widget::text_editor::Action::Edit(
|
|
iced::widget::text_editor::Edit::Insert('?'),
|
|
),
|
|
));
|
|
}
|
|
_ => {
|
|
navigation_event(&mut app, |context| CanvasEvent::BeginOccurrence {
|
|
context,
|
|
tool: Tool::Action,
|
|
slot: Slot {
|
|
parent: context.scenario,
|
|
before: None,
|
|
},
|
|
lifeline: None,
|
|
});
|
|
form(&mut app, Event::Name(" Unfinished action ".into()));
|
|
}
|
|
}
|
|
let selected = app.selected;
|
|
let source = app.document.bundle().clone();
|
|
let raw_source = app.source_editor.text();
|
|
let property_snapshot = |app: &App| {
|
|
app.draft
|
|
.as_ref()
|
|
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap())
|
|
};
|
|
let raw_properties = property_snapshot(&app);
|
|
let raw_behavior = app.behavior.scenario.recovery_snapshot().unwrap();
|
|
navigation_event(&mut app, |ctx| {
|
|
if back {
|
|
CanvasEvent::Back(ctx)
|
|
} else {
|
|
CanvasEvent::OpenUse(ctx, first)
|
|
}
|
|
});
|
|
assert_eq!(app.selected, selected);
|
|
assert_eq!(app.source_editor.text(), raw_source);
|
|
assert_eq!(property_snapshot(&app), raw_properties);
|
|
assert_eq!(
|
|
app.behavior.scenario.recovery_snapshot().unwrap(),
|
|
raw_behavior
|
|
);
|
|
assert_eq!(app.document.bundle(), &source);
|
|
assert_eq!(app.project(), &p);
|
|
assert!(!app.document.can_undo());
|
|
assert!(app.status.contains("Apply or cancel"), "{}", app.status);
|
|
if back {
|
|
assert_eq!(selected, b);
|
|
}
|
|
}
|
|
}
|
|
let mut app = App::with_project(p.clone());
|
|
let _ = app.update(Message::Select(a));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
for dimension in 0..4 {
|
|
let mut stale = app.scenario_context().unwrap();
|
|
match dimension {
|
|
0 => stale.document = Id::new_v4(),
|
|
1 => stale.scenario = b,
|
|
2 => stale.revision += 1,
|
|
_ => stale.epoch += 1,
|
|
}
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::OpenUse(stale, first)));
|
|
assert_eq!(app.selected, a);
|
|
assert!(app.behavior.scenario.return_to.is_empty());
|
|
}
|
|
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, b));
|
|
assert_eq!(
|
|
app.selected, a,
|
|
"a definition is not a local reference occurrence"
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_scenario_reference_return_survives_rename_and_rejects_deleted_or_changed_use() {
|
|
let (p, [a, b, c], [first, _, _]) = navigation_fixture();
|
|
for edit in 0..4 {
|
|
let mut app = App::with_project(p.clone());
|
|
let _ = app.update(Message::Select(a));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, first));
|
|
let command = match edit {
|
|
0 => Command::Batch(vec![
|
|
Command::Rename(a, "Renamed caller".into()),
|
|
Command::Rename(first, "Renamed reference".into()),
|
|
]),
|
|
1 => Command::Delete(first),
|
|
2 => Command::Delete(a),
|
|
_ => {
|
|
let mut changed = p.elements[&first].clone();
|
|
if let Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::ScenarioUse { scenario, .. },
|
|
..
|
|
}) = changed.shared_mut()
|
|
{
|
|
*scenario = Some(c);
|
|
}
|
|
Command::EditElement(first, changed)
|
|
}
|
|
};
|
|
let _ = app.update(Message::Command(command));
|
|
let source = app.document.bundle().clone();
|
|
let project = app.project().clone();
|
|
let revision = app.document.revision();
|
|
assert!(app.document.can_undo(), "{}", app.status);
|
|
navigation_event(&mut app, CanvasEvent::Back);
|
|
assert_eq!(app.selected, if edit == 0 { first } else { b });
|
|
assert_eq!(
|
|
app.scenario_context().unwrap().scenario,
|
|
if edit == 0 { a } else { b },
|
|
"Back must restore the caller workspace even if a rename already selected its UUID"
|
|
);
|
|
assert_eq!(app.project(), &project);
|
|
assert_eq!(app.document.bundle(), &source);
|
|
assert_eq!(app.document.revision(), revision);
|
|
assert!(app.document.can_undo());
|
|
assert!(!app.document.can_redo());
|
|
}
|
|
}
|
|
#[test]
|
|
fn direct_clicked_occurrences_use_root_and_operand_slots_with_exact_source_history() {
|
|
let (f, branch, anchor, observed, reused, remote) = placement_fixture();
|
|
for tool in [Tool::Action, Tool::Observation, Tool::Reuse, Tool::Fragment] {
|
|
for slot in [
|
|
Slot {
|
|
parent: f.owner,
|
|
before: Some(f.reply),
|
|
},
|
|
Slot {
|
|
parent: branch,
|
|
before: Some(anchor),
|
|
},
|
|
] {
|
|
for on_lifeline in [true, false] {
|
|
let mut app = App::with_project(f.project.clone());
|
|
let _ = app.update(Message::Select(f.owner));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
let context = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Choose(
|
|
context,
|
|
CanvasTool::Add(tool),
|
|
)));
|
|
let context = app.scenario_context().unwrap();
|
|
let before = app.document.bundle().clone();
|
|
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
|
scene.interaction = Some((context, CanvasTool::Add(tool), true));
|
|
scene.camera_pan = iced::Vector::new(23., 80.);
|
|
let bounds = Rectangle::new(
|
|
Point::new(20., 40.),
|
|
Size::new(scene.size.width + 46., scene.size.height + 160.),
|
|
);
|
|
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
|
|
let x = if on_lifeline {
|
|
scene.lanes[&f.sender]
|
|
} else {
|
|
scene.size.width - 35.
|
|
};
|
|
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
|
|
assert!(bounds.contains(point));
|
|
let mut input = SequenceInput::default();
|
|
canvas_mouse(
|
|
&scene,
|
|
&mut input,
|
|
mouse::Event::CursorMoved { position: point },
|
|
point,
|
|
bounds,
|
|
);
|
|
assert!(
|
|
canvas_mouse(
|
|
&scene,
|
|
&mut input,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
point,
|
|
bounds
|
|
)
|
|
.is_none()
|
|
);
|
|
let message = canvas_mouse(
|
|
&scene,
|
|
&mut input,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
point,
|
|
bounds,
|
|
)
|
|
.unwrap();
|
|
let line = (tool.uses_lifeline() && on_lifeline).then_some(f.sender);
|
|
assert!(
|
|
matches!(&message, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { tool: actual, slot: captured, lifeline, .. }) if *actual == tool && *captured == slot && *lifeline == line)
|
|
);
|
|
assert!(
|
|
canvas_mouse(
|
|
&scene,
|
|
&mut input,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
point,
|
|
bounds
|
|
)
|
|
.is_none(),
|
|
"one click publishes once"
|
|
);
|
|
let _ = app.update(message);
|
|
assert_eq!(
|
|
app.behavior.scenario.pending_placement(f.owner),
|
|
Some((slot, line))
|
|
);
|
|
assert_eq!(app.document.bundle(), &before);
|
|
assert!(!app.document.can_undo());
|
|
form(&mut app, Event::Name(format!("Placed {tool:?}")));
|
|
match tool {
|
|
Tool::Action => form(
|
|
&mut app,
|
|
Event::Target(named_choice(
|
|
&f.project,
|
|
represented(&f.project, f.sender).unwrap(),
|
|
)),
|
|
),
|
|
Tool::Observation => {
|
|
form(&mut app, Event::Target(named_choice(&f.project, observed)))
|
|
}
|
|
Tool::Reuse => {
|
|
form(&mut app, Event::Target(named_choice(&f.project, reused)));
|
|
form(
|
|
&mut app,
|
|
Event::Binding(remote, named_choice(&f.project, f.sender)),
|
|
);
|
|
}
|
|
Tool::Fragment => form(&mut app, Event::Expression("ready".into())),
|
|
_ => unreachable!(),
|
|
}
|
|
if tool.uses_lifeline() && !on_lifeline {
|
|
form(&mut app, Event::Submit);
|
|
assert!(
|
|
app.behavior.has_pending(),
|
|
"off-lifeline creation requires an explicit lifeline"
|
|
);
|
|
assert_eq!(app.document.bundle(), &before);
|
|
form(&mut app, Event::Sender(named_choice(&f.project, f.sender)));
|
|
}
|
|
form(&mut app, Event::Submit);
|
|
assert!(!app.behavior.has_pending(), "{tool:?}: {}", app.status);
|
|
let created = app.selected;
|
|
assert_eq!(app.project().elements[&created].parent, Some(slot.parent));
|
|
let mut expected = f.project.elements[&slot.parent].steps().unwrap().clone();
|
|
let index = expected
|
|
.iter()
|
|
.position(|id| Some(*id) == slot.before)
|
|
.unwrap();
|
|
expected.insert(index, created);
|
|
assert_eq!(
|
|
app.project().elements[&slot.parent].steps().unwrap(),
|
|
&expected
|
|
);
|
|
let untouched = if slot.parent == f.owner {
|
|
branch
|
|
} else {
|
|
f.owner
|
|
};
|
|
assert_eq!(
|
|
app.project().elements[&untouched],
|
|
f.project.elements[&untouched]
|
|
);
|
|
assert_eq!(app.project().relations, f.project.relations);
|
|
assert_eq!(app.project().elements[&reused], f.project.elements[&reused]);
|
|
assert_eq!(
|
|
language::parse(&language::format(app.project())).unwrap(),
|
|
*app.project()
|
|
);
|
|
let after = app.document.bundle().clone();
|
|
let result = app.project().clone();
|
|
let _ = app.update(Message::Undo);
|
|
assert_eq!(app.document.bundle(), &before);
|
|
assert_eq!(app.project(), &f.project);
|
|
assert!(!app.document.can_undo());
|
|
let _ = app.update(Message::Redo);
|
|
assert_eq!(app.document.bundle(), &after);
|
|
assert_eq!(app.project(), &result);
|
|
assert!(!app.document.can_redo());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn direct_clicked_occurrence_rejects_stale_slots_and_retains_recoverable_drafts() {
|
|
let (f, branch, anchor, _, _, remote) = placement_fixture();
|
|
let slot = Slot {
|
|
parent: branch,
|
|
before: Some(anchor),
|
|
};
|
|
let mut state = State::default();
|
|
for (tool, slot, line) in [
|
|
(Tool::Lifeline, slot, None),
|
|
(
|
|
Tool::Action,
|
|
Slot {
|
|
parent: f.owner,
|
|
before: Some(anchor),
|
|
},
|
|
Some(f.sender),
|
|
),
|
|
(
|
|
Tool::Action,
|
|
Slot {
|
|
parent: Id::new_v4(),
|
|
before: None,
|
|
},
|
|
Some(f.sender),
|
|
),
|
|
(Tool::Action, slot, Some(remote)),
|
|
(Tool::Reuse, slot, Some(f.sender)),
|
|
] {
|
|
assert!(
|
|
state
|
|
.begin_occurrence(&f.project, context(f.owner), tool, slot, line)
|
|
.is_err()
|
|
);
|
|
assert!(state.draft.is_none());
|
|
}
|
|
let mut app = App::with_project(f.project.clone());
|
|
let _ = app.update(Message::Select(f.owner));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
let original = app.document.bundle().clone();
|
|
let event = |context| CanvasEvent::BeginOccurrence {
|
|
context,
|
|
tool: Tool::Action,
|
|
slot,
|
|
lifeline: Some(f.sender),
|
|
};
|
|
for dimension in 0..4 {
|
|
let mut stale = app.scenario_context().unwrap();
|
|
match dimension {
|
|
0 => stale.document = Id::new_v4(),
|
|
1 => stale.scenario = Id::new_v4(),
|
|
2 => stale.revision += 1,
|
|
_ => stale.epoch += 1,
|
|
}
|
|
let _ = app.update(Message::ScenarioCanvas(event(stale)));
|
|
assert!(!app.behavior.has_pending());
|
|
}
|
|
let old_context = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
|
old_context,
|
|
app.camera_epoch,
|
|
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
|
|
)));
|
|
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
|
|
assert!(!app.behavior.has_pending());
|
|
let current = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(event(current)));
|
|
form(&mut app, Event::Name(" Λ phase — retained ".into()));
|
|
let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap();
|
|
form(&mut app, Event::Submit);
|
|
assert!(
|
|
app.behavior.has_pending(),
|
|
"missing action source rejects without losing the clicked slot"
|
|
);
|
|
let _ = app.update(Message::Escape);
|
|
let current = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
|
current,
|
|
ScrollArea::Page,
|
|
iced::Vector::new(0., 200.),
|
|
)));
|
|
assert_eq!(
|
|
app.behavior.scenario.recovery_snapshot().unwrap(),
|
|
Some(raw.clone())
|
|
);
|
|
assert_eq!(app.document.bundle(), &original);
|
|
assert!(!app.document.can_undo());
|
|
let recovered = State::from_recovery(raw.clone()).unwrap();
|
|
assert_eq!(
|
|
recovered.pending_placement(f.owner),
|
|
Some((slot, Some(f.sender)))
|
|
);
|
|
assert_eq!(recovered.recovery_snapshot().unwrap(), Some(raw));
|
|
assert!(recovered.prepare_pending(&f.project).is_err());
|
|
form(
|
|
&mut app,
|
|
Event::Target(named_choice(
|
|
&f.project,
|
|
represented(&f.project, f.sender).unwrap(),
|
|
)),
|
|
);
|
|
form(&mut app, Event::Submit);
|
|
assert!(!app.behavior.has_pending(), "{}", app.status);
|
|
let _ = app.update(Message::Undo);
|
|
assert_eq!(app.document.bundle(), &original);
|
|
}
|
|
#[test]
|
|
fn direct_middle_pair_insertion_is_one_exact_source_and_model_undo() {
|
|
let f = fixture();
|
|
let mut state = State::default();
|
|
let slot = Slot {
|
|
parent: f.owner,
|
|
before: Some(f.reply),
|
|
};
|
|
state
|
|
.begin_message(
|
|
&f.project,
|
|
context(f.owner),
|
|
slot,
|
|
f.sender,
|
|
f.receiver,
|
|
MessageStyle::CallReply,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
exchanges(&f.project, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|choice| choice.id == f.exchange)
|
|
);
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().sender, Some(f.sender));
|
|
assert_eq!(state.draft.as_ref().unwrap().receiver, Some(f.receiver));
|
|
let mut document =
|
|
crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap();
|
|
let source = document.bundle().clone();
|
|
document
|
|
.execute(state.prepare_pending(&f.project).unwrap())
|
|
.unwrap();
|
|
let p = document.project();
|
|
let steps = p.elements[&f.owner].steps().unwrap();
|
|
assert_eq!(steps.len(), 4);
|
|
assert_eq!((steps[0], steps[3]), (f.call, f.reply));
|
|
assert!(
|
|
matches!(p.elements[&steps[2]].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange {
|
|
reply_to: Some(call), message_kind: MessageKind::Reply, exchange: Some(exchange), .. }, .. }) if *call == steps[1] && *exchange == f.exchange)
|
|
);
|
|
assert_eq!(p.relations, f.project.relations);
|
|
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), &f.project);
|
|
assert_eq!(document.bundle(), &source);
|
|
assert!(!document.undo());
|
|
}
|
|
#[test]
|
|
fn direct_reply_choices_validate_the_actual_slot_and_reorder_rejects_reverse_order() {
|
|
let f = fixture();
|
|
let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&p,
|
|
context(f.owner),
|
|
Slot {
|
|
parent: f.owner,
|
|
before: Some(f.call),
|
|
},
|
|
f.receiver,
|
|
f.sender,
|
|
MessageStyle::Reply,
|
|
)
|
|
.unwrap();
|
|
assert!(calls(&p, state.draft.as_ref().unwrap()).is_empty());
|
|
state.update(&p, Event::AppendInstead).unwrap();
|
|
assert!(
|
|
calls(&p, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|choice| choice.id == f.call)
|
|
);
|
|
state
|
|
.update(&p, Event::Call(named_choice(&p, f.call)))
|
|
.unwrap();
|
|
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
|
|
let reply = *result.elements[&f.owner].steps().unwrap().last().unwrap();
|
|
assert!(
|
|
reorder(
|
|
&result,
|
|
f.owner,
|
|
reply,
|
|
Slot {
|
|
parent: f.owner,
|
|
before: Some(f.call)
|
|
}
|
|
)
|
|
.unwrap_err()
|
|
.contains("call")
|
|
);
|
|
assert_eq!(result.elements[&f.owner].steps().unwrap(), &[f.call, reply]);
|
|
}
|
|
#[test]
|
|
fn direct_operand_pair_keeps_parent_sequence_and_cannot_reorder_across_operands() {
|
|
let f = fixture();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&f.project, Event::Start(f.owner, Tool::Fragment))
|
|
.unwrap();
|
|
state
|
|
.update(&f.project, Event::Expression("ready".into()))
|
|
.unwrap();
|
|
let p = execute_batch(&f.project, &[state.prepare_pending(&f.project).unwrap()]).unwrap();
|
|
let fragment = *p.elements[&f.owner].steps().unwrap().last().unwrap();
|
|
let branches: Vec<_> = p.children(fragment).map(|e| e.id).collect();
|
|
let root_steps = p.elements[&f.owner].steps().unwrap().clone();
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&p,
|
|
context(f.owner),
|
|
Slot {
|
|
parent: branches[0],
|
|
before: None,
|
|
},
|
|
f.sender,
|
|
f.receiver,
|
|
MessageStyle::CallReply,
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(&p, Event::Exchange(named_choice(&p, f.exchange)))
|
|
.unwrap();
|
|
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
|
|
assert_eq!(result.elements[&f.owner].steps().unwrap(), &root_steps);
|
|
let branch_steps = result.elements[&branches[0]].steps().unwrap();
|
|
assert_eq!(branch_steps.len(), 2);
|
|
assert!(
|
|
reorder(
|
|
&result,
|
|
f.owner,
|
|
branch_steps[0],
|
|
Slot {
|
|
parent: branches[1],
|
|
before: None
|
|
}
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
|
|
let f = fixture();
|
|
let mut app = App::with_project(f.project.clone());
|
|
let _ = app.update(Message::Select(f.owner));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
let context = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
|
context,
|
|
app.camera_epoch,
|
|
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
|
|
)));
|
|
let context = app.scenario_context().unwrap();
|
|
let original = app.document.snapshot();
|
|
app.busy = true;
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
|
context,
|
|
ScrollArea::Page,
|
|
iced::Vector::new(0., 40.),
|
|
)));
|
|
assert!(app.busy);
|
|
assert_eq!(
|
|
app.behavior.scenario.details_offset,
|
|
iced::Vector::new(0., 40.)
|
|
);
|
|
assert_ne!(
|
|
app.scenario_context(),
|
|
Some(context),
|
|
"scroll invalidates the pre-save pointer context even while busy"
|
|
);
|
|
let mut stale = context;
|
|
stale.document = Id::new_v4();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
|
stale,
|
|
ScrollArea::Page,
|
|
iced::Vector::new(0., 900.),
|
|
)));
|
|
assert_eq!(
|
|
app.behavior.scenario.details_offset,
|
|
iced::Vector::new(0., 40.)
|
|
);
|
|
let root = std::path::PathBuf::from("scenario-details-save-fixture");
|
|
let receipt = crate::app::FileSnapshot {
|
|
document: context.document,
|
|
revision: context.revision,
|
|
snapshot: original.clone(),
|
|
path: root.join(&original.bundle.entry),
|
|
disk: Some(oa_core::source_files::DiskProject {
|
|
root,
|
|
bundle: original.bundle.clone(),
|
|
expectations: BTreeMap::new(),
|
|
reachable: original.bundle.files.keys().cloned().collect(),
|
|
diagnostics: vec![],
|
|
}),
|
|
};
|
|
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
|
assert!(!app.busy);
|
|
assert_eq!(app.project(), &original.decoded.project);
|
|
assert!(!app.document.can_undo());
|
|
let context = app.scenario_context().unwrap();
|
|
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
|
|
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
|
|
assert_eq!(
|
|
app.behavior.scenario.presentation.pan,
|
|
iced::Vector::new(-25., -100.)
|
|
);
|
|
scene.camera_pan = app.behavior.scenario.presentation.pan;
|
|
let slot = scene.slots.last().unwrap().slot;
|
|
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
|
|
let y = bounds.y + scene.slots.last().unwrap().y;
|
|
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
|
|
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
|
|
assert!(bounds.contains(a) && bounds.contains(b));
|
|
let mut input = super::super::SequenceInput::default();
|
|
let mut published = vec![];
|
|
for event in [
|
|
mouse::Event::CursorMoved { position: a },
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
mouse::Event::CursorMoved { position: b },
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
] {
|
|
if let Some(message) = canvas::Program::update(
|
|
&scene,
|
|
&mut input,
|
|
&canvas::Event::Mouse(event),
|
|
bounds,
|
|
mouse::Cursor::Available(b),
|
|
)
|
|
.and_then(|action| action.into_inner().0)
|
|
{
|
|
published.push(message);
|
|
}
|
|
}
|
|
assert_eq!(published.len(), 1);
|
|
assert!(
|
|
matches!(&published[0], Message::ScenarioCanvas(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if *sender == f.sender && *receiver == f.receiver && *actual == slot)
|
|
);
|
|
let _ = app.update(published.pop().unwrap());
|
|
assert_eq!(
|
|
app.behavior.scenario.draft.as_ref().unwrap().before,
|
|
slot.before
|
|
);
|
|
assert_eq!(
|
|
app.behavior.scenario.draft.as_ref().unwrap().sender,
|
|
Some(f.sender)
|
|
);
|
|
assert_eq!(
|
|
app.behavior.scenario.draft.as_ref().unwrap().receiver,
|
|
Some(f.receiver)
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_reply_choices_cache_keeps_typing_fast_and_revalidates_changed_contexts() {
|
|
let f = fixture();
|
|
let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
|
|
let mut commands = vec![];
|
|
for index in 1..100 {
|
|
commands.push(Command::CreateElement(occurrence(
|
|
&p,
|
|
f.owner,
|
|
&format!("Call {index}"),
|
|
OccurrenceBody::Exchange {
|
|
operation: None,
|
|
exchange: Some(f.exchange),
|
|
sender: Some(f.sender),
|
|
receiver: Some(f.receiver),
|
|
message_kind: MessageKind::Call,
|
|
reply_to: None,
|
|
values: vec![],
|
|
},
|
|
)));
|
|
}
|
|
p = execute_batch(&p, &commands).unwrap();
|
|
let mut state = State::default();
|
|
let mut context = context(f.owner);
|
|
state
|
|
.begin_message(
|
|
&p,
|
|
context,
|
|
Slot {
|
|
parent: f.owner,
|
|
before: None,
|
|
},
|
|
f.receiver,
|
|
f.sender,
|
|
MessageStyle::Reply,
|
|
)
|
|
.unwrap();
|
|
let start = std::time::Instant::now();
|
|
let expected: BTreeSet<_> = state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.into_iter()
|
|
.map(|c| c.id)
|
|
.collect();
|
|
assert_eq!(expected.len(), 100);
|
|
eprintln!(
|
|
"100 exact-slot earlier-call choices cold: {:?}",
|
|
start.elapsed()
|
|
);
|
|
for event in [
|
|
Event::Name("unfinished name".into()),
|
|
Event::ChoiceSearch("Call".into()),
|
|
Event::ChoicePage(2),
|
|
Event::Value(
|
|
crate::data_values::FieldPathKey {
|
|
path: vec![],
|
|
field: Id::new_v4(),
|
|
},
|
|
"unfinished expression".into(),
|
|
),
|
|
] {
|
|
state.update(&p, event).unwrap();
|
|
let start = std::time::Instant::now();
|
|
assert_eq!(
|
|
state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.into_iter()
|
|
.map(|c| c.id)
|
|
.collect::<BTreeSet<_>>(),
|
|
expected
|
|
);
|
|
eprintln!(
|
|
"100 earlier-call choices after draft edit: {:?}",
|
|
start.elapsed()
|
|
);
|
|
}
|
|
state.draft.as_mut().unwrap().before = Some(f.call);
|
|
assert!(
|
|
state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.is_empty(),
|
|
"changed insertion invalidates cached reply order"
|
|
);
|
|
state.update(&p, Event::AppendInstead).unwrap();
|
|
assert_eq!(
|
|
state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.len(),
|
|
100
|
|
);
|
|
let deleted = *expected.iter().find(|id| **id != f.call).unwrap();
|
|
p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap();
|
|
context.revision += 1;
|
|
assert_eq!(
|
|
state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.len(),
|
|
99
|
|
);
|
|
let deleted = *expected
|
|
.iter()
|
|
.find(|id| **id != f.call && **id != deleted)
|
|
.unwrap();
|
|
p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap();
|
|
context.document = Id::new_v4();
|
|
assert_eq!(
|
|
state
|
|
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
|
.len(),
|
|
98,
|
|
"another document with the same IDs and revision still invalidates cached choices"
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_exchange_compatibility_browsing_preserves_final_slot_validation() {
|
|
let f = fixture();
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&f.project,
|
|
context(f.owner),
|
|
Slot {
|
|
parent: f.owner,
|
|
before: Some(f.call),
|
|
},
|
|
f.sender,
|
|
f.receiver,
|
|
MessageStyle::Delete,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
exchanges(&f.project, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|c| c.id == f.exchange)
|
|
);
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
state.prepare_pending(&f.project).is_err(),
|
|
"deleting the callee before the existing call fails exact-slot lifecycle validation"
|
|
);
|
|
state.update(&f.project, Event::AppendInstead).unwrap();
|
|
assert!(state.prepare_pending(&f.project).is_ok());
|
|
state.draft.as_mut().unwrap().sender = Some(f.receiver);
|
|
state.draft.as_mut().unwrap().receiver = Some(f.sender);
|
|
assert!(
|
|
!exchanges(&f.project, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|c| c.id == f.exchange),
|
|
"reversed represented endpoints are not offered"
|
|
);
|
|
}
|
|
#[test]
|
|
fn direct_five_hundred_exchange_choice_render_probe() {
|
|
let mut f = fixture();
|
|
let template = f.project.relations[&f.exchange].clone();
|
|
for index in 1..500 {
|
|
let id = f.project.relate(
|
|
RelationKind::Interaction,
|
|
&format!("Contract {index:03}"),
|
|
template.source(),
|
|
template.target(),
|
|
);
|
|
f.project.relations.get_mut(&id).unwrap().body = template.body.clone();
|
|
}
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&f.project,
|
|
context(f.owner),
|
|
Slot {
|
|
parent: f.owner,
|
|
before: None,
|
|
},
|
|
f.sender,
|
|
f.receiver,
|
|
MessageStyle::CallReply,
|
|
)
|
|
.unwrap();
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
exchanges(&f.project, state.draft.as_ref().unwrap()).len(),
|
|
500
|
|
);
|
|
eprintln!("500 compatible exchange choices: {:?}", started.elapsed());
|
|
state
|
|
.update(&f.project, Event::Name("Draft name changed".into()))
|
|
.unwrap();
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
exchanges(&f.project, state.draft.as_ref().unwrap()).len(),
|
|
500
|
|
);
|
|
eprintln!("500 choices after name keystroke: {:?}", started.elapsed());
|
|
}
|
|
#[test]
|
|
fn direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values() {
|
|
let mut f = fixture();
|
|
let template = f.project.relations[&f.exchange].clone();
|
|
for index in 0..70 {
|
|
let id = f.project.relate(
|
|
RelationKind::Interaction,
|
|
&format!("Other contract {index:02}"),
|
|
template.source(),
|
|
template.target(),
|
|
);
|
|
f.project.relations.get_mut(&id).unwrap().body = template.body.clone();
|
|
}
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&f.project,
|
|
context(f.owner),
|
|
Slot {
|
|
parent: f.owner,
|
|
before: None,
|
|
},
|
|
f.sender,
|
|
f.receiver,
|
|
MessageStyle::CallReply,
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
)
|
|
.unwrap();
|
|
let key = crate::data_values::roots(
|
|
&f.project,
|
|
&f.project.relations[&f.exchange],
|
|
&MessageKind::Call,
|
|
)[0]
|
|
.key
|
|
.clone();
|
|
state
|
|
.update(&f.project, Event::Value(key.clone(), "pending(".into()))
|
|
.unwrap();
|
|
let choices = exchanges(&f.project, state.draft.as_ref().unwrap());
|
|
assert_eq!(choices.len(), 71);
|
|
let mut visited = BTreeSet::new();
|
|
for page in 0..choices.len().div_ceil(VALUE_PAGE_SIZE) {
|
|
state.update(&f.project, Event::ChoicePage(page)).unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
for choice in &choices[value_page(choices.len(), d.choice_page)] {
|
|
visited.insert(choice.id);
|
|
}
|
|
let view = bounded_picker(choices.clone(), d, d.exchange, "Choose", Event::Exchange);
|
|
let tree = iced::advanced::widget::Tree::new(view.as_widget());
|
|
fn count(tree: &iced::advanced::widget::Tree) -> usize {
|
|
1 + tree.children.iter().map(count).sum::<usize>()
|
|
}
|
|
assert!(count(&tree) < 20);
|
|
}
|
|
assert_eq!(visited.len(), 71);
|
|
state
|
|
.update(&f.project, Event::ChoiceSearch("Other contract 69".into()))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().choice_page, 0);
|
|
assert_eq!(state.draft.as_ref().unwrap().values[&key], "pending(");
|
|
assert_eq!(state.draft.as_ref().unwrap().exchange, Some(f.exchange));
|
|
}
|
|
#[test]
|
|
fn direct_form_keeps_the_same_canvas_in_the_widget_tree() {
|
|
let f = fixture();
|
|
let mut app = App::with_project(f.project.clone());
|
|
let _ = app.update(Message::Select(f.owner));
|
|
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
|
let canvas: Element<'_, Message> =
|
|
canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into();
|
|
let tag = iced::advanced::widget::Tree::new(canvas.as_widget()).tag;
|
|
fn count(
|
|
tree: &iced::advanced::widget::Tree,
|
|
tag: iced::advanced::widget::tree::Tag,
|
|
) -> usize {
|
|
usize::from(tree.tag == tag)
|
|
+ tree
|
|
.children
|
|
.iter()
|
|
.map(|child| count(child, tag))
|
|
.sum::<usize>()
|
|
}
|
|
for pending in [false, true] {
|
|
if pending {
|
|
let context = app.scenario_context().unwrap();
|
|
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Begin {
|
|
context,
|
|
slot: Slot {
|
|
parent: f.owner,
|
|
before: None,
|
|
},
|
|
sender: f.sender,
|
|
receiver: f.receiver,
|
|
style: MessageStyle::CallReply,
|
|
}));
|
|
assert!(app.behavior.has_pending());
|
|
}
|
|
let view = view(&app, f.owner, Column::new());
|
|
let tree = iced::advanced::widget::Tree::new(view.as_widget());
|
|
assert_eq!(count(&tree, tag), 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn reverse_communication_canvas_choices_and_correlated_reply_share_core_direction() {
|
|
let mut p = Project::empty();
|
|
let a = p
|
|
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
|
.unwrap();
|
|
let b = p
|
|
.add_element(Kind::Actor, Layer::Oa, "Station", None)
|
|
.unwrap();
|
|
let exchange = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
|
|
let owner = scenario(&mut p, "Participant dialogue");
|
|
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
|
p.elements.get_mut(&owner).unwrap().shared_mut()
|
|
{
|
|
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
|
}
|
|
let la = lifeline(&mut p, owner, a);
|
|
let lb = lifeline(&mut p, owner, b);
|
|
let mut state = State::default();
|
|
state
|
|
.begin_message(
|
|
&p,
|
|
direct_tests::context(owner),
|
|
Slot {
|
|
parent: owner,
|
|
before: None,
|
|
},
|
|
lb,
|
|
la,
|
|
MessageStyle::CallReply,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
exchanges(&p, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|c| c.id == exchange)
|
|
);
|
|
state
|
|
.update(&p, Event::Exchange(named_choice(&p, exchange)))
|
|
.unwrap();
|
|
let mut document =
|
|
crate::document::Document::from_project(p.clone(), Default::default()).unwrap();
|
|
let original = document.bundle().clone();
|
|
document
|
|
.execute(state.prepare_pending(&p).unwrap())
|
|
.unwrap();
|
|
let accepted = document.project();
|
|
let steps = accepted.elements[&owner].steps().unwrap();
|
|
assert_eq!(steps.len(), 2);
|
|
for (id, expected) in [(steps[0], (b, a)), (steps[1], (a, b))] {
|
|
let Some(SharedBody::Occurrence { body, .. }) = accepted.elements[&id].shared() else {
|
|
unreachable!()
|
|
};
|
|
assert_eq!(
|
|
validation::message_endpoints(accepted, body),
|
|
Some(expected)
|
|
);
|
|
}
|
|
let after = document.bundle().clone();
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &original);
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &after);
|
|
let mut directed = p.clone();
|
|
if let RelationBody::CommunicationMean { oriented, .. } =
|
|
&mut directed.relations.get_mut(&exchange).unwrap().body
|
|
{
|
|
*oriented = true;
|
|
}
|
|
assert!(
|
|
!exchanges(&directed, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|c| c.id == exchange)
|
|
);
|
|
}
|
|
fn scenario(p: &mut Project, name: &str) -> Id {
|
|
let mut e = p
|
|
.make_element(Kind::Scenario, Layer::Oa, name, None)
|
|
.unwrap();
|
|
let id = e.id;
|
|
if let Some(SharedBody::Scenario { viewpoint, .. }) = e.shared_mut() {
|
|
*viewpoint = ScenarioViewpoint::OperationalActivity;
|
|
}
|
|
*p = execute_batch(p, &[Command::CreateElement(e)]).unwrap();
|
|
id
|
|
}
|
|
fn lifeline(p: &mut Project, owner: Id, represented: Id) -> Id {
|
|
let mut state = State::default();
|
|
state
|
|
.update(p, Event::Start(owner, Tool::Lifeline))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Target(named_choice(p, represented)))
|
|
.unwrap();
|
|
let command = state.update(p, Event::Submit).unwrap().unwrap();
|
|
*p = execute_batch(p, &[command]).unwrap();
|
|
p.children(owner).find(|e|matches!(e.shared(),Some(SharedBody::Lifeline {represented:Some(id)}) if *id==represented)).unwrap().id
|
|
}
|
|
fn fixture() -> (Project, Id, Id, Id, Id) {
|
|
let mut p = Project::demo();
|
|
let relation = p
|
|
.relations
|
|
.values()
|
|
.find(|r| r.kind() == RelationKind::Interaction)
|
|
.unwrap();
|
|
let (exchange, a, b) = (relation.id, relation.source(), relation.target());
|
|
let owner = scenario(&mut p, "Scenario authoring test");
|
|
let sender = lifeline(&mut p, owner, a);
|
|
let receiver = lifeline(&mut p, owner, b);
|
|
(p, owner, exchange, sender, receiver)
|
|
}
|
|
fn message(p: &Project, owner: Id, exchange: Id, sender: Id, receiver: Id) -> State {
|
|
let mut state = State::default();
|
|
state.update(p, Event::Start(owner, Tool::Message)).unwrap();
|
|
state
|
|
.update(p, Event::Exchange(named_choice(p, exchange)))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Sender(named_choice(p, sender)))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Receiver(named_choice(p, receiver)))
|
|
.unwrap();
|
|
state
|
|
}
|
|
#[test]
|
|
fn graphical_call_reply_is_atomic_correlated_and_source_equivalent() {
|
|
let (p, owner, exchange, sender, receiver) = fixture();
|
|
let mut state = message(&p, owner, exchange, sender, receiver);
|
|
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
|
let mut session = Session::new(p.clone());
|
|
session.execute(command).unwrap();
|
|
let result = session.project();
|
|
let steps = result.elements[&owner].steps().unwrap();
|
|
assert_eq!(steps.len(), 2);
|
|
assert!(
|
|
matches!(result.elements[&steps[1]].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {message_kind:MessageKind::Reply,reply_to:Some(call),sender:Some(from),receiver:Some(to),..},..}) if *call==steps[0]&&*from==receiver&&*to==sender)
|
|
);
|
|
assert_eq!(language::parse(&language::format(result)).unwrap(), *result);
|
|
let scene = Scene::sequence(result, owner, owner);
|
|
assert!(
|
|
scene
|
|
.lines
|
|
.iter()
|
|
.any(|l| l.id == steps[1] && l.dashed && l.label.contains("reply"))
|
|
);
|
|
assert_eq!(
|
|
scene.activations,
|
|
vec![super::super::scenario_activation::Segment {
|
|
call: steps[0],
|
|
receiver,
|
|
scope: owner,
|
|
rect: Rectangle::new(
|
|
Point::new(scene.lanes[&receiver] - 6., 148.),
|
|
Size::new(12., 92.),
|
|
),
|
|
column: 0,
|
|
finish: super::super::scenario_activation::Finish::Reply(steps[1]),
|
|
}]
|
|
);
|
|
let call_line = scene.lines.iter().find(|line| line.id == steps[0]).unwrap();
|
|
let reply_line = scene.lines.iter().find(|line| line.id == steps[1]).unwrap();
|
|
assert_eq!(call_line.points.last().unwrap().y, 148.);
|
|
assert_eq!(reply_line.points.first().unwrap().y, 240.);
|
|
let mut reply = State::default();
|
|
reply
|
|
.update(result, Event::Start(owner, Tool::Message))
|
|
.unwrap();
|
|
reply
|
|
.update(result, Event::Style(MessageStyle::Reply))
|
|
.unwrap();
|
|
assert!(calls(result, reply.draft.as_ref().unwrap()).is_empty());
|
|
session.undo();
|
|
assert_eq!(session.project(), &p);
|
|
}
|
|
#[test]
|
|
fn scenario_reuse_requires_explicit_compatible_bindings() {
|
|
let (mut p, owner, _, sender, receiver) = fixture();
|
|
let a = represented(&p, sender).unwrap();
|
|
let b = represented(&p, receiver).unwrap();
|
|
let target = scenario(&mut p, "Reusable scenario");
|
|
let remote_a = lifeline(&mut p, target, a);
|
|
let remote_b = lifeline(&mut p, target, b);
|
|
let mut state = State::default();
|
|
state.update(&p, Event::Start(owner, Tool::Reuse)).unwrap();
|
|
state
|
|
.update(&p, Event::Target(named_choice(&p, target)))
|
|
.unwrap();
|
|
assert!(state.update(&p, Event::Submit).is_err());
|
|
assert!(state.draft.is_some());
|
|
state
|
|
.update(&p, Event::Binding(remote_a, named_choice(&p, receiver)))
|
|
.unwrap();
|
|
state
|
|
.update(&p, Event::Binding(remote_b, named_choice(&p, sender)))
|
|
.unwrap();
|
|
assert!(state.update(&p, Event::Submit).is_err());
|
|
assert!(state.draft.is_some());
|
|
state
|
|
.update(&p, Event::Binding(remote_a, named_choice(&p, sender)))
|
|
.unwrap();
|
|
state
|
|
.update(&p, Event::Binding(remote_b, named_choice(&p, receiver)))
|
|
.unwrap();
|
|
let result =
|
|
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
|
let use_id = result.elements[&owner].steps().unwrap()[0];
|
|
assert!(
|
|
matches!(result.elements[&use_id].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::ScenarioUse {scenario:Some(id),bindings},..}) if *id==target&&bindings.len()==2)
|
|
);
|
|
assert_eq!(result.children(target).count(), 2);
|
|
assert_eq!(language::parse(&language::format(&result)).unwrap(), result);
|
|
}
|
|
#[test]
|
|
fn fragment_operands_accept_paired_messages_through_normal_destination_picker() {
|
|
let (mut p, owner, exchange, sender, receiver) = fixture();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner, Tool::Fragment))
|
|
.unwrap();
|
|
state
|
|
.update(&p, Event::Name("Choose outcome".into()))
|
|
.unwrap();
|
|
state
|
|
.update(&p, Event::Expression("accepted".into()))
|
|
.unwrap();
|
|
p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
|
let fragment = p.elements[&owner].steps().unwrap()[0];
|
|
let branches = p.children(fragment).map(|e| e.id).collect::<Vec<_>>();
|
|
assert_eq!(branches.len(), 2);
|
|
let mut state = message(&p, owner, exchange, sender, receiver);
|
|
state
|
|
.update(&p, Event::Parent(named_choice(&p, branches[0])))
|
|
.unwrap();
|
|
p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
|
assert_eq!(p.elements[&branches[0]].steps().unwrap().len(), 2);
|
|
assert_eq!(
|
|
Scene::sequence(&p, owner, owner)
|
|
.lines
|
|
.iter()
|
|
.filter(|l| !l.label.is_empty())
|
|
.count(),
|
|
2
|
|
);
|
|
}
|
|
#[test]
|
|
fn invalid_lifecycle_edit_keeps_draft_and_timer_has_no_global_exchange() {
|
|
let (mut p, owner, exchange, sender, receiver) = fixture();
|
|
let mut state = message(&p, owner, exchange, sender, receiver);
|
|
p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
|
let mut state = message(&p, owner, exchange, sender, receiver);
|
|
state.draft.as_mut().unwrap().style = MessageStyle::Create;
|
|
assert!(state.update(&p, Event::Submit).is_err());
|
|
assert!(state.draft.is_some());
|
|
state.update(&p, Event::Style(MessageStyle::Timer)).unwrap();
|
|
state
|
|
.update(&p, Event::Sender(named_choice(&p, sender)))
|
|
.unwrap();
|
|
state.update(&p, Event::Expression("30 s".into())).unwrap();
|
|
p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
|
let timer = *p.elements[&owner].steps().unwrap().last().unwrap();
|
|
assert!(
|
|
matches!(p.elements[&timer].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {exchange:None,receiver:None,message_kind:MessageKind::Timer {expression},..},..}) if expression=="30 s")
|
|
);
|
|
assert!(
|
|
Scene::sequence(&p, owner, owner)
|
|
.lines
|
|
.iter()
|
|
.any(|l| l.id == timer && l.label.contains("30 s"))
|
|
);
|
|
}
|
|
#[test]
|
|
fn tagged_scenario_commit_is_not_redirected_by_other_editor_events() {
|
|
let (p, owner, exchange, sender, receiver) = fixture();
|
|
let mut state = crate::behavior::State::default();
|
|
for e in [
|
|
Event::Start(owner, Tool::Message),
|
|
Event::Exchange(named_choice(&p, exchange)),
|
|
Event::Sender(named_choice(&p, sender)),
|
|
Event::Receiver(named_choice(&p, receiver)),
|
|
] {
|
|
state
|
|
.update(&p, crate::behavior::Event::Scenario(e))
|
|
.unwrap();
|
|
}
|
|
state
|
|
.update(
|
|
&p,
|
|
crate::behavior::Event::Topology(super::super::topology::Event::Cancel),
|
|
)
|
|
.unwrap();
|
|
let submit = crate::behavior::Event::Scenario(Event::Submit);
|
|
assert!(submit.is_submit());
|
|
let command = state.update(&p, submit).unwrap().unwrap();
|
|
let result = execute_batch(&p, &[command]).unwrap();
|
|
assert_eq!(result.elements[&owner].steps().unwrap().len(), 2);
|
|
}
|
|
#[test]
|
|
fn lifeline_picker_uses_core_viewpoint_contract() {
|
|
let (p, owner, ..) = fixture();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&p, Event::Start(owner, Tool::Lifeline))
|
|
.unwrap();
|
|
let choices = sources(&p, state.draft.as_ref().unwrap());
|
|
assert!(!choices.is_empty());
|
|
assert!(
|
|
choices
|
|
.iter()
|
|
.all(|c| p.elements[&c.id].kind() == Kind::Activity)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod data_contract_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scenario_form_commits_two_reused_record_values_and_correlated_reply_atomically() {
|
|
let mut f = crate::data_values::tests::payload();
|
|
if let Some(SharedBody::DataField { direction, .. }) = f
|
|
.project
|
|
.elements
|
|
.get_mut(&f.destination)
|
|
.unwrap()
|
|
.shared_mut()
|
|
{
|
|
*direction = ParameterDirection::InOut;
|
|
}
|
|
let source = f.project.relations[&f.exchange].source();
|
|
let target = f.project.relations[&f.exchange].target();
|
|
let mut lines = vec![];
|
|
for (name, represented) in [("Requester", source), ("Performer", target)] {
|
|
let id = f
|
|
.project
|
|
.add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario))
|
|
.unwrap();
|
|
*f.project
|
|
.elements
|
|
.get_mut(&id)
|
|
.unwrap()
|
|
.shared_mut()
|
|
.unwrap() = SharedBody::Lifeline {
|
|
represented: Some(represented),
|
|
};
|
|
lines.push(id);
|
|
}
|
|
let mut state = State::default();
|
|
for event in [
|
|
Event::Start(f.scenario, Tool::Message),
|
|
Event::Name("Move coordinates".into()),
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
Event::Sender(named_choice(&f.project, lines[0])),
|
|
Event::Receiver(named_choice(&f.project, lines[1])),
|
|
] {
|
|
state.update(&f.project, event).unwrap();
|
|
}
|
|
assert!(
|
|
exchanges(&f.project, state.draft.as_ref().unwrap())
|
|
.iter()
|
|
.any(|c| c.id == f.exchange)
|
|
);
|
|
for (parameter, expression) in [(f.origin, "45.0"), (f.destination, "46.0")] {
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Value(
|
|
crate::data_values::FieldPathKey {
|
|
path: vec![parameter],
|
|
field: f.leaf,
|
|
},
|
|
expression.into(),
|
|
),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let before = state.draft.as_ref().unwrap().values.clone();
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::Call))
|
|
.unwrap();
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().values, before);
|
|
assert_eq!(state.draft.as_ref().unwrap().sender, Some(lines[0]));
|
|
assert_eq!(state.draft.as_ref().unwrap().receiver, Some(lines[1]));
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::CallReply))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().values, before);
|
|
let command = state.prepare_pending(&f.project).unwrap();
|
|
let mut document =
|
|
crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap();
|
|
document.execute(command).unwrap();
|
|
let messages = document
|
|
.project()
|
|
.children(f.scenario)
|
|
.filter_map(|e| match e.shared()? {
|
|
SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::Exchange {
|
|
message_kind,
|
|
values,
|
|
..
|
|
},
|
|
..
|
|
} => Some((message_kind, values)),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let call = messages
|
|
.iter()
|
|
.find(|(kind, _)| **kind == MessageKind::Call)
|
|
.unwrap()
|
|
.1;
|
|
assert_eq!(call.len(), 2);
|
|
assert_eq!(
|
|
call.iter().map(|v| v.field).collect::<BTreeSet<_>>(),
|
|
BTreeSet::from([f.leaf])
|
|
);
|
|
assert_eq!(
|
|
call.iter().map(|v| v.path.clone()).collect::<BTreeSet<_>>(),
|
|
BTreeSet::from([vec![f.origin], vec![f.destination]])
|
|
);
|
|
assert!(
|
|
messages
|
|
.iter()
|
|
.find(|(kind, _)| **kind == MessageKind::Reply)
|
|
.unwrap()
|
|
.1
|
|
.is_empty()
|
|
);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), &f.project);
|
|
assert!(!document.undo());
|
|
}
|
|
#[test]
|
|
fn recursive_value_focus_exposes_authored_leaf_and_phase_changes_retain_surviving_keys() {
|
|
let (f, next) = crate::data_values::tests::recursive_payload();
|
|
let mut state = State::default();
|
|
state
|
|
.update(&f.project, Event::Start(f.scenario, Tool::Message))
|
|
.unwrap();
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Exchange(named_choice(&f.project, f.exchange)),
|
|
)
|
|
.unwrap();
|
|
let key = crate::data_values::FieldPathKey {
|
|
path: vec![f.origin, next],
|
|
field: f.leaf,
|
|
};
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::ValueBrowse(crate::data_values::parent(&key)),
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(&f.project, Event::ValueFocus(key.clone()))
|
|
.unwrap();
|
|
assert!(
|
|
state
|
|
.draft
|
|
.as_ref()
|
|
.unwrap()
|
|
.browsed_values(&f.project, &f.project.relations[&f.exchange])
|
|
.unwrap()
|
|
.iter()
|
|
.any(|c| c.key == key)
|
|
);
|
|
state
|
|
.update(
|
|
&f.project,
|
|
Event::Value(key.clone(), "recursive expression".into()),
|
|
)
|
|
.unwrap();
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::Call))
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&key],
|
|
"recursive expression"
|
|
);
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::Asynchronous))
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&key],
|
|
"recursive expression"
|
|
);
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::Reply))
|
|
.unwrap();
|
|
assert!(
|
|
state.draft.as_ref().unwrap().values.is_empty(),
|
|
"In parameters do not become reply values"
|
|
);
|
|
let out = crate::data_values::FieldPathKey {
|
|
path: vec![f.destination],
|
|
field: f.leaf,
|
|
};
|
|
state
|
|
.update(&f.project, Event::Value(out.clone(), "reply value".into()))
|
|
.unwrap();
|
|
state
|
|
.update(&f.project, Event::Style(MessageStyle::Asynchronous))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value");
|
|
}
|
|
|
|
fn panel_counts(p: &Project, d: &Draft, exchange: Id) -> (usize, usize) {
|
|
let input: Element<'_, Message> = text_input("", "").into();
|
|
let input_tag = iced::advanced::widget::Tree::new(input.as_widget()).tag;
|
|
let panel = value_panel(p, d, &p.relations[&exchange]);
|
|
let tree = iced::advanced::widget::Tree::new(panel.as_widget());
|
|
fn count(
|
|
tree: &iced::advanced::widget::Tree,
|
|
tag: iced::advanced::widget::tree::Tag,
|
|
) -> (usize, usize) {
|
|
tree.children.iter().fold(
|
|
(1, usize::from(tree.tag == tag)),
|
|
|(nodes, inputs), child| {
|
|
let (child_nodes, child_inputs) = count(child, tag);
|
|
(nodes + child_nodes, inputs + child_inputs)
|
|
},
|
|
)
|
|
}
|
|
count(&tree, input_tag)
|
|
}
|
|
|
|
#[test]
|
|
fn large_payload_has_one_editor_with_reachable_field_and_authored_pages() {
|
|
let f = crate::data_values::tests::wide_payload();
|
|
let p = &f.project;
|
|
let exchange = &p.relations[&f.exchange];
|
|
let mut state = State::default();
|
|
state
|
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Exchange(named_choice(p, f.exchange)))
|
|
.unwrap();
|
|
let roots = crate::data_values::roots(p, exchange, &MessageKind::Call);
|
|
assert_eq!(roots.len(), 96);
|
|
assert_eq!(
|
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
|
0
|
|
);
|
|
let mut visited = BTreeSet::new();
|
|
for page in 0..roots.len().div_ceil(VALUE_PAGE_SIZE) {
|
|
state
|
|
.update(p, Event::ValuePage(ValueSection::Fields, page))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
let choices = d.browsed_values(p, exchange).unwrap();
|
|
let visible = choices[value_page(choices.len(), d.value_browse_page)].to_vec();
|
|
assert!(visible.len() <= VALUE_PAGE_SIZE);
|
|
for choice in visible {
|
|
visited.insert(choice.key.clone());
|
|
state
|
|
.update(p, Event::ValueFocus(choice.key.clone()))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Value(choice.key, format!("draft {page}")))
|
|
.unwrap();
|
|
}
|
|
let (nodes, inputs) = panel_counts(p, state.draft.as_ref().unwrap(), f.exchange);
|
|
assert_eq!(
|
|
inputs, 1,
|
|
"one input for over twelve thousand carried paths"
|
|
);
|
|
assert!(nodes < 200, "{nodes} widgets for the bounded value panel");
|
|
}
|
|
assert_eq!(
|
|
visited,
|
|
roots.iter().map(|choice| choice.key.clone()).collect()
|
|
);
|
|
|
|
state
|
|
.update(p, Event::ValueBrowse(Some(roots[0].key.clone())))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().value_browse_page, 0);
|
|
let children = state
|
|
.draft
|
|
.as_ref()
|
|
.unwrap()
|
|
.browsed_values(p, exchange)
|
|
.unwrap();
|
|
assert_eq!(children.len(), 128);
|
|
let mut visited_children = BTreeSet::new();
|
|
for page in 0..children.len().div_ceil(VALUE_PAGE_SIZE) {
|
|
state
|
|
.update(p, Event::ValuePage(ValueSection::Fields, page))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
let choices = d.browsed_values(p, exchange).unwrap();
|
|
for choice in &choices[value_page(choices.len(), d.value_browse_page)] {
|
|
assert_eq!(choice.key.path, [roots[0].key.field]);
|
|
visited_children.insert(choice.key.clone());
|
|
}
|
|
}
|
|
assert_eq!(
|
|
visited_children,
|
|
children.iter().map(|choice| choice.key.clone()).collect()
|
|
);
|
|
|
|
let nested = children[0].key.clone();
|
|
state.update(p, Event::ValueFocus(nested.clone())).unwrap();
|
|
state
|
|
.update(p, Event::Value(nested.clone(), "unfinished(\"".into()))
|
|
.unwrap();
|
|
let before = state.draft.as_ref().unwrap().values.clone();
|
|
let before_browse = state.draft.as_ref().unwrap().value_browse.clone();
|
|
let before_page = state.draft.as_ref().unwrap().value_browse_page;
|
|
for event in [
|
|
Event::Style(MessageStyle::Call),
|
|
Event::Exchange(named_choice(p, f.exchange)),
|
|
Event::Style(MessageStyle::CallReply),
|
|
] {
|
|
state.update(p, event).unwrap();
|
|
}
|
|
let d = state.draft.as_ref().unwrap();
|
|
assert_eq!(d.values, before);
|
|
assert_eq!(d.value_browse, before_browse);
|
|
assert_eq!(d.value_browse_page, before_page);
|
|
assert_eq!(d.value_focus, Some(nested));
|
|
|
|
let mut authored = BTreeSet::new();
|
|
for page in 0..before.len().div_ceil(VALUE_PAGE_SIZE) {
|
|
state
|
|
.update(p, Event::ValuePage(ValueSection::Authored, page))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
let values = d.authored_values();
|
|
for (key, _) in &values[value_page(values.len(), d.value_authored_page)] {
|
|
authored.insert((*key).clone());
|
|
}
|
|
let (nodes, inputs) = panel_counts(p, d, f.exchange);
|
|
assert_eq!(inputs, 1);
|
|
assert!(nodes < 200, "{nodes} widget nodes");
|
|
}
|
|
assert_eq!(authored, before.keys().cloned().collect());
|
|
state.update(p, Event::ValueBrowse(None)).unwrap();
|
|
state
|
|
.update(p, Event::ValuePage(ValueSection::Fields, usize::MAX))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
assert_eq!(
|
|
d.values, before,
|
|
"browsing and page clamping never remove drafts"
|
|
);
|
|
let OccurrenceBody::Exchange { values, .. } = d.message_body() else {
|
|
panic!()
|
|
};
|
|
assert_eq!(
|
|
values.len(),
|
|
before.len(),
|
|
"commit includes every authored page"
|
|
);
|
|
assert_eq!(panel_counts(p, d, f.exchange).1, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn recursive_browsing_up_and_root_keep_values_and_report_the_depth_limit() {
|
|
let (f, next) = crate::data_values::tests::recursive_payload();
|
|
let p = &f.project;
|
|
let exchange = &p.relations[&f.exchange];
|
|
let mut state = State::default();
|
|
state
|
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::Exchange(named_choice(p, f.exchange)))
|
|
.unwrap();
|
|
let root = crate::data_values::FieldPathKey {
|
|
path: vec![],
|
|
field: f.origin,
|
|
};
|
|
state.update(p, Event::ValueFocus(root.clone())).unwrap();
|
|
state
|
|
.update(p, Event::ValueBrowse(Some(root.clone())))
|
|
.unwrap();
|
|
let inner = state
|
|
.draft
|
|
.as_ref()
|
|
.unwrap()
|
|
.browsed_values(p, exchange)
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|choice| choice.key.field == next)
|
|
.unwrap()
|
|
.key;
|
|
state.update(p, Event::ValueFocus(inner.clone())).unwrap();
|
|
state
|
|
.update(p, Event::ValueBrowse(Some(inner.clone())))
|
|
.unwrap();
|
|
let leaf = state
|
|
.draft
|
|
.as_ref()
|
|
.unwrap()
|
|
.browsed_values(p, exchange)
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|choice| choice.key.field == f.leaf)
|
|
.unwrap()
|
|
.key;
|
|
assert_eq!(leaf.path, [f.origin, next]);
|
|
state.update(p, Event::ValueFocus(leaf.clone())).unwrap();
|
|
state
|
|
.update(p, Event::Value(leaf.clone(), "keep this draft".into()))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::ValueBrowse(crate::data_values::parent(&inner)))
|
|
.unwrap();
|
|
assert_eq!(state.draft.as_ref().unwrap().value_browse, Some(root));
|
|
state.update(p, Event::ValueBrowse(None)).unwrap();
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&leaf],
|
|
"keep this draft"
|
|
);
|
|
assert_eq!(
|
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
|
1
|
|
);
|
|
let deepest = crate::data_values::FieldPathKey {
|
|
path: std::iter::once(f.origin)
|
|
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
|
.collect(),
|
|
field: next,
|
|
};
|
|
state.update(p, Event::ValueBrowse(Some(deepest))).unwrap();
|
|
assert!(
|
|
state
|
|
.draft
|
|
.as_ref()
|
|
.unwrap()
|
|
.browsed_values(p, exchange)
|
|
.unwrap_err()
|
|
.contains("128")
|
|
);
|
|
assert_eq!(
|
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
|
1
|
|
);
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&leaf],
|
|
"keep this draft"
|
|
);
|
|
state
|
|
.update(p, Event::Value(leaf.clone(), String::new()))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
assert!(d.authored_values().is_empty());
|
|
assert_eq!(d.values[&leaf], "", "empty input remains an editable draft");
|
|
}
|
|
|
|
#[test]
|
|
fn reply_call_picker_previews_the_same_value_reset_as_selecting_another_exchange() {
|
|
let mut f = crate::data_values::tests::payload();
|
|
let p = &mut f.project;
|
|
let source = p.relations[&f.exchange].source();
|
|
let target = p.relations[&f.exchange].target();
|
|
let mut lifelines = vec![];
|
|
for (name, represented) in [("Caller", source), ("Callee", target)] {
|
|
let id = p
|
|
.add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario))
|
|
.unwrap();
|
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
|
represented: Some(represented),
|
|
};
|
|
lifelines.push(id);
|
|
}
|
|
let other_item = p
|
|
.add_element(Kind::ExchangeItem, Layer::Oa, "Other operation", None)
|
|
.unwrap();
|
|
*p.elements
|
|
.get_mut(&other_item)
|
|
.unwrap()
|
|
.shared_mut()
|
|
.unwrap() = p.elements[&f.item].shared().unwrap().clone();
|
|
let other_field = p
|
|
.add_element(Kind::DataField, Layer::Oa, "Result", Some(other_item))
|
|
.unwrap();
|
|
*p.elements
|
|
.get_mut(&other_field)
|
|
.unwrap()
|
|
.shared_mut()
|
|
.unwrap() = SharedBody::DataField {
|
|
type_ref: TypeRef::Real,
|
|
direction: ParameterDirection::Out,
|
|
minimum: 1,
|
|
maximum: Some(1),
|
|
unit: String::new(),
|
|
storage: FieldStorage::Value,
|
|
default: None,
|
|
};
|
|
let other_exchange = p.relate(RelationKind::Interaction, "Other exchange", source, target);
|
|
p.relations.get_mut(&other_exchange).unwrap().body = RelationBody::Interaction {
|
|
source,
|
|
target,
|
|
items: vec![other_item],
|
|
};
|
|
let mut call_ids = vec![];
|
|
for (name, exchange) in [("First call", f.exchange), ("Other call", other_exchange)] {
|
|
let call = occurrence(
|
|
p,
|
|
f.scenario,
|
|
name,
|
|
OccurrenceBody::Exchange {
|
|
operation: None,
|
|
exchange: Some(exchange),
|
|
sender: Some(lifelines[0]),
|
|
receiver: Some(lifelines[1]),
|
|
message_kind: MessageKind::Call,
|
|
reply_to: None,
|
|
values: vec![],
|
|
},
|
|
);
|
|
call_ids.push(call.id);
|
|
*p = execute_batch(p, &[Command::CreateElement(call)]).unwrap();
|
|
}
|
|
let mut state = State::default();
|
|
state
|
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
|
.unwrap();
|
|
state.update(p, Event::Style(MessageStyle::Reply)).unwrap();
|
|
state
|
|
.update(p, Event::Call(named_choice(p, call_ids[0])))
|
|
.unwrap();
|
|
let key = crate::data_values::FieldPathKey {
|
|
path: vec![f.destination],
|
|
field: f.leaf,
|
|
};
|
|
state.update(p, Event::ValueFocus(key.clone())).unwrap();
|
|
state
|
|
.update(p, Event::Value(key.clone(), "reply expression".into()))
|
|
.unwrap();
|
|
state
|
|
.update(p, Event::ValueBrowse(crate::data_values::parent(&key)))
|
|
.unwrap();
|
|
let offered = calls(p, state.draft.as_ref().unwrap());
|
|
assert!(
|
|
offered.iter().any(|choice| choice.id == call_ids[1]),
|
|
"a different exchange remains selectable after entering a value"
|
|
);
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&key],
|
|
"reply expression",
|
|
"preview is read-only"
|
|
);
|
|
state
|
|
.update(p, Event::Call(named_choice(p, call_ids[0])))
|
|
.unwrap();
|
|
assert_eq!(
|
|
state.draft.as_ref().unwrap().values[&key],
|
|
"reply expression"
|
|
);
|
|
state
|
|
.update(p, Event::Call(named_choice(p, call_ids[1])))
|
|
.unwrap();
|
|
let d = state.draft.as_ref().unwrap();
|
|
assert!(d.values.is_empty());
|
|
assert!(d.value_focus.is_none());
|
|
assert!(d.value_browse.is_none());
|
|
assert_eq!(d.exchange, Some(other_exchange));
|
|
assert!(state.prepare_pending(p).is_ok());
|
|
}
|
|
}
|