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

3234 lines
120 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}