//! Staged scenario authoring. UI picks semantic identities; the core validates //! the complete transaction, including correlation, lifecycle and reuse rules. use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; mod workspace; pub use workspace::{Shelf, view}; #[cfg(test)] #[path = "scenario_operation_tests.rs"] mod operation_tests; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Tool { Lifeline, Message, Reuse, Action, Observation, Fragment, } impl Tool { pub(super) fn canvas_occurrence(self) -> bool { matches!( self, Self::Reuse | Self::Action | Self::Observation | Self::Fragment ) } pub(super) fn uses_lifeline(self) -> bool { matches!(self, Self::Action | Self::Observation) } fn label(self) -> &'static str { match self { Self::Lifeline => "Add lifeline", Self::Message => "Add message", Self::Reuse => "Reuse scenario", Self::Action => "Add action", Self::Observation => "Observe state", Self::Fragment => "Add fragment", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum MessageStyle { Asynchronous, Call, CallReply, Reply, Create, Delete, Timer, } impl std::fmt::Display for MessageStyle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Asynchronous => "Asynchronous message", Self::Call => "Synchronous call", Self::CallReply => "Call + correlated reply", Self::Reply => "Reply to existing call", Self::Create => "Create participant", Self::Delete => "Delete participant", Self::Timer => "Local timer", }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Context { pub document: Id, pub scenario: Id, pub revision: u64, pub epoch: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Slot { pub parent: Id, pub before: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum CanvasTool { #[default] Select, Message(MessageStyle), Add(Tool), } impl std::fmt::Display for CanvasTool { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Select => f.write_str("Select / reorder"), Self::Message(style) => write!(f, "{style}"), Self::Add(tool) => f.write_str(tool.label()), } } } #[derive(Debug, Clone)] pub enum CanvasEvent { Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), Viewport(Context, Size), Zoom(Context, Option), Focus(Context), Shelf(Context, Shelf), OpenUse(Context, Id), Back(Context), Choose(Context, CanvasTool), Select(Context, Id), Begin { context: Context, slot: Slot, sender: Id, receiver: Id, style: MessageStyle, }, BeginOccurrence { context: Context, tool: Tool, slot: Slot, lifeline: Option, }, Reorder { context: Context, occurrence: Id, slot: Slot, }, } #[derive(Debug, Clone, Copy)] pub enum ScrollArea { Canvas, // Retained navigation channel, now the independent Selection/outline shelf. Page, } impl CanvasEvent { pub fn context(&self) -> Context { match self { Self::Scrolled(context, _, _) | Self::Restored(context, ..) | Self::Viewport(context, _) | Self::Zoom(context, _) | Self::Focus(context) | Self::Shelf(context, _) | Self::OpenUse(context, _) | Self::Back(context) | Self::Choose(context, _) | Self::Select(context, _) | Self::Begin { context, .. } | Self::BeginOccurrence { context, .. } | Self::Reorder { context, .. } => *context, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Operator(pub FragmentOperator); impl std::fmt::Display for Operator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self.0 { FragmentOperator::Alternative => "ALT · alternatives", FragmentOperator::Parallel => "PAR · parallel", FragmentOperator::Loop => "LOOP · repetition", FragmentOperator::Optional => "OPT · optional", }) } } #[derive(Debug, Clone)] pub enum Event { Start(Id, Tool), Cancel, Submit, Name(String), Target(Choice), Exchange(Choice), Sender(Choice), Receiver(Choice), Call(Choice), Style(MessageStyle), OperationIntent(bool), Operation(Choice), ClearOperation, Parent(Choice), Expression(String), SecondGuard(String), Operator(Operator), Binding(Id, Choice), Value(crate::data_values::FieldPathKey, String), ValueFocus(crate::data_values::FieldPathKey), ValueBrowse(Option), 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, canvas_tool: Option<(Id, CanvasTool)>, call_choices: RefCell)>>, scroll_offsets: [iced::Vector; 2], return_to: Vec, restore: Option, presentation: workspace::Presentation, } const CALLER_LIMIT: usize = 32; pub(crate) struct Caller { document: Id, pub owner: Id, pub occurrence: Id, target: Id, tool: CanvasTool, offsets: [iced::Vector; 2], presentation: workspace::Presentation, } struct ViewportRestore { document: Id, owner: Id, token: Id, offsets: [iced::Vector; 2], pending: [bool; 2], } #[derive(Clone, Copy)] pub(super) struct ScrollRestore { pub document: Id, pub owner: Id, pub token: Id, pub offset: iced::Vector, } #[derive(PartialEq, Eq)] struct CallChoiceContext { document: Id, revision: u64, owner: Id, parent: Id, before: Option, endpoints: Option<(Option, Option)>, } #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Draft { owner: Id, parent: Id, before: Option, fixed_endpoints: bool, tool: Tool, name: String, target: Option, exchange: Option, sender: Option, receiver: Option, call: Option, style: MessageStyle, #[serde(default)] operation: Option, #[serde(default)] disabled_operation: Option, expression: String, second_guard: String, operator: FragmentOperator, bindings: BTreeMap, #[serde(with = "recovery_values")] values: BTreeMap, #[serde(with = "recovery_field_key")] value_focus: Option, #[serde(with = "recovery_field_key")] value_browse: Option, value_browse_page: usize, value_authored_page: usize, choice_search: String, choice_page: usize, } impl State { pub(crate) fn enter(&mut self, document: Id, owner: Id) { if self.presentation.identity != Some((document, owner)) { self.presentation = workspace::Presentation { identity: Some((document, owner)), focused: self.presentation.focused, ..Default::default() }; self.scroll_offsets = [iced::Vector::ZERO; 2]; self.restore = None; } } pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } pub(crate) fn focused(&self) -> bool { self.presentation.focused } pub(crate) fn toggle_focus(&mut self) { self.presentation.focused = !self.presentation.focused; } pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) { self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf); } pub(crate) fn open_use( &mut self, p: &Project, context: Context, occurrence: Id, ) -> Result { let target = referenced_scenario(p, context.scenario, occurrence) .ok_or("Choose a scenario reference in the current scenario.")?; self.return_to .retain(|frame| frame.document == context.document); if self.return_to.len() == CALLER_LIMIT { self.return_to.remove(0); } self.return_to.push(Caller { document: context.document, owner: context.scenario, occurrence, target, tool: self.canvas_tool(context.scenario), offsets: self.scroll_offsets, presentation: self.presentation.clone(), }); self.canvas_tool = Some((target, CanvasTool::Select)); self.scroll_offsets = [iced::Vector::ZERO; 2]; self.restore = None; Ok(target) } fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool { frame.document == context.document && frame.target == context.scenario && referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target) } pub(super) fn can_back(&self, p: &Project, context: Context) -> bool { self.return_to .iter() .any(|frame| Self::valid_caller(p, context, frame)) } pub(crate) fn back(&mut self, p: &Project, context: Context) -> Option { while let Some(frame) = self.return_to.pop() { if Self::valid_caller(p, context, &frame) { return Some(frame); } } None } pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) { if context.document != frame.document || context.scenario != frame.owner { return; } self.canvas_tool = Some((frame.owner, frame.tool)); self.scroll_offsets = frame.offsets; self.presentation = frame.presentation; self.presentation.identity = Some((context.document, context.scenario)); self.restore = Some(ViewportRestore { document: context.document, owner: frame.owner, token: Id::new_v4(), offsets: frame.offsets, pending: [true; 2], }); } pub(super) fn scroll_restore( &self, context: Context, area: ScrollArea, ) -> Option { let index = match area { ScrollArea::Canvas => 0, ScrollArea::Page => 1, }; let restore = self.restore.as_ref().filter(|r| { r.document == context.document && r.owner == context.scenario && r.pending[index] })?; Some(ScrollRestore { document: restore.document, owner: restore.owner, token: restore.token, offset: restore.offsets[index], }) } pub(crate) fn restored( &mut self, context: Context, token: Id, area: ScrollArea, offset: iced::Vector, ) -> bool { let index = match area { ScrollArea::Canvas => 0, ScrollArea::Page => 1, }; let Some(restore) = self.restore.as_mut().filter(|r| { r.document == context.document && r.owner == context.scenario && r.token == token && r.pending[index] }) else { return false; }; restore.pending[index] = false; if !restore.pending.contains(&true) { self.restore = None; } self.scrolled(area, offset) } pub(super) fn recovery_snapshot(&self) -> Result, 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 { 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 { if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. { return false; } 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 { // The former page scroll now belongs to a sibling outline shelf. self.scroll_offsets[0] } fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec { 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 { let draft = self.draft.as_ref()?; fn created(command: &Command, parent: Id) -> Option { match command { Command::CreateElement(element) if element.parent == Some(parent) => { Some(element.id) } Command::Batch(commands) => { commands.iter().find_map(|command| created(command, parent)) } _ => None, } } created(command, draft.parent) } pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool { self.canvas_tool .filter(|(id, _)| *id == owner) .map(|(_, tool)| tool) .unwrap_or_default() } pub(crate) fn choose_tool(&mut self, owner: Id, tool: CanvasTool) { self.canvas_tool = Some((owner, tool)); } pub(crate) fn begin_message( &mut self, p: &Project, context: Context, slot: Slot, sender: Id, receiver: Id, style: MessageStyle, ) -> Result<(), String> { slot_index(p, context.scenario, slot)?; for line in [sender, receiver] { if p.elements .get(&line) .is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(context.scenario)) || represented(p, line).is_none() { return Err("Choose represented lifelines in this scenario.".into()); } } self.update(p, Event::Start(context.scenario, Tool::Message))?; let draft = self.draft.as_mut().expect("Start installed the draft"); draft.parent = slot.parent; draft.before = slot.before; draft.fixed_endpoints = true; draft.sender = Some(sender); draft.receiver = (style != MessageStyle::Timer).then_some(receiver); draft.style = style; Ok(()) } pub(crate) fn begin_occurrence( &mut self, p: &Project, context: Context, tool: Tool, slot: Slot, lifeline: Option, ) -> Result<(), String> { if !tool.canvas_occurrence() { return Err( "Choose an action, state observation, scenario reference or fragment.".into(), ); } slot_index(p, context.scenario, slot)?; if let Some(line) = lifeline && (!tool.uses_lifeline() || p.elements.get(&line).is_none_or(|e| { e.kind() != Kind::Lifeline || e.parent != Some(context.scenario) }) || represented(p, line).is_none()) { return Err("Choose a represented lifeline in this scenario.".into()); } self.update(p, Event::Start(context.scenario, tool))?; let draft = self.draft.as_mut().expect("Start installed the draft"); draft.parent = slot.parent; draft.before = slot.before; draft.sender = lifeline; Ok(()) } pub(super) fn pending_placement(&self, owner: Id) -> Option<(Slot, Option)> { let draft = self .draft .as_ref() .filter(|d| d.owner == owner && d.tool != Tool::Lifeline)?; Some(( Slot { parent: draft.parent, before: draft.before, }, draft.sender, )) } pub(super) fn pending_owner(&self) -> Option { self.draft.as_ref().map(|d| d.owner) } pub(super) fn prepare_pending(&self, p: &Project) -> Result { 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, 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, operation: None, disabled_operation: None, expression: String::new(), second_guard: "else".into(), operator: FragmentOperator::Alternative, bindings: BTreeMap::new(), values: BTreeMap::new(), value_focus: None, value_browse: None, value_browse_page: 0, value_authored_page: 0, choice_search: String::new(), choice_page: 0, }); } Event::Cancel => self.acknowledge_commit(), Event::Submit => return self.prepare_pending(p).map(Some), other => { if let Some(d) = &mut self.draft { match other { Event::Name(s) => d.name = s, Event::Expression(s) => d.expression = s, Event::SecondGuard(s) => d.second_guard = s, Event::OperationIntent(enabled) => { if enabled { d.operation = Some( d.operation .clone() .or_else(|| d.disabled_operation.take()) .unwrap_or(OperationUse { allocation: None }), ); } else { d.disabled_operation = d.operation.take().or_else(|| d.disabled_operation.take()); } } Event::Operation(c) => { d.operation = Some(OperationUse { allocation: Some(c.id), }) } Event::ClearOperation => { d.operation = Some(OperationUse { allocation: None }) } Event::Target(c) => { d.target = Some(c.id); d.bindings.clear(); } Event::Exchange(c) => { if d.exchange != Some(c.id) { d.exchange = Some(c.id); if !d.has_operation_intent(p) { d.reset_values(); } if !d.fixed_endpoints { d.sender = None; d.receiver = None; } } } Event::Sender(c) => d.sender = Some(c.id), Event::Receiver(c) => d.receiver = Some(c.id), Event::Parent(c) => { if d.parent != c.id { d.parent = c.id; d.before = None; } } Event::ChoiceSearch(value) => { d.choice_search = value; d.choice_page = 0; } Event::ChoicePage(page) => d.choice_page = page, Event::AppendInstead => d.before = None, Event::Style(style) => { let previous = d.style; d.style = style; if previous != style { if previous == MessageStyle::Timer || style == MessageStyle::Timer { d.exchange = None; d.sender = None; d.receiver = None; d.call = None; } else if (previous == MessageStyle::Reply) != (style == MessageStyle::Reply) { std::mem::swap(&mut d.sender, &mut d.receiver); d.call = None; } if !d.has_operation_intent(p) { d.retain_value_paths(p); } } } Event::Call(c) => { d.call = Some(c.id); if let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange, sender, receiver, .. }, .. }) = p.elements.get(&c.id).and_then(|e| e.shared()) { if d.exchange != *exchange && !d.has_operation_intent(p) { d.reset_values(); } d.exchange = *exchange; d.sender = *receiver; d.receiver = *sender; } } Event::Operator(operator) => d.operator = operator.0, Event::Binding(referenced, c) => { d.bindings.insert(referenced, c.id); } Event::Value(id, s) => { d.values.insert(id, s); } Event::ValueFocus(key) if d.signature(p) .ok() .flatten() .and_then(|r| { crate::data_values::resolve(p, &r, &d.value_kind(), &key) }) .is_some() => { d.value_focus = Some(key); } Event::ValueBrowse(key) => { if key.as_ref().is_none_or(|key| { d.signature(p) .ok() .flatten() .and_then(|r| { crate::data_values::resolve(p, &r, &d.value_kind(), key) }) .is_some() }) { d.value_browse = key; d.value_browse_page = 0; } } Event::ValuePage(section, page) => match section { ValueSection::Fields => d.value_browse_page = page, ValueSection::Authored => d.value_authored_page = page, }, _ => {} } } } } Ok(None) } } mod recovery_values { use super::*; use serde::{Deserialize, Serialize}; pub fn serialize( values: &BTreeMap, serializer: S, ) -> Result { values .iter() .map(|(key, value)| (&key.path, key.field, value)) .collect::>() .serialize(serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { let values = Vec::<(Vec, 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( key: &Option, serializer: S, ) -> Result { key.as_ref() .map(|key| (&key.path, key.field)) .serialize(serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { Ok(Option::<(Vec, Id)>::deserialize(deserializer)? .map(|(path, field)| crate::data_values::FieldPathKey { path, field })) } } fn viewpoint(p: &Project, owner: Id) -> ScenarioViewpoint { match p.elements.get(&owner).and_then(|e| e.shared()) { Some(SharedBody::Scenario { viewpoint, .. }) => *viewpoint, _ => ScenarioViewpoint::MixedLegacy, } } pub(crate) fn referenced_scenario(p: &Project, owner: Id, occurrence: Id) -> Option { if p.elements.get(&owner)?.kind() != Kind::Scenario || validation::ancestor_of_kind(p, occurrence, Kind::Scenario) != Some(owner) { return None; } let SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(target), .. }, .. } = p.elements.get(&occurrence)?.shared()? else { return None; }; p.elements .get(target) .filter(|e| e.kind() == Kind::Scenario) .map(|e| e.id) } fn represented(p: &Project, line: Id) -> Option { 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 { p.children(owner) .filter(|e| e.kind() == Kind::Lifeline) .map(|e| named_choice(p, e.id)) .collect() } fn parents(p: &Project, owner: Id) -> Vec { 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 { 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 { 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 { 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 { // 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 { p.elements .values() .filter(|e| validation::ancestor_of_kind(p, e.id, Kind::Scenario) == Some(d.owner)) .filter_map(|e| { let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, exchange, sender, receiver, .. }, .. }) = e.shared() else { return None; }; if d.fixed_endpoints && (d.sender != *receiver || d.receiver != *sender) { return None; } let mut candidate = d.clone(); candidate.style = MessageStyle::Reply; candidate.call = Some(e.id); // Candidate availability must not fluctuate while typing names or // expressions. These draft values are validated only on Apply. candidate.reset_values(); candidate.name.clear(); candidate.exchange = *exchange; candidate.sender = *receiver; candidate.receiver = *sender; candidate .command(p) .ok() .and_then(|command| execute_batch(p, &[command]).ok()) .map(|_| named_choice(p, e.id)) }) .collect() } impl Draft { fn has_operation_intent(&self, p: &Project) -> bool { self.operation.is_some() || self .call .and_then(|id| p.elements.get(&id)) .is_some_and(|e| { matches!( e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(_), .. }, .. }) ) }) } fn proposed_message(&self, p: &Project) -> oa_core::Element { let mut message = self.append(p, "Operation preview", self.message_body()); // This is a transient resolver input; no generated identity reaches a command. message.id = Id::nil(); message } fn signature(&self, p: &Project) -> Result, String> { crate::data_values::message_signature(p, &self.proposed_message(p)) } fn reset_values(&mut self) { self.values.clear(); self.value_focus = None; self.value_browse = None; self.value_browse_page = 0; self.value_authored_page = 0; } fn retain_value_paths(&mut self, p: &Project) { let kind = self.kind(); let exchange = self.exchange.and_then(|id| p.relations.get(&id)); self.values.retain(|key, _| { exchange .and_then(|r| crate::data_values::resolve(p, r, &kind, key)) .is_some() }); if self.value_focus.as_ref().is_some_and(|key| { exchange .and_then(|r| crate::data_values::resolve(p, r, &kind, key)) .is_none() }) { self.value_focus = None; } if self.value_browse.as_ref().is_some_and(|key| { exchange .and_then(|r| crate::data_values::resolve(p, r, &kind, key)) .is_none() }) { self.value_browse = None; self.value_browse_page = 0; } } fn browsed_values( &self, p: &Project, exchange: &Relation, ) -> Result, String> { match &self.value_browse { Some(key) => crate::data_values::children(p, exchange, &self.value_kind(), key), None => Ok(crate::data_values::roots(p, exchange, &self.value_kind())), } } fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> { self.values .iter() .filter(|(_, value)| !value.trim().is_empty()) .map(|(key, value)| (key, value.as_str())) .collect() } fn value_kind(&self) -> MessageKind { crate::data_values::operation_value_phase(&self.kind(), self.operation.as_ref()) } fn kind(&self) -> MessageKind { match self.style { MessageStyle::Asynchronous => MessageKind::Asynchronous, MessageStyle::Call | MessageStyle::CallReply => MessageKind::Call, MessageStyle::Reply => MessageKind::Reply, MessageStyle::Create => MessageKind::Create, MessageStyle::Delete => MessageKind::Delete, MessageStyle::Timer => MessageKind::Timer { expression: self.expression.clone(), }, } } fn message_body(&self) -> OccurrenceBody { OccurrenceBody::Exchange { exchange: if self.style == MessageStyle::Timer { None } else { self.exchange }, sender: self.sender, receiver: if self.style == MessageStyle::Timer { None } else { self.receiver }, message_kind: self.kind(), operation: if matches!( self.style, MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous ) { self.operation.clone() } else { None }, reply_to: self.call, values: self .values .iter() .filter(|(_, v)| !v.trim().is_empty()) .map(|(field, expression)| MessageValue { path: field.path.clone(), field: field.field, expression: expression.clone(), }) .collect(), } } fn line_choices(&self, p: &Project, sender: bool) -> Vec { 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 { 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, operation: None, reply_to: Some(call_id), values: vec![], }, ))); } } Tool::Reuse => { let target = self.target.ok_or("Choose a scenario to reuse.")?; if !sources(p, self).iter().any(|c| c.id == target) { return Err("Choose a compatible scenario without recursive reuse.".into()); } let mut used = BTreeSet::new(); let mut bindings = vec![]; for line in lines(p, target) { let local = *self .bindings .get(&line.id) .ok_or("Bind every referenced lifeline to a local lifeline.")?; if !lines(p, self.owner).iter().any(|c| c.id == local) || !compatible_binding(p, line.id, local) || !used.insert(local) { return Err( "Each binding needs a distinct compatible local lifeline.".into() ); } bindings.push(LifelineBinding { referenced: line.id, local, }); } commands.push(Command::CreateElement(self.append( p, name, OccurrenceBody::ScenarioUse { scenario: Some(target), bindings, }, ))); } Tool::Action | Tool::Observation => { let target = self .target .ok_or("Choose the action or state to observe.")?; if !sources(p, self).iter().any(|c| c.id == target) { return Err("Choose compatible work or a state in this layer.".into()); } let line = self.sender.ok_or("Choose the observed lifeline.")?; if !lines(p, self.owner).iter().any(|c| c.id == line) { return Err("Choose a lifeline in this scenario.".into()); } let body = if self.tool == Tool::Action { OccurrenceBody::Action { action: Some(target), lifeline: Some(line), } } else { OccurrenceBody::State { state: Some(target), lifeline: Some(line), } }; commands.push(Command::CreateElement(self.append(p, name, body))); } Tool::Fragment => { let two = matches!( self.operator, FragmentOperator::Alternative | FragmentOperator::Parallel ); if self.operator != FragmentOperator::Parallel && self.expression.trim().is_empty() { return Err("Describe the operand condition or loop guard.".into()); } let fragment = self.append( p, name, OccurrenceBody::Fragment { operator: self.operator, branches: vec![], }, ); let id = fragment.id; commands.push(Command::CreateElement(fragment)); for (i, guard) in [&self.expression, &self.second_guard] .into_iter() .take(if two { 2 } else { 1 }) .enumerate() { let mut branch = self.append( p, &format!("{} · operand {}", name, i + 1), OccurrenceBody::Branch { guard: if self.operator == FragmentOperator::Parallel { String::new() } else { guard.clone() }, occurrences: vec![], }, ); branch.parent = Some(id); commands.push(Command::CreateElement(branch)); } } } if self.tool != Tool::Lifeline { let slot = Slot { parent: self.parent, before: self.before, }; let index = slot_index(p, self.owner, slot)?; let added: Vec<_> = commands .iter() .filter_map(|command| match command { Command::CreateElement(e) if e.parent == Some(self.parent) && e.kind() == Kind::Occurrence => { Some(e.id) } _ => None, }) .collect(); let mut steps = p.elements[&self.parent] .steps() .expect("slot validated") .clone(); steps.splice(index..index, added); commands.push(Command::SetSteps(self.parent, steps)); } Ok(unique_batch(p, commands)) } } fn event(e: Event) -> Message { Message::Behavior(super::Event::Scenario(e)) } fn value_page(len: usize, requested: usize) -> std::ops::Range { let start = requested.min(len.saturating_sub(1) / VALUE_PAGE_SIZE) * VALUE_PAGE_SIZE; start..(start + VALUE_PAGE_SIZE).min(len) } fn value_pager(len: usize, requested: usize, section: ValueSection) -> Element<'static, Message> { let range = value_page(len, requested); let page = range.start / VALUE_PAGE_SIZE; row![ button("Previous") .on_press_maybe((page > 0).then(|| event(Event::ValuePage(section, page - 1)))), text(format!( "Page {} of {} · {} fields", page + 1, len.div_ceil(VALUE_PAGE_SIZE).max(1), len )) .size(11), button("Next") .on_press_maybe((range.end < len).then(|| event(Event::ValuePage(section, page + 1)))) ] .spacing(6) .into() } fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a, Message> { use crate::data_values; let mut out = column![text("Message values").size(14)].spacing(7); if let Some(browse) = &d.value_browse { let title = data_values::resolve(p, exchange, &d.value_kind(), browse) .map(|choice| format!("Fields inside {}", choice.label)) .unwrap_or_else(|| "This record path is no longer available in this message.".into()); out = out.push(text(title).size(11)).push( row![ button("Root fields").on_press(event(Event::ValueBrowse(None))), button("Up one level") .on_press(event(Event::ValueBrowse(data_values::parent(browse)))) ] .spacing(6), ); } else { out = out.push(text("Root fields · choose a record to browse its fields").size(11)); } match d.browsed_values(p, exchange) { Ok(choices) if !choices.is_empty() => { let range = value_page(choices.len(), d.value_browse_page); let visible = choices[range].to_vec(); let selected = visible .iter() .find(|choice| Some(&choice.key) == d.value_focus.as_ref()) .cloned(); out = out .push(value_pager( choices.len(), d.value_browse_page, ValueSection::Fields, )) .push( pick_list(visible, selected, |choice| { event(Event::ValueFocus(choice.key)) }) .placeholder("Choose a field on this page…") .text_size(12) .padding(8) .width(Fill), ); } Ok(_) => { out = out.push(text("No fields at this level for this message phase.").size(11)); } Err(error) => out = out.push(text(error).size(11).color(style::MUTED)), } match d .value_focus .as_ref() .map(|key| data_values::resolve(p, exchange, &d.value_kind(), key)) { Some(Some(focus)) => { let key = focus.key; out = out .push(text(focus.label).size(12)) .push( text_input( "Optional value expression", d.values.get(&key).map(String::as_str).unwrap_or(""), ) .on_input({ let key = key.clone(); move |value| event(Event::Value(key.clone(), value)) }) .padding(8) .size(12), ) .push(text("An empty expression omits this value.").size(11)); let mut actions = row![ button("Show enclosing fields") .on_press(event(Event::ValueBrowse(data_values::parent(&key)))) ] .spacing(6); if matches!(p.elements.get(&key.field).and_then(oa_core::Element::shared), Some(SharedBody::DataField { type_ref: TypeRef::Named(record), storage: FieldStorage::Value, .. }) if data::is_record(p, *record)) { actions = actions.push( button("Browse record fields").on_press(event(Event::ValueBrowse(Some(key)))), ); } out = out.push(actions); } Some(None) => { out = out.push(text("The selected path is unavailable in this message. Choose a compatible field; entered values remain in the draft.") .size(11).color(style::MUTED)); } None => out = out.push(text("Choose a field to enter its value expression.").size(11)), } let authored = d.authored_values(); out = out.push(text(format!("Authored values ({})", authored.len())).size(12)); if authored.is_empty() { out = out.push(text("No value expressions entered.").size(11)); } else { out = out.push(value_pager( authored.len(), d.value_authored_page, ValueSection::Authored, )); for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] { let title = data_values::resolve(p, exchange, &d.value_kind(), key) .map(|choice| choice.label) .unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field)); out = out.push( button( column![text(title).size(11), text(short(expression, 100)).size(11)].spacing(3), ) .on_press(event(Event::ValueFocus((*key).clone()))) .width(Fill), ); } } out.into() } fn picker<'a>( choices: Vec, selected: Option, 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, d: &'a Draft, selected: Option, placeholder: &'a str, change: impl Fn(Choice) -> Event + 'a, ) -> Element<'a, Message> { let current = choices .iter() .find(|choice| Some(choice.id) == selected) .cloned(); let query = d.choice_search.trim().to_lowercase(); choices.retain(|choice| choice.label.to_lowercase().contains(&query)); choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); let len = choices.len(); let range = value_page(len, d.choice_page); let page = range.start / VALUE_PAGE_SIZE; let mut out = column![ text_input("Search compatible exchanges / calls…", &d.choice_search) .on_input(|value| event(Event::ChoiceSearch(value))) .padding(8) .size(12) ] .spacing(6); if let Some(choice) = current.as_ref().filter(|choice| { !choices[range.clone()] .iter() .any(|visible| visible.id == choice.id) }) { out = out.push(label(format!("Selected: {}", choice.label))); } if len == 0 { out = out.push(label("No matching exchange or eligible earlier call. Change the search, endpoints or destination, or cancel.")); } else { out = out .push( row![ button("Previous") .on_press_maybe((page > 0).then(|| event(Event::ChoicePage(page - 1)))), text(format!( "{} choices · page {}/{}", len, page + 1, len.div_ceil(VALUE_PAGE_SIZE) )) .size(11), button("Next").on_press_maybe( (range.end < len).then(|| event(Event::ChoicePage(page + 1))) ), ] .spacing(6), ) .push( pick_list(choices[range].to_vec(), current, move |choice| { event(change(choice)) }) .placeholder(placeholder) .padding(9) .text_size(12) .width(Fill), ); } out.into() } #[cfg(test)] pub(crate) mod direct_tests { use super::*; pub struct Fixture { pub project: Project, pub owner: Id, pub exchange: Id, pub sender: Id, pub receiver: Id, pub call: Id, pub reply: Id, } pub fn fixture() -> Fixture { let f = crate::data_values::tests::payload(); let mut p = f.project; let mut lines = vec![]; for (name, represented) in [ ("Caller", p.relations[&f.exchange].source()), ("Callee", p.relations[&f.exchange].target()), ] { let id = p .add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario)) .unwrap(); *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { represented: Some(represented), }; lines.push(id); } let mut state = State::default(); for event in [ Event::Start(f.scenario, Tool::Message), Event::Exchange(named_choice(&p, f.exchange)), Event::Sender(named_choice(&p, lines[0])), Event::Receiver(named_choice(&p, lines[1])), ] { state.update(&p, event).unwrap(); } p = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); let steps = p.elements[&f.scenario].steps().unwrap(); let (call, reply) = (steps[0], steps[1]); Fixture { project: p, owner: f.scenario, exchange: f.exchange, sender: lines[0], receiver: lines[1], call, reply, } } pub fn context(owner: Id) -> Context { Context { document: Id::new_v4(), scenario: owner, revision: 0, epoch: 0, } } pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) { let mut f = fixture(); let p = &mut f.project; let machine = p .add_element(Kind::StateMachine, Layer::Oa, "Context", None) .unwrap(); let region = p .add_element(Kind::Region, Layer::Oa, "Main region", Some(machine)) .unwrap(); let state = p .add_element(Kind::State, Layer::Oa, "Ready", Some(region)) .unwrap(); let reused = p .add_element(Kind::Scenario, Layer::Oa, "Reusable check", None) .unwrap(); let remote = p .add_element(Kind::Lifeline, Layer::Oa, "Reused caller", Some(reused)) .unwrap(); let represented = represented(p, f.sender); *p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { represented }; let fragment = occurrence( p, f.owner, "Outer alternative", OccurrenceBody::Fragment { operator: FragmentOperator::Alternative, branches: vec![], }, ); let fragment_id = fragment.id; *p = execute_batch(p, &[Command::CreateElement(fragment)]).unwrap(); let branch = occurrence( p, fragment_id, "Chosen operand", OccurrenceBody::Branch { guard: "ready".into(), occurrences: vec![], }, ); let branch_id = branch.id; *p = execute_batch(p, &[Command::CreateElement(branch)]).unwrap(); let anchor = occurrence( p, branch_id, "Existing check", OccurrenceBody::Action { action: represented, lifeline: Some(f.sender), }, ); let anchor_id = anchor.id; *p = execute_batch(p, &[Command::CreateElement(anchor)]).unwrap(); (f, branch_id, anchor_id, state, reused, remote) } fn canvas_mouse( scene: &Scene, input: &mut SequenceInput, event: mouse::Event, point: Point, bounds: Rectangle, ) -> Option { canvas::Program::update( scene, input, &canvas::Event::Mouse(event), bounds, mouse::Cursor::Available(point), ) .and_then(|action| action.into_inner().0) } fn form(app: &mut App, event: Event) { let _ = app.update(Message::Behavior(super::super::Event::Scenario(event))); } pub fn navigation_fixture() -> (Project, [Id; 3], [Id; 3]) { let mut p = Project::empty(); let owners = ["A caller", "B definition", "C definition"].map(|name| { p.add_element(Kind::Scenario, Layer::Oa, name, None) .unwrap() }); let uses = [ (owners[0], owners[1]), (owners[0], owners[1]), (owners[1], owners[2]), ] .map(|(owner, target)| { let element = occurrence( &p, owner, "Reference", OccurrenceBody::ScenarioUse { scenario: Some(target), bindings: vec![], }, ); let id = element.id; p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap(); id }); (p, owners, uses) } pub fn navigation_event(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) { let context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(event(context))); } #[test] fn direct_scenario_reference_navigation_retains_each_exact_caller_without_history() { let (p, [a, b, c], [first, second, nested]) = navigation_fixture(); let mut app = App::with_project(p.clone()); let _ = app.update(Message::Select(a)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); let source = app.document.bundle().clone(); let revision = app.document.revision(); for caller in [second, first] { navigation_event(&mut app, |ctx| { CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse)) }); navigation_event(&mut app, |ctx| { CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.)) }); navigation_event(&mut app, |ctx| { CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.)) }); navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller)); assert_eq!(app.selected, b); assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO); navigation_event(&mut app, |ctx| { CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) }); navigation_event(&mut app, |ctx| { CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.)) }); navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested)); assert_eq!(app.selected, c); navigation_event(&mut app, CanvasEvent::Back); assert_eq!(app.selected, nested); assert_eq!( app.behavior.scenario.canvas_tool(b), CanvasTool::Message(MessageStyle::Timer) ); assert_eq!( app.behavior.scenario.scroll_offset(), iced::Vector::new(0., 115.) ); navigation_event(&mut app, CanvasEvent::Back); assert_eq!(app.selected, caller); assert_eq!(app.scenario_context().unwrap().scenario, a); assert_eq!( app.behavior.scenario.canvas_tool(a), CanvasTool::Add(Tool::Reuse) ); assert_eq!( app.behavior.scenario.scroll_offset(), iced::Vector::new(25., 170.) ); assert_eq!( app.behavior.scenario.scroll_offsets[1], iced::Vector::new(0., 80.), "The caller's independent outline scroll is restored separately" ); assert!( !app.behavior .scenario .can_back(app.project(), app.scenario_context().unwrap()) ); assert_eq!(app.project(), &p); assert_eq!(app.document.bundle(), &source); assert_eq!(app.document.revision(), revision); assert!(!app.document.can_undo()); assert!(!app.document.can_redo()); } } #[test] fn direct_scenario_reference_navigation_blocks_pending_drafts_and_stale_contexts() { let (p, [a, b, _], [first, _, _]) = navigation_fixture(); for back in [false, true] { for pending in 0..3 { let mut app = App::with_project(p.clone()); let _ = app.update(Message::Select(a)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); if back { navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, first)); } match pending { 0 => { let _ = app.update(Message::Field("/name".into(), "".into())); } 1 => { let _ = app.update(Message::SourceEdit( iced::widget::text_editor::Action::Edit( iced::widget::text_editor::Edit::Insert('?'), ), )); } _ => { navigation_event(&mut app, |context| CanvasEvent::BeginOccurrence { context, tool: Tool::Action, slot: Slot { parent: context.scenario, before: None, }, lifeline: None, }); form(&mut app, Event::Name(" Unfinished action ".into())); } } let selected = app.selected; let source = app.document.bundle().clone(); let raw_source = app.source_editor.text(); let property_snapshot = |app: &App| { app.draft .as_ref() .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()) }; let raw_properties = property_snapshot(&app); let raw_behavior = app.behavior.scenario.recovery_snapshot().unwrap(); navigation_event(&mut app, |ctx| { if back { CanvasEvent::Back(ctx) } else { CanvasEvent::OpenUse(ctx, first) } }); assert_eq!(app.selected, selected); assert_eq!(app.source_editor.text(), raw_source); assert_eq!(property_snapshot(&app), raw_properties); assert_eq!( app.behavior.scenario.recovery_snapshot().unwrap(), raw_behavior ); assert_eq!(app.document.bundle(), &source); assert_eq!(app.project(), &p); assert!(!app.document.can_undo()); assert!(app.status.contains("Apply or cancel"), "{}", app.status); if back { assert_eq!(selected, b); } } } let mut app = App::with_project(p.clone()); let _ = app.update(Message::Select(a)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); for dimension in 0..4 { let mut stale = app.scenario_context().unwrap(); match dimension { 0 => stale.document = Id::new_v4(), 1 => stale.scenario = b, 2 => stale.revision += 1, _ => stale.epoch += 1, } let _ = app.update(Message::ScenarioCanvas(CanvasEvent::OpenUse(stale, first))); assert_eq!(app.selected, a); assert!(app.behavior.scenario.return_to.is_empty()); } navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, b)); assert_eq!( app.selected, a, "a definition is not a local reference occurrence" ); } #[test] fn direct_scenario_reference_return_survives_rename_and_rejects_deleted_or_changed_use() { let (p, [a, b, c], [first, _, _]) = navigation_fixture(); for edit in 0..4 { let mut app = App::with_project(p.clone()); let _ = app.update(Message::Select(a)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, first)); let command = match edit { 0 => Command::Batch(vec![ Command::Rename(a, "Renamed caller".into()), Command::Rename(first, "Renamed reference".into()), ]), 1 => Command::Delete(first), 2 => Command::Delete(a), _ => { let mut changed = p.elements[&first].clone(); if let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario, .. }, .. }) = changed.shared_mut() { *scenario = Some(c); } Command::EditElement(first, changed) } }; let _ = app.update(Message::Command(command)); let source = app.document.bundle().clone(); let project = app.project().clone(); let revision = app.document.revision(); assert!(app.document.can_undo(), "{}", app.status); navigation_event(&mut app, CanvasEvent::Back); assert_eq!(app.selected, if edit == 0 { first } else { b }); assert_eq!( app.scenario_context().unwrap().scenario, if edit == 0 { a } else { b }, "Back must restore the caller workspace even if a rename already selected its UUID" ); assert_eq!(app.project(), &project); assert_eq!(app.document.bundle(), &source); assert_eq!(app.document.revision(), revision); assert!(app.document.can_undo()); assert!(!app.document.can_redo()); } } #[test] fn direct_clicked_occurrences_use_root_and_operand_slots_with_exact_source_history() { let (f, branch, anchor, observed, reused, remote) = placement_fixture(); for tool in [Tool::Action, Tool::Observation, Tool::Reuse, Tool::Fragment] { for slot in [ Slot { parent: f.owner, before: Some(f.reply), }, Slot { parent: branch, before: Some(anchor), }, ] { for on_lifeline in [true, false] { let mut app = App::with_project(f.project.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); let context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Choose( context, CanvasTool::Add(tool), ))); let context = app.scenario_context().unwrap(); let before = app.document.bundle().clone(); let mut scene = Scene::sequence(app.project(), f.owner, f.call); scene.interaction = Some((context, CanvasTool::Add(tool), true)); scene.scroll_offset = iced::Vector::new(23., 80.); let bounds = Rectangle::new(Point::new(20., 40.), scene.size); let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y; let x = if on_lifeline { scene.lanes[&f.sender] } else { scene.size.width - 35. }; let point = Point::new(bounds.x + x, bounds.y + y); let mut input = SequenceInput::default(); canvas_mouse( &scene, &mut input, mouse::Event::CursorMoved { position: point - scene.scroll_offset, }, point, bounds, ); assert!( canvas_mouse( &scene, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, bounds ) .is_none() ); let message = canvas_mouse( &scene, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds, ) .unwrap(); let line = (tool.uses_lifeline() && on_lifeline).then_some(f.sender); assert!( matches!(&message, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { tool: actual, slot: captured, lifeline, .. }) if *actual == tool && *captured == slot && *lifeline == line) ); assert!( canvas_mouse( &scene, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds ) .is_none(), "one click publishes once" ); let _ = app.update(message); assert_eq!( app.behavior.scenario.pending_placement(f.owner), Some((slot, line)) ); assert_eq!(app.document.bundle(), &before); assert!(!app.document.can_undo()); form(&mut app, Event::Name(format!("Placed {tool:?}"))); match tool { Tool::Action => form( &mut app, Event::Target(named_choice( &f.project, represented(&f.project, f.sender).unwrap(), )), ), Tool::Observation => { form(&mut app, Event::Target(named_choice(&f.project, observed))) } Tool::Reuse => { form(&mut app, Event::Target(named_choice(&f.project, reused))); form( &mut app, Event::Binding(remote, named_choice(&f.project, f.sender)), ); } Tool::Fragment => form(&mut app, Event::Expression("ready".into())), _ => unreachable!(), } if tool.uses_lifeline() && !on_lifeline { form(&mut app, Event::Submit); assert!( app.behavior.has_pending(), "off-lifeline creation requires an explicit lifeline" ); assert_eq!(app.document.bundle(), &before); form(&mut app, Event::Sender(named_choice(&f.project, f.sender))); } form(&mut app, Event::Submit); assert!(!app.behavior.has_pending(), "{tool:?}: {}", app.status); let created = app.selected; assert_eq!(app.project().elements[&created].parent, Some(slot.parent)); let mut expected = f.project.elements[&slot.parent].steps().unwrap().clone(); let index = expected .iter() .position(|id| Some(*id) == slot.before) .unwrap(); expected.insert(index, created); assert_eq!( app.project().elements[&slot.parent].steps().unwrap(), &expected ); let untouched = if slot.parent == f.owner { branch } else { f.owner }; assert_eq!( app.project().elements[&untouched], f.project.elements[&untouched] ); assert_eq!(app.project().relations, f.project.relations); assert_eq!(app.project().elements[&reused], f.project.elements[&reused]); assert_eq!( language::parse(&language::format(app.project())).unwrap(), *app.project() ); let after = app.document.bundle().clone(); let result = app.project().clone(); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &before); assert_eq!(app.project(), &f.project); assert!(!app.document.can_undo()); let _ = app.update(Message::Redo); assert_eq!(app.document.bundle(), &after); assert_eq!(app.project(), &result); assert!(!app.document.can_redo()); } } } } #[test] fn direct_clicked_occurrence_rejects_stale_slots_and_retains_recoverable_drafts() { let (f, branch, anchor, _, _, remote) = placement_fixture(); let slot = Slot { parent: branch, before: Some(anchor), }; let mut state = State::default(); for (tool, slot, line) in [ (Tool::Lifeline, slot, None), ( Tool::Action, Slot { parent: f.owner, before: Some(anchor), }, Some(f.sender), ), ( Tool::Action, Slot { parent: Id::new_v4(), before: None, }, Some(f.sender), ), (Tool::Action, slot, Some(remote)), (Tool::Reuse, slot, Some(f.sender)), ] { assert!( state .begin_occurrence(&f.project, context(f.owner), tool, slot, line) .is_err() ); assert!(state.draft.is_none()); } let mut app = App::with_project(f.project.clone()); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); let original = app.document.bundle().clone(); let event = |context| CanvasEvent::BeginOccurrence { context, tool: Tool::Action, slot, lifeline: Some(f.sender), }; for dimension in 0..4 { let mut stale = app.scenario_context().unwrap(); match dimension { 0 => stale.document = Id::new_v4(), 1 => stale.scenario = Id::new_v4(), 2 => stale.revision += 1, _ => stale.epoch += 1, } let _ = app.update(Message::ScenarioCanvas(event(stale))); assert!(!app.behavior.has_pending()); } let old_context = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( old_context, ScrollArea::Canvas, iced::Vector::new(0., 100.), ))); let _ = app.update(Message::ScenarioCanvas(event(old_context))); assert!(!app.behavior.has_pending()); let current = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(event(current))); form(&mut app, Event::Name(" Λ phase — retained ".into())); let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); form(&mut app, Event::Submit); assert!( app.behavior.has_pending(), "missing action source rejects without losing the clicked slot" ); let _ = app.update(Message::Escape); let current = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( current, ScrollArea::Page, iced::Vector::new(0., 200.), ))); assert_eq!( app.behavior.scenario.recovery_snapshot().unwrap(), Some(raw.clone()) ); assert_eq!(app.document.bundle(), &original); assert!(!app.document.can_undo()); let recovered = State::from_recovery(raw.clone()).unwrap(); assert_eq!( recovered.pending_placement(f.owner), Some((slot, Some(f.sender))) ); assert_eq!(recovered.recovery_snapshot().unwrap(), Some(raw)); assert!(recovered.prepare_pending(&f.project).is_err()); form( &mut app, Event::Target(named_choice( &f.project, represented(&f.project, f.sender).unwrap(), )), ); form(&mut app, Event::Submit); assert!(!app.behavior.has_pending(), "{}", app.status); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &original); } #[test] fn direct_middle_pair_insertion_is_one_exact_source_and_model_undo() { let f = fixture(); let mut state = State::default(); let slot = Slot { parent: f.owner, before: Some(f.reply), }; state .begin_message( &f.project, context(f.owner), slot, f.sender, f.receiver, MessageStyle::CallReply, ) .unwrap(); assert!( exchanges(&f.project, state.draft.as_ref().unwrap()) .iter() .any(|choice| choice.id == f.exchange) ); state .update( &f.project, Event::Exchange(named_choice(&f.project, f.exchange)), ) .unwrap(); assert_eq!(state.draft.as_ref().unwrap().sender, Some(f.sender)); assert_eq!(state.draft.as_ref().unwrap().receiver, Some(f.receiver)); let mut document = crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap(); let source = document.bundle().clone(); document .execute(state.prepare_pending(&f.project).unwrap()) .unwrap(); let p = document.project(); let steps = p.elements[&f.owner].steps().unwrap(); assert_eq!(steps.len(), 4); assert_eq!((steps[0], steps[3]), (f.call, f.reply)); assert!( matches!(p.elements[&steps[2]].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { reply_to: Some(call), message_kind: MessageKind::Reply, exchange: Some(exchange), .. }, .. }) if *call == steps[1] && *exchange == f.exchange) ); assert_eq!(p.relations, f.project.relations); assert_eq!(language::parse(&language::format(p)).unwrap(), *p); assert!(document.undo()); assert_eq!(document.project(), &f.project); assert_eq!(document.bundle(), &source); assert!(!document.undo()); } #[test] fn direct_reply_choices_validate_the_actual_slot_and_reorder_rejects_reverse_order() { let f = fixture(); let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); let mut state = State::default(); state .begin_message( &p, context(f.owner), Slot { parent: f.owner, before: Some(f.call), }, f.receiver, f.sender, MessageStyle::Reply, ) .unwrap(); assert!(calls(&p, state.draft.as_ref().unwrap()).is_empty()); state.update(&p, Event::AppendInstead).unwrap(); assert!( calls(&p, state.draft.as_ref().unwrap()) .iter() .any(|choice| choice.id == f.call) ); state .update(&p, Event::Call(named_choice(&p, f.call))) .unwrap(); let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); let reply = *result.elements[&f.owner].steps().unwrap().last().unwrap(); assert!( reorder( &result, f.owner, reply, Slot { parent: f.owner, before: Some(f.call) } ) .unwrap_err() .contains("call") ); assert_eq!(result.elements[&f.owner].steps().unwrap(), &[f.call, reply]); } #[test] fn direct_operand_pair_keeps_parent_sequence_and_cannot_reorder_across_operands() { let f = fixture(); let mut state = State::default(); state .update(&f.project, Event::Start(f.owner, Tool::Fragment)) .unwrap(); state .update(&f.project, Event::Expression("ready".into())) .unwrap(); let p = execute_batch(&f.project, &[state.prepare_pending(&f.project).unwrap()]).unwrap(); let fragment = *p.elements[&f.owner].steps().unwrap().last().unwrap(); let branches: Vec<_> = p.children(fragment).map(|e| e.id).collect(); let root_steps = p.elements[&f.owner].steps().unwrap().clone(); let mut state = State::default(); state .begin_message( &p, context(f.owner), Slot { parent: branches[0], before: None, }, f.sender, f.receiver, MessageStyle::CallReply, ) .unwrap(); state .update(&p, Event::Exchange(named_choice(&p, f.exchange))) .unwrap(); let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); assert_eq!(result.elements[&f.owner].steps().unwrap(), &root_steps); let branch_steps = result.elements[&branches[0]].steps().unwrap(); assert_eq!(branch_steps.len(), 2); assert!( reorder( &result, f.owner, branch_steps[0], Slot { parent: branches[1], before: None } ) .is_err() ); } #[test] fn direct_busy_save_keeps_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., 100.) ); 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., 100.) ); 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 { operation: None, exchange: Some(f.exchange), sender: Some(f.sender), receiver: Some(f.receiver), message_kind: MessageKind::Call, reply_to: None, values: vec![], }, ))); } p = execute_batch(&p, &commands).unwrap(); let mut state = State::default(); let mut context = context(f.owner); state .begin_message( &p, context, Slot { parent: f.owner, before: None, }, f.receiver, f.sender, MessageStyle::Reply, ) .unwrap(); let start = std::time::Instant::now(); let expected: BTreeSet<_> = state .call_choices(&p, context, state.draft.as_ref().unwrap()) .into_iter() .map(|c| c.id) .collect(); assert_eq!(expected.len(), 100); eprintln!( "100 exact-slot earlier-call choices cold: {:?}", start.elapsed() ); for event in [ Event::Name("unfinished name".into()), Event::ChoiceSearch("Call".into()), Event::ChoicePage(2), Event::Value( crate::data_values::FieldPathKey { path: vec![], field: Id::new_v4(), }, "unfinished expression".into(), ), ] { state.update(&p, event).unwrap(); let start = std::time::Instant::now(); assert_eq!( state .call_choices(&p, context, state.draft.as_ref().unwrap()) .into_iter() .map(|c| c.id) .collect::>(), 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::() } 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::() } 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::>(); 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::>(); 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::from([f.leaf]) ); assert_eq!( call.iter().map(|v| v.path.clone()).collect::>(), BTreeSet::from([vec![f.origin], vec![f.destination]]) ); assert!( messages .iter() .find(|(kind, _)| **kind == MessageKind::Reply) .unwrap() .1 .is_empty() ); assert!(document.undo()); assert_eq!(document.project(), &f.project); assert!(!document.undo()); } #[test] fn recursive_value_focus_exposes_authored_leaf_and_phase_changes_retain_surviving_keys() { let (f, next) = crate::data_values::tests::recursive_payload(); let mut state = State::default(); state .update(&f.project, Event::Start(f.scenario, Tool::Message)) .unwrap(); state .update( &f.project, Event::Exchange(named_choice(&f.project, f.exchange)), ) .unwrap(); let key = crate::data_values::FieldPathKey { path: vec![f.origin, next], field: f.leaf, }; state .update( &f.project, Event::ValueBrowse(crate::data_values::parent(&key)), ) .unwrap(); state .update(&f.project, Event::ValueFocus(key.clone())) .unwrap(); assert!( state .draft .as_ref() .unwrap() .browsed_values(&f.project, &f.project.relations[&f.exchange]) .unwrap() .iter() .any(|c| c.key == key) ); state .update( &f.project, Event::Value(key.clone(), "recursive expression".into()), ) .unwrap(); state .update(&f.project, Event::Style(MessageStyle::Call)) .unwrap(); assert_eq!( state.draft.as_ref().unwrap().values[&key], "recursive expression" ); state .update(&f.project, Event::Style(MessageStyle::Asynchronous)) .unwrap(); assert_eq!( state.draft.as_ref().unwrap().values[&key], "recursive expression" ); state .update(&f.project, Event::Style(MessageStyle::Reply)) .unwrap(); assert!( state.draft.as_ref().unwrap().values.is_empty(), "In parameters do not become reply values" ); let out = crate::data_values::FieldPathKey { path: vec![f.destination], field: f.leaf, }; state .update(&f.project, Event::Value(out.clone(), "reply value".into())) .unwrap(); state .update(&f.project, Event::Style(MessageStyle::Asynchronous)) .unwrap(); assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value"); } fn panel_counts(p: &Project, d: &Draft, exchange: Id) -> (usize, usize) { let input: Element<'_, Message> = text_input("", "").into(); let input_tag = iced::advanced::widget::Tree::new(input.as_widget()).tag; let panel = value_panel(p, d, &p.relations[&exchange]); let tree = iced::advanced::widget::Tree::new(panel.as_widget()); fn count( tree: &iced::advanced::widget::Tree, tag: iced::advanced::widget::tree::Tag, ) -> (usize, usize) { tree.children.iter().fold( (1, usize::from(tree.tag == tag)), |(nodes, inputs), child| { let (child_nodes, child_inputs) = count(child, tag); (nodes + child_nodes, inputs + child_inputs) }, ) } count(&tree, input_tag) } #[test] fn large_payload_has_one_editor_with_reachable_field_and_authored_pages() { let f = crate::data_values::tests::wide_payload(); let p = &f.project; let exchange = &p.relations[&f.exchange]; let mut state = State::default(); state .update(p, Event::Start(f.scenario, Tool::Message)) .unwrap(); state .update(p, Event::Exchange(named_choice(p, f.exchange))) .unwrap(); let roots = crate::data_values::roots(p, exchange, &MessageKind::Call); assert_eq!(roots.len(), 96); assert_eq!( panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1, 0 ); let mut visited = BTreeSet::new(); for page in 0..roots.len().div_ceil(VALUE_PAGE_SIZE) { state .update(p, Event::ValuePage(ValueSection::Fields, page)) .unwrap(); let d = state.draft.as_ref().unwrap(); let choices = d.browsed_values(p, exchange).unwrap(); let visible = choices[value_page(choices.len(), d.value_browse_page)].to_vec(); assert!(visible.len() <= VALUE_PAGE_SIZE); for choice in visible { visited.insert(choice.key.clone()); state .update(p, Event::ValueFocus(choice.key.clone())) .unwrap(); state .update(p, Event::Value(choice.key, format!("draft {page}"))) .unwrap(); } let (nodes, inputs) = panel_counts(p, state.draft.as_ref().unwrap(), f.exchange); assert_eq!( inputs, 1, "one input for over twelve thousand carried paths" ); assert!(nodes < 200, "{nodes} widgets for the bounded value panel"); } assert_eq!( visited, roots.iter().map(|choice| choice.key.clone()).collect() ); state .update(p, Event::ValueBrowse(Some(roots[0].key.clone()))) .unwrap(); assert_eq!(state.draft.as_ref().unwrap().value_browse_page, 0); let children = state .draft .as_ref() .unwrap() .browsed_values(p, exchange) .unwrap(); assert_eq!(children.len(), 128); let mut visited_children = BTreeSet::new(); for page in 0..children.len().div_ceil(VALUE_PAGE_SIZE) { state .update(p, Event::ValuePage(ValueSection::Fields, page)) .unwrap(); let d = state.draft.as_ref().unwrap(); let choices = d.browsed_values(p, exchange).unwrap(); for choice in &choices[value_page(choices.len(), d.value_browse_page)] { assert_eq!(choice.key.path, [roots[0].key.field]); visited_children.insert(choice.key.clone()); } } assert_eq!( visited_children, children.iter().map(|choice| choice.key.clone()).collect() ); let nested = children[0].key.clone(); state.update(p, Event::ValueFocus(nested.clone())).unwrap(); state .update(p, Event::Value(nested.clone(), "unfinished(\"".into())) .unwrap(); let before = state.draft.as_ref().unwrap().values.clone(); let before_browse = state.draft.as_ref().unwrap().value_browse.clone(); let before_page = state.draft.as_ref().unwrap().value_browse_page; for event in [ Event::Style(MessageStyle::Call), Event::Exchange(named_choice(p, f.exchange)), Event::Style(MessageStyle::CallReply), ] { state.update(p, event).unwrap(); } let d = state.draft.as_ref().unwrap(); assert_eq!(d.values, before); assert_eq!(d.value_browse, before_browse); assert_eq!(d.value_browse_page, before_page); assert_eq!(d.value_focus, Some(nested)); let mut authored = BTreeSet::new(); for page in 0..before.len().div_ceil(VALUE_PAGE_SIZE) { state .update(p, Event::ValuePage(ValueSection::Authored, page)) .unwrap(); let d = state.draft.as_ref().unwrap(); let values = d.authored_values(); for (key, _) in &values[value_page(values.len(), d.value_authored_page)] { authored.insert((*key).clone()); } let (nodes, inputs) = panel_counts(p, d, f.exchange); assert_eq!(inputs, 1); assert!(nodes < 200, "{nodes} widget nodes"); } assert_eq!(authored, before.keys().cloned().collect()); state.update(p, Event::ValueBrowse(None)).unwrap(); state .update(p, Event::ValuePage(ValueSection::Fields, usize::MAX)) .unwrap(); let d = state.draft.as_ref().unwrap(); assert_eq!( d.values, before, "browsing and page clamping never remove drafts" ); let OccurrenceBody::Exchange { values, .. } = d.message_body() else { panic!() }; assert_eq!( values.len(), before.len(), "commit includes every authored page" ); assert_eq!(panel_counts(p, d, f.exchange).1, 1); } #[test] fn recursive_browsing_up_and_root_keep_values_and_report_the_depth_limit() { let (f, next) = crate::data_values::tests::recursive_payload(); let p = &f.project; let exchange = &p.relations[&f.exchange]; let mut state = State::default(); state .update(p, Event::Start(f.scenario, Tool::Message)) .unwrap(); state .update(p, Event::Exchange(named_choice(p, f.exchange))) .unwrap(); let root = crate::data_values::FieldPathKey { path: vec![], field: f.origin, }; state.update(p, Event::ValueFocus(root.clone())).unwrap(); state .update(p, Event::ValueBrowse(Some(root.clone()))) .unwrap(); let inner = state .draft .as_ref() .unwrap() .browsed_values(p, exchange) .unwrap() .into_iter() .find(|choice| choice.key.field == next) .unwrap() .key; state.update(p, Event::ValueFocus(inner.clone())).unwrap(); state .update(p, Event::ValueBrowse(Some(inner.clone()))) .unwrap(); let leaf = state .draft .as_ref() .unwrap() .browsed_values(p, exchange) .unwrap() .into_iter() .find(|choice| choice.key.field == f.leaf) .unwrap() .key; assert_eq!(leaf.path, [f.origin, next]); state.update(p, Event::ValueFocus(leaf.clone())).unwrap(); state .update(p, Event::Value(leaf.clone(), "keep this draft".into())) .unwrap(); state .update(p, Event::ValueBrowse(crate::data_values::parent(&inner))) .unwrap(); assert_eq!(state.draft.as_ref().unwrap().value_browse, Some(root)); state.update(p, Event::ValueBrowse(None)).unwrap(); assert_eq!( state.draft.as_ref().unwrap().values[&leaf], "keep this draft" ); assert_eq!( panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1, 1 ); let deepest = crate::data_values::FieldPathKey { path: std::iter::once(f.origin) .chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1)) .collect(), field: next, }; state.update(p, Event::ValueBrowse(Some(deepest))).unwrap(); assert!( state .draft .as_ref() .unwrap() .browsed_values(p, exchange) .unwrap_err() .contains("128") ); assert_eq!( panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1, 1 ); assert_eq!( state.draft.as_ref().unwrap().values[&leaf], "keep this draft" ); state .update(p, Event::Value(leaf.clone(), String::new())) .unwrap(); let d = state.draft.as_ref().unwrap(); assert!(d.authored_values().is_empty()); assert_eq!(d.values[&leaf], "", "empty input remains an editable draft"); } #[test] fn reply_call_picker_previews_the_same_value_reset_as_selecting_another_exchange() { let mut f = crate::data_values::tests::payload(); let p = &mut f.project; let source = p.relations[&f.exchange].source(); let target = p.relations[&f.exchange].target(); let mut lifelines = vec![]; for (name, represented) in [("Caller", source), ("Callee", target)] { let id = p .add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario)) .unwrap(); *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { represented: Some(represented), }; lifelines.push(id); } let other_item = p .add_element(Kind::ExchangeItem, Layer::Oa, "Other operation", None) .unwrap(); *p.elements .get_mut(&other_item) .unwrap() .shared_mut() .unwrap() = p.elements[&f.item].shared().unwrap().clone(); let other_field = p .add_element(Kind::DataField, Layer::Oa, "Result", Some(other_item)) .unwrap(); *p.elements .get_mut(&other_field) .unwrap() .shared_mut() .unwrap() = SharedBody::DataField { type_ref: TypeRef::Real, direction: ParameterDirection::Out, minimum: 1, maximum: Some(1), unit: String::new(), storage: FieldStorage::Value, default: None, }; let other_exchange = p.relate(RelationKind::Interaction, "Other exchange", source, target); p.relations.get_mut(&other_exchange).unwrap().body = RelationBody::Interaction { source, target, items: vec![other_item], }; let mut call_ids = vec![]; for (name, exchange) in [("First call", f.exchange), ("Other call", other_exchange)] { let call = occurrence( p, f.scenario, name, OccurrenceBody::Exchange { operation: None, exchange: Some(exchange), sender: Some(lifelines[0]), receiver: Some(lifelines[1]), message_kind: MessageKind::Call, reply_to: None, values: vec![], }, ); call_ids.push(call.id); *p = execute_batch(p, &[Command::CreateElement(call)]).unwrap(); } let mut state = State::default(); state .update(p, Event::Start(f.scenario, Tool::Message)) .unwrap(); state.update(p, Event::Style(MessageStyle::Reply)).unwrap(); state .update(p, Event::Call(named_choice(p, call_ids[0]))) .unwrap(); let key = crate::data_values::FieldPathKey { path: vec![f.destination], field: f.leaf, }; state.update(p, Event::ValueFocus(key.clone())).unwrap(); state .update(p, Event::Value(key.clone(), "reply expression".into())) .unwrap(); state .update(p, Event::ValueBrowse(crate::data_values::parent(&key))) .unwrap(); let offered = calls(p, state.draft.as_ref().unwrap()); assert!( offered.iter().any(|choice| choice.id == call_ids[1]), "a different exchange remains selectable after entering a value" ); assert_eq!( state.draft.as_ref().unwrap().values[&key], "reply expression", "preview is read-only" ); state .update(p, Event::Call(named_choice(p, call_ids[0]))) .unwrap(); assert_eq!( state.draft.as_ref().unwrap().values[&key], "reply expression" ); state .update(p, Event::Call(named_choice(p, call_ids[1]))) .unwrap(); let d = state.draft.as_ref().unwrap(); assert!(d.values.is_empty()); assert!(d.value_focus.is_none()); assert!(d.value_browse.is_none()); assert_eq!(d.exchange, Some(other_exchange)); assert!(state.prepare_pending(p).is_ok()); } }