3234 lines
120 KiB
Rust
3234 lines
120 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};
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum Tool {
|
||
Lifeline,
|
||
Message,
|
||
Reuse,
|
||
Action,
|
||
Observation,
|
||
Fragment,
|
||
}
|
||
impl Tool {
|
||
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 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),
|
||
}
|
||
#[derive(Debug, Clone)]
|
||
pub enum CanvasEvent {
|
||
Scrolled(Context, ScrollArea, iced::Vector),
|
||
Choose(Context, CanvasTool),
|
||
Select(Context, Id),
|
||
Begin {
|
||
context: Context,
|
||
slot: Slot,
|
||
sender: Id,
|
||
receiver: Id,
|
||
style: MessageStyle,
|
||
},
|
||
Reorder {
|
||
context: Context,
|
||
occurrence: Id,
|
||
slot: Slot,
|
||
},
|
||
}
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub enum ScrollArea {
|
||
Canvas,
|
||
Page,
|
||
}
|
||
impl CanvasEvent {
|
||
pub fn context(&self) -> Context {
|
||
match self {
|
||
Self::Scrolled(context, _, _)
|
||
| Self::Choose(context, _)
|
||
| Self::Select(context, _)
|
||
| Self::Begin { 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 {
|
||
Start(Id, Tool),
|
||
Cancel,
|
||
Submit,
|
||
Name(String),
|
||
Target(Choice),
|
||
Exchange(Choice),
|
||
Sender(Choice),
|
||
Receiver(Choice),
|
||
Call(Choice),
|
||
Style(MessageStyle),
|
||
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 {
|
||
draft: Option<Draft>,
|
||
canvas_tool: Option<(Id, CanvasTool)>,
|
||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||
scroll_offsets: [iced::Vector; 2],
|
||
}
|
||
#[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 {
|
||
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,
|
||
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(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())?;
|
||
Ok(Self {
|
||
draft: Some(draft),
|
||
..Default::default()
|
||
})
|
||
}
|
||
|
||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||
let index = match area {
|
||
ScrollArea::Canvas => 0,
|
||
ScrollArea::Page => 1,
|
||
};
|
||
let changed = self.scroll_offsets[index] != offset;
|
||
self.scroll_offsets[index] = offset;
|
||
changed
|
||
}
|
||
fn scroll_offset(&self) -> iced::Vector {
|
||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||
}
|
||
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, command: &Command) -> Option<Id> {
|
||
let draft = self.draft.as_ref()?;
|
||
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(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 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.draft = Some(Draft {
|
||
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,
|
||
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::Target(c) => {
|
||
d.target = Some(c.id);
|
||
d.bindings.clear();
|
||
}
|
||
Event::Exchange(c) => {
|
||
if d.exchange != Some(c.id) {
|
||
d.exchange = Some(c.id);
|
||
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;
|
||
}
|
||
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.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.exchange
|
||
.and_then(|id| p.relations.get(&id))
|
||
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key))
|
||
.is_some() =>
|
||
{
|
||
d.value_focus = Some(key);
|
||
}
|
||
Event::ValueBrowse(key) => {
|
||
if key.as_ref().is_none_or(|key| {
|
||
d.exchange
|
||
.and_then(|id| p.relations.get(&id))
|
||
.and_then(|r| crate::data_values::resolve(p, r, &d.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,
|
||
}
|
||
}
|
||
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 layer = p.elements[&d.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 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.kind(), key),
|
||
None => Ok(crate::data_values::roots(p, exchange, &self.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 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(),
|
||
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 !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 {
|
||
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,
|
||
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.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.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.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()
|
||
}
|
||
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||
let p = app.project();
|
||
out = out.push(label(match viewpoint(p, owner) {
|
||
ScenarioViewpoint::OperationalActivity => {
|
||
"Operational activity scenario · lifelines represent activities"
|
||
}
|
||
ScenarioViewpoint::Functional => {
|
||
"Functional scenario · lifelines represent system functions"
|
||
}
|
||
ScenarioViewpoint::ParticipantInteraction => {
|
||
"Participant scenario · lifelines represent entities, actors or roles"
|
||
}
|
||
ScenarioViewpoint::BoundaryInterface => {
|
||
"Interface scenario · lifelines represent the system and external actors"
|
||
}
|
||
ScenarioViewpoint::MixedLegacy => {
|
||
"Mixed scenario · lifelines may represent work or participants"
|
||
}
|
||
}));
|
||
let pending = app.behavior.has_pending();
|
||
// Keep the canvas at a stable widget-tree position when a form replaces
|
||
// the palette, so Iced retains the user's current sequence viewport.
|
||
let mut controls = column![].spacing(12);
|
||
if !pending {
|
||
controls = controls
|
||
.push(
|
||
row![
|
||
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
|
||
action("+ Message", event(Event::Start(owner, Tool::Message))),
|
||
action("Reuse…", event(Event::Start(owner, Tool::Reuse)))
|
||
]
|
||
.spacing(7),
|
||
)
|
||
.push(
|
||
row![
|
||
action("+ Fragment", event(Event::Start(owner, Tool::Fragment))),
|
||
action("+ Action", event(Event::Start(owner, Tool::Action))),
|
||
action(
|
||
"Observe state",
|
||
event(Event::Start(owner, Tool::Observation))
|
||
),
|
||
action("Properties", Message::EditProperties(owner))
|
||
]
|
||
.spacing(7),
|
||
);
|
||
}
|
||
let context = app.scenario_context().expect("visible scenario context");
|
||
let tool = app.behavior.scenario.canvas_tool(owner);
|
||
let style = if let CanvasTool::Message(style) = tool {
|
||
style
|
||
} else {
|
||
MessageStyle::CallReply
|
||
};
|
||
if !pending {
|
||
controls = controls.push(
|
||
row![
|
||
button("Select / reorder")
|
||
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||
context,
|
||
CanvasTool::Select
|
||
)))
|
||
.style(if tool == CanvasTool::Select {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}),
|
||
pick_list(
|
||
vec![
|
||
MessageStyle::Asynchronous,
|
||
MessageStyle::Call,
|
||
MessageStyle::CallReply,
|
||
MessageStyle::Reply,
|
||
MessageStyle::Create,
|
||
MessageStyle::Delete,
|
||
MessageStyle::Timer
|
||
],
|
||
Some(style),
|
||
move |style| Message::ScenarioCanvas(CanvasEvent::Choose(
|
||
context,
|
||
CanvasTool::Message(style)
|
||
))
|
||
)
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill),
|
||
button("Draw message")
|
||
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||
context,
|
||
CanvasTool::Message(style)
|
||
)))
|
||
.style(if matches!(tool, CanvasTool::Message(_)) {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}),
|
||
]
|
||
.spacing(6),
|
||
);
|
||
controls = controls.push(label(if matches!(tool, CanvasTool::Message(_)) {
|
||
"Draw message · choose the highlighted sequence position on a lifeline, then click or drag to another lifeline · Escape cancels"
|
||
} else { "Select a lifeline or occurrence · drag the selected occurrence’s ↕ handle to reorder within its sequence" }));
|
||
} else {
|
||
controls = controls.push(label(
|
||
"Pending form · the canvas remains visible · apply or cancel below",
|
||
));
|
||
}
|
||
out = out.push(controls);
|
||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||
scene.interaction = Some((
|
||
context,
|
||
tool,
|
||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||
));
|
||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||
let size = scene.size;
|
||
out = out.push(
|
||
container(super::scenario_scroll::observe(
|
||
context,
|
||
ScrollArea::Canvas,
|
||
scrollable(
|
||
canvas::Canvas::new(scene)
|
||
.width(size.width)
|
||
.height(size.height),
|
||
)
|
||
.direction(scrollable::Direction::Both {
|
||
vertical: scrollable::Scrollbar::default(),
|
||
horizontal: scrollable::Scrollbar::default(),
|
||
})
|
||
.height(if pending {
|
||
(app.window_size.height - 660.).clamp(110., 190.)
|
||
} else {
|
||
(app.window_size.height - 410.).clamp(180., 340.)
|
||
}),
|
||
))
|
||
.padding(8)
|
||
.style(style::card),
|
||
);
|
||
if let Some(d) = app
|
||
.behavior
|
||
.scenario
|
||
.draft
|
||
.as_ref()
|
||
.filter(|d| d.owner == owner)
|
||
{
|
||
let mut form = column![
|
||
text(d.tool.label()).size(16),
|
||
text_input("Name (defaults to selected source)", &d.name)
|
||
.on_input(|s| event(Event::Name(s)))
|
||
.padding(9)
|
||
.size(12)
|
||
]
|
||
.spacing(9);
|
||
if d.tool != Tool::Lifeline {
|
||
form = form.push(label(format!(
|
||
"Insert: {}",
|
||
slot_label(
|
||
p,
|
||
Slot {
|
||
parent: d.parent,
|
||
before: d.before
|
||
}
|
||
)
|
||
)));
|
||
if d.before.is_some() {
|
||
form = form.push(
|
||
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
|
||
);
|
||
}
|
||
form = form.push(picker(
|
||
parents(p, owner),
|
||
Some(d.parent),
|
||
"Destination sequence / operand…",
|
||
Event::Parent,
|
||
));
|
||
}
|
||
if matches!(
|
||
d.tool,
|
||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||
) {
|
||
form = form.push(picker(
|
||
sources(p, d),
|
||
d.target,
|
||
"Choose a named source…",
|
||
Event::Target,
|
||
));
|
||
}
|
||
if d.tool == Tool::Message {
|
||
form = form.push(
|
||
pick_list(
|
||
vec![
|
||
MessageStyle::Asynchronous,
|
||
MessageStyle::Call,
|
||
MessageStyle::CallReply,
|
||
MessageStyle::Reply,
|
||
MessageStyle::Create,
|
||
MessageStyle::Delete,
|
||
MessageStyle::Timer,
|
||
],
|
||
Some(d.style),
|
||
|style| event(Event::Style(style)),
|
||
)
|
||
.width(Fill)
|
||
.padding(9)
|
||
.text_size(12),
|
||
);
|
||
if d.style == MessageStyle::Reply {
|
||
form = form.push(bounded_picker(
|
||
app.behavior.scenario.call_choices(p, context, d),
|
||
d,
|
||
d.call,
|
||
"Choose the earlier call…",
|
||
Event::Call,
|
||
));
|
||
if d.call.is_some() {
|
||
form = form.push(label(
|
||
"Reply reuses the call exchange in reverse and retains its correlation.",
|
||
));
|
||
}
|
||
} else if d.style != MessageStyle::Timer {
|
||
form = form.push(bounded_picker(
|
||
exchanges(p, d),
|
||
d,
|
||
d.exchange,
|
||
"Choose the exchange…",
|
||
Event::Exchange,
|
||
));
|
||
form = form.push(label(
|
||
"Apply checks that the message is valid at this insertion position.",
|
||
));
|
||
}
|
||
form = form.push(picker(
|
||
d.line_choices(p, true),
|
||
d.sender,
|
||
"Sending lifeline…",
|
||
Event::Sender,
|
||
));
|
||
if d.style != MessageStyle::Timer {
|
||
form = form.push(picker(
|
||
d.line_choices(p, false),
|
||
d.receiver,
|
||
"Receiving lifeline…",
|
||
Event::Receiver,
|
||
));
|
||
}
|
||
if d.style == MessageStyle::Timer {
|
||
form = form.push(
|
||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||
.on_input(|s| event(Event::Expression(s)))
|
||
.padding(9)
|
||
.size(12),
|
||
);
|
||
}
|
||
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
|
||
form = form.push(value_panel(p, d, exchange));
|
||
}
|
||
if d.style == MessageStyle::CallReply {
|
||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||
}
|
||
}
|
||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||
form = form.push(picker(
|
||
lines(p, owner),
|
||
d.sender,
|
||
"Observed lifeline…",
|
||
Event::Sender,
|
||
));
|
||
}
|
||
if d.tool == Tool::Reuse
|
||
&& let Some(target) = d.target
|
||
{
|
||
for line in lines(p, target) {
|
||
let referenced = line.id;
|
||
let compatible: Vec<_> = lines(p, owner)
|
||
.into_iter()
|
||
.filter(|c| {
|
||
compatible_binding(p, referenced, c.id)
|
||
&& !d
|
||
.bindings
|
||
.iter()
|
||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||
})
|
||
.collect();
|
||
if compatible.is_empty() {
|
||
form = form.push(label(format!(
|
||
"Add a compatible local lifeline before binding '{}'.",
|
||
p.named(referenced)
|
||
)));
|
||
}
|
||
form = form
|
||
.push(label(format!("Bind {}", p.named(referenced))))
|
||
.push(picker(
|
||
compatible,
|
||
d.bindings.get(&referenced).copied(),
|
||
"Compatible local lifeline…",
|
||
move |c| Event::Binding(referenced, c),
|
||
));
|
||
}
|
||
form = form.push(label(
|
||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||
));
|
||
}
|
||
if d.tool == Tool::Fragment {
|
||
form = form.push(
|
||
pick_list(
|
||
vec![
|
||
Operator(FragmentOperator::Alternative),
|
||
Operator(FragmentOperator::Parallel),
|
||
Operator(FragmentOperator::Loop),
|
||
Operator(FragmentOperator::Optional),
|
||
],
|
||
Some(Operator(d.operator)),
|
||
|o| event(Event::Operator(o)),
|
||
)
|
||
.padding(9)
|
||
.text_size(12)
|
||
.width(Fill),
|
||
);
|
||
if d.operator != FragmentOperator::Parallel {
|
||
form = form.push(
|
||
text_input("Condition / loop guard", &d.expression)
|
||
.on_input(|s| event(Event::Expression(s)))
|
||
.padding(9)
|
||
.size(12),
|
||
);
|
||
}
|
||
if d.operator == FragmentOperator::Alternative {
|
||
form = form.push(
|
||
text_input("Second alternative guard", &d.second_guard)
|
||
.on_input(|s| event(Event::SecondGuard(s)))
|
||
.padding(9)
|
||
.size(12),
|
||
);
|
||
}
|
||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||
}
|
||
let readiness = d.command(p);
|
||
if let Err(error) = &readiness {
|
||
form = form.push(label(error.clone()));
|
||
}
|
||
let footer = row![
|
||
button("Create")
|
||
.padding(9)
|
||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||
action("Cancel", event(Event::Cancel))
|
||
]
|
||
.spacing(7);
|
||
out = out.push(
|
||
container(
|
||
column![
|
||
scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)),
|
||
footer
|
||
]
|
||
.spacing(9),
|
||
)
|
||
.padding(14)
|
||
.style(style::card),
|
||
);
|
||
}
|
||
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
|
||
out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card));
|
||
} else if !pending {
|
||
out = sequence_rows(p, owner, out);
|
||
}
|
||
super::scenario_scroll::observe(context, ScrollArea::Page, scrollable(out.padding(24)))
|
||
}
|
||
fn sequence_rows<'a>(
|
||
p: &'a Project,
|
||
owner: Id,
|
||
mut out: Column<'a, Message>,
|
||
) -> Column<'a, Message> {
|
||
if let Some(SharedBody::Occurrence {
|
||
body: OccurrenceBody::Fragment { branches, .. },
|
||
..
|
||
}) = p.elements.get(&owner).and_then(|e| e.shared())
|
||
{
|
||
for branch in branches {
|
||
out = out.push(action(
|
||
&format!("Operand: {}", p.named(*branch)),
|
||
Message::Select(*branch),
|
||
));
|
||
out = sequence_rows(p, *branch, out);
|
||
}
|
||
}
|
||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||
for (i, id) in steps.iter().enumerate() {
|
||
out =
|
||
out.push(
|
||
row![
|
||
action(
|
||
&format!("{}. {}", i + 1, p.named(*id)),
|
||
Message::Select(*id)
|
||
)
|
||
.width(Fill),
|
||
button("↑")
|
||
.padding(8)
|
||
.on_press_maybe((i > 0).then_some(Message::Command(
|
||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||
))),
|
||
button("↓").padding(8).on_press_maybe(
|
||
(i + 1 < steps.len()).then_some(Message::Command(Command::MoveStep(
|
||
owner,
|
||
i,
|
||
i + 1
|
||
)))
|
||
)
|
||
]
|
||
.spacing(6),
|
||
);
|
||
if let Some(SharedBody::Occurrence {
|
||
body:
|
||
OccurrenceBody::ScenarioUse {
|
||
scenario: Some(target),
|
||
..
|
||
},
|
||
..
|
||
}) = p.elements[id].shared()
|
||
{
|
||
out = out.push(action(
|
||
&format!("Open {} →", p.named(*target)),
|
||
Message::Select(*target),
|
||
));
|
||
}
|
||
out = sequence_rows(p, *id, out);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[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,
|
||
revision: 0,
|
||
epoch: 0,
|
||
}
|
||
}
|
||
#[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_observed_scroll_current_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 original = app.document.snapshot();
|
||
app.busy = true;
|
||
for (area, offset) in [
|
||
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
|
||
(ScrollArea::Page, iced::Vector::new(0., 40.)),
|
||
] {
|
||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
|
||
context, area, offset,
|
||
)));
|
||
}
|
||
assert!(app.busy);
|
||
assert_eq!(
|
||
app.behavior.scenario.scroll_offset(),
|
||
iced::Vector::new(25., 140.)
|
||
);
|
||
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::Canvas,
|
||
iced::Vector::new(0., 900.),
|
||
)));
|
||
assert_eq!(
|
||
app.behavior.scenario.scroll_offset(),
|
||
iced::Vector::new(25., 140.)
|
||
);
|
||
let root = std::path::PathBuf::from("scenario-scroll-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));
|
||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||
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.scroll_offset;
|
||
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
|
||
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 + scene.scroll_offset),
|
||
)
|
||
.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 {
|
||
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!(
|
||
scene
|
||
.boxes
|
||
.iter()
|
||
.any(|b| b.id == steps[0] && b.rect.width == 8.)
|
||
);
|
||
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 {
|
||
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());
|
||
}
|
||
}
|