Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/scenario.rs
T

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());
}
}