//! Explicit, reviewable relationship and cross-perspective operations. use crate::{ app::{Choice, Message}, style, views::short, }; use iced::{ Element, Fill, widget::{ Space, button, checkbox, column, pick_list, row, scrollable, text, text_editor, text_input, }, }; use oa_core::{transitions::*, *}; use std::collections::BTreeSet; mod communication; pub(crate) mod recovery; #[cfg(test)] mod transition_tests; #[derive(Debug, Clone)] pub enum Event { Semantic(crate::semantic_review::Event), Port(crate::port_workflow::Event), ExchangeItem(Id, bool), Endpoint(bool, EndpointChoice), Oriented(bool), Cancel, Apply, RelationName(String), RelationKind(RelationKind), Condition(text_editor::Action), Source(Choice), Target(Choice), Scope(String), Destination(Choice), MappingTarget(Id, Choice), MappingKind(Id, Kind), CommunicationPort(Id, bool, communication::PortMenuChoice), OperationOmission(Id, bool), OperationRationale(Id, String), Resolve(Id, TransitionField, FieldDecision), Preview, } pub enum Workflow { Semantic(Box), Ports(Box), Relation(Box), ManualTrace { base_fingerprint: String, name: String, source: Choice, target: Option, }, Transition(Box), } pub struct Relationship { base_fingerprint: String, id: Id, pub name: String, pub kind: RelationKind, pub source: Option, pub target: Option, pub condition: text_editor::Content, layer: Layer, sources: Vec, targets: Vec, items: Vec, source_port: EndpointChoice, target_port: EndpointChoice, oriented: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct EndpointChoice { pub choice: PortChoice, pub label: String, } impl std::fmt::Display for EndpointChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.label) } } pub(crate) fn automatic_port() -> EndpointChoice { EndpointChoice { choice: PortChoice::Auto, label: "Automatic · reuse only if unique".into(), } } fn endpoint_choices( p: &Project, owner: Option, outgoing: bool, items: &[Id], ) -> Vec { let mut choices = vec![ automatic_port(), EndpointChoice { choice: PortChoice::Create, label: "Create a new port".into(), }, ]; choices.extend( p.elements .values() .filter(|e| e.parent == owner && e.layer() == Layer::Sa) .filter(|e| match port_contract(e) { Some(PortContract::FunctionInput { incoming_items }) if !outgoing => { items_satisfy(p, items, incoming_items) } Some(PortContract::FunctionOutput { outgoing_items }) if outgoing => { items_satisfy(p, items, outgoing_items) } _ => false, }) .map(|e| EndpointChoice { choice: PortChoice::Reuse(e.id), label: format!("Reuse {} · @{}", e.name, e.alias), }), ); choices } impl Relationship { fn accepts_source(&self, p: &Project, id: Id) -> bool { if p.layer_of(id) != Some(self.layer) { return false; } if matches!( self.kind, RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement ) { return validation::can_create_interface_role(p, id); } if self.kind == RelationKind::ExchangeAllocation { return p.relations.get(&id).is_some_and(|r| { matches!( r.kind(), RelationKind::BoundaryExchange | RelationKind::CommunicationMean ) }); } p.elements.get(&id).is_some_and(|e| { Kind::ALL .into_iter() .any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target)) }) } fn refresh_choices(&mut self, p: &Project) { self.sources = p .elements .keys() .chain(p.relations.keys()) .copied() .filter(|id| self.accepts_source(p, *id)) .map(|id| choice(p, id)) .collect(); self.sources.sort_by(|a, b| a.label.cmp(&b.label)); self.targets = p .elements .keys() .chain(p.relations.keys()) .copied() .filter(|id| self.accepts_target(p, *id)) .map(|id| choice(p, id)) .collect(); self.targets.sort_by(|a, b| a.label.cmp(&b.label)); } fn accepts_target(&self, p: &Project, target: Id) -> bool { let Some(source) = self.source.as_ref().map(|source| source.id) else { return false; }; if self.kind == RelationKind::BoundaryExchange { validation::can_connect_boundary(p, source, target, self.oriented) } else { validation::can_connect(p, self.kind, source, target) } } fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String { if !self.name.trim().is_empty() { return self.name.clone(); } let verb = match self.kind { RelationKind::CapabilityInclude => "includes", RelationKind::CapabilityExtend => "extends", RelationKind::CapabilityGeneralization => "specializes", RelationKind::MissionCapability => "exploits", RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => { "participates in" } RelationKind::Allocation => "performs", _ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)), }; format!("{} {verb} {}", p.named(source), p.named(target)) } fn default_orientation(&mut self, p: &Project) { if self.kind == RelationKind::CommunicationMean { self.oriented = false; return; } self.oriented = !self .source .as_ref() .and_then(|v| p.elements.get(&v.id)) .is_some_and(|e| { matches!( port_contract(e), Some(PortContract::Component { kind: ComponentPortKind::Standard, .. }) ) }); } fn commands(&self, p: &Project) -> Result, String> { let a = self.source.as_ref().ok_or("Choose a source.")?.id; let b = self.target.as_ref().ok_or("Choose a target.")?.id; if !self.accepts_source(p, a) { return Err("Choose an eligible source for this relationship.".into()); } let id = self.id; let name = self.engineering_name(p, a, b); if self.kind == RelationKind::FunctionalExchange { return plan_functional_exchange( p, &FunctionalExchangeRequest { id, alias: p.fresh_alias(&name, "exchange", id), name, description: String::new(), source: a, target: b, source_port: self.source_port.choice, target_port: self.target_port.choice, items: self.items.clone(), }, ) .map_err(|e| e.to_string()); } let mut body = RelationBody::new(self.kind, a, b); if let RelationBody::CapabilityExtend { condition, .. } = &mut body { *condition = self.condition.text(); } if let RelationBody::BoundaryExchange { oriented, items, .. } | RelationBody::CommunicationMean { oriented, items, .. } = &mut body { *oriented = self.oriented; *items = self.items.clone(); } let commands = vec![Command::CreateRelation(Relation { id, alias: p.fresh_alias(&name, "relation", id), name, description: String::new(), body, })]; execute_batch(p, &commands) .map(|_| commands) .map_err(|e| e.to_string()) } } pub struct Transition { pub request: TransitionRequest, pub plan: TransitionPlan, communication_ports: std::collections::BTreeMap, } impl Transition { fn presentation(&self, p: &Project) -> (Project, Vec) { let mut candidate = p.clone(); for command in &self.plan.commands { match command { Command::CreateElement(e) | Command::EditElement(_, e) => { candidate.elements.insert(e.id, e.clone()); } _ => {} } } let targets = self .plan .changes .iter() .map(|c| c.target) .filter(|id| candidate.elements.contains_key(id)) .collect::>() .into_iter() .collect(); (candidate, targets) } fn automatic_behavior(&self, p: &Project) -> bool { self.request .sources .first() .is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some()) } fn new_counterpart(&self, p: &Project) -> bool { self.request.sources.first().is_some_and(|source| { self.plan .changes .iter() .find(|c| c.source == *source) .map_or_else( || { !p.traces .values() .any(|t| t.oa == *source || t.sa == *source) && !self .request .mappings .iter() .any(|m| m.source == *source && m.target.is_some()) }, |c| c.action == TransitionAction::Create, ) }) } fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool { conflict.field.is_none() && conflict.target.is_none() && conflict.message == "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred." && conflict .source .and_then(|id| p.elements.get(&id)) .is_some_and(|e| { matches!( e.kind(), Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor ) }) } fn destination_error(&self, p: &Project) -> Option { let id = self.request.destination_diagram?; let (candidate, targets) = self.presentation(p); match candidate.diagrams.get(&id) { None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()), Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) => Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()), _ => None, } } } fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool { (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability) && (diagram.projection.state_machine().is_none() || !targets.is_empty()) && targets.iter().all(|id| { p.elements.get(id).is_some_and(|e| { diagram.projection.includes(e.kind()) && diagram.projection.state_machine().is_none_or(|machine| { oa_core::projection::state_machine_contains(p, machine, *id) }) }) }) } fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option { p.diagrams .values() .filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer)) .filter_map(|d| { Some(( match d.projection { ProjectionKind::Allocation => 0, ProjectionKind::Overview => 1, ProjectionKind::Decomposition => 2, _ => return None, }, d.id, )) }) .min() .map(|(_, id)| id) } fn place_transition_results( before: &Project, candidate: &mut Project, diagram: Id, targets: &[Id], ) { // Keep every accepted placement and route. Only newly introduced placements // receive space below the accepted projected bounds, including tall/nested nodes. let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes); let mut occupied = candidate.diagrams[&diagram].clone(); occupied .nodes .retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id))); let projected = oa_core::projection::project_diagram(candidate, &occupied); let mut bottom = projected .nodes .iter() .map(|n| n.position.y + n.size.height) .chain( occupied .nodes .values() .map(|n| n.position.y + n.size.height), ) .fold(20.0_f32, f32::max) + 40.; for target in targets { if candidate.diagrams[&diagram].nodes.values().any(|n| { n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id)) }) { continue; } let node = candidate.place(diagram, *target, Position { x: 60., y: bottom }); let placement = candidate .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap(); placement.position = Position { x: 60., y: bottom }; bottom += placement.size.height + 40.; } } impl Workflow { pub fn manual_trace(p: &Project, selected: Id) -> Result { if p.layer_of(selected).is_none() || !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected)) { return Err("Select an OA or SA element or relationship to trace.".into()); } Ok(Self::ManualTrace { base_fingerprint: project_fingerprint(p), name: "Engineering correspondence".into(), source: choice(p, selected), target: None, }) } pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self { if let Some(PortContract::Component { orientation, .. }) = p.elements.get(&selected).and_then(port_contract) { return Self::relationship_kind( p, selected, layer, RelationKind::BoundaryExchange, *orientation == ComponentPortOrientation::In, ); } let kind = match p.elements.get(&selected).map(oa_core::Element::kind) { Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability, Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude, Some(Kind::Role) => RelationKind::RoleAssignment, Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement, Some(kind) if kind.is_performer() => RelationKind::Allocation, Some(kind) if kind.is_state() => RelationKind::StateTransition, _ if layer == Layer::Oa => RelationKind::Interaction, _ => RelationKind::FunctionalExchange, }; Self::relationship_kind(p, selected, layer, kind, false) } pub fn relationship_kind( p: &Project, selected: Id, layer: Layer, kind: RelationKind, incoming: bool, ) -> Self { let mut draft = Relationship { base_fingerprint: project_fingerprint(p), id: Id::new_v4(), name: String::new(), kind, source: (!incoming).then(|| choice(p, selected)), target: incoming.then(|| choice(p, selected)), condition: text_editor::Content::new(), layer, sources: vec![], targets: vec![], items: vec![], source_port: automatic_port(), target_port: automatic_port(), oriented: true, }; if draft .source .as_ref() .is_some_and(|s| !draft.accepts_source(p, s.id)) { draft.source = None; } draft.default_orientation(p); draft.refresh_choices(p); Self::Relation(Box::new(draft)) } pub fn transition(p: &Project, selected: Id) -> Result { let layer = p .layer_of(selected) .ok_or("Select an element or relationship in OA or SA.")?; let direction = if layer == Layer::Oa { TransitionDirection::OaToSa } else { TransitionDirection::SaToOa }; let mut request = TransitionRequest::new(direction, vec![selected]); request.scope = DependencyScope::Required; let plan = plan_transition(p, &request).map_err(|e| e.to_string())?; Ok(Self::Transition(Box::new(Transition { request, plan, communication_ports: Default::default(), }))) } /// Return a complete accepted candidate only on explicit Apply. Intermediate /// choices and previews never mutate the Session. pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { match self { Self::Semantic(_) => { return Err( "Semantic review commits its exact command batch through Document.".into(), ); } Self::Ports(state) => return state.update(p, event), Self::ManualTrace { name, source, target, .. } => match event { Event::RelationName(value) => *name = value, Event::Target(value) => *target = Some(value), Event::Apply => { let target = target .as_ref() .ok_or("Choose a counterpart in the other perspective.")? .id; if name.trim().is_empty() { return Err("Give the trace an engineering name.".into()); } let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) { (source.id, target) } else { (target, source.id) }; let id = Id::new_v4(); let trace = Trace { id, alias: p.fresh_alias("correspondence", "trace", id), name: name.clone(), description: String::new(), oa, sa, kind: TraceKind::Manual, baseline: None, }; return execute_batch(p, &[Command::CreateTrace(trace)]) .map(Some) .map_err(|e| e.to_string()); } _ => {} }, Self::Relation(d) => match event { Event::Oriented(value) => { d.oriented = value; d.refresh_choices(p); } Event::ExchangeItem(id, enabled) => { if enabled && !d.items.contains(&id) { d.items.push(id); } if !enabled { d.items.retain(|item| *item != id); } } Event::Endpoint(source, choice) => { if source { d.source_port = choice; } else { d.target_port = choice; } } Event::RelationName(name) => d.name = name, Event::RelationKind(kind) => { d.kind = kind; d.target = None; if d.source .as_ref() .is_some_and(|s| !d.accepts_source(p, s.id)) { d.source = None; } d.default_orientation(p); d.refresh_choices(p); } Event::Source(source) => { d.source = Some(source); d.default_orientation(p); if d.target .as_ref() .is_some_and(|target| !d.accepts_target(p, target.id)) { d.target = None; } d.refresh_choices(p); } Event::Target(target) => d.target = Some(target), Event::Condition(action) => d.condition.perform(action), Event::Apply => { return execute_batch(p, &d.commands(p)?) .map(Some) .map_err(|e| e.to_string()); } _ => {} }, Self::Transition(d) => { match &event { Event::OperationOmission(source, enabled) => { d.request .operation_omissions .retain(|v| v.source != *source); if *enabled { d.request.operation_omissions.push(OperationOmission { source: *source, rationale: String::new(), }); } } Event::OperationRationale(source, raw) => { if let Some(decision) = d .request .operation_omissions .iter_mut() .find(|v| v.source == *source) { decision.rationale = raw.clone(); } } _ => {} } match event { Event::Destination(choice) => { d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id); } Event::CommunicationPort(exchange, source, value) => { d.choose_communication_port(p, exchange, source, value)?; } Event::Scope(scope) => { d.request.scope = match scope.as_str() { "Required references" => DependencyScope::Required, "Whole subtree" => DependencyScope::Subtree, _ => DependencyScope::SelectedOnly, } } Event::MappingTarget(source, target) => { let target = (!target.id.is_nil()).then_some(target.id); let previous = d .request .mappings .iter() .find(|m| m.source == source) .map(|m| m.target) .unwrap_or_else(|| { d.plan .changes .iter() .find(|c| c.source == source) .map(|c| c.target) .or_else(|| { d.plan .conflicts .iter() .find(|c| c.source == Some(source)) .and_then(|c| c.target) }) // Planned new identities are not an existing counterpart. .filter(|id| p.contains(*id)) }); if previous != target { d.request.resolutions.retain(|r| r.source != source); } if let Some(mapping) = d.request.mappings.iter_mut().find(|m| m.source == source) { mapping.target = target; } else { d.request.mappings.push(TransitionMapping { source, target, target_kind: None, }); } } Event::MappingKind(source, kind) => { let mapping = d.request.mappings.iter().find(|m| m.source == source); let planned_target = d .plan .changes .iter() .find(|c| c.source == source) .map(|c| c.target); let previous = mapping .and_then(|m| m.target_kind) .or_else(|| { planned_target.and_then(|target| { p.elements.get(&target).map(|e| e.kind()).or_else(|| { d.plan.commands.iter().find_map(|command| match command { Command::CreateElement(e) if e.id == target => { Some(e.kind()) } _ => None, }) }) }) }) .or_else(|| { p.elements.get(&source).and_then(|e| { let kinds = counterpart_kinds(e.kind(), d.request.direction); (kinds.len() == 1).then(|| kinds[0]) }) }); // Adding a kind mapping explicitly requests creation. If // it replaces an inferred existing target, that is also // a new review context even when its kind is identical. let replaces_existing = mapping.is_none() && planned_target.is_some_and(|target| p.contains(target)); if previous != Some(kind) || replaces_existing { d.request.resolutions.retain(|r| r.source != source); } if let Some(mapping) = d.request.mappings.iter_mut().find(|m| m.source == source) { mapping.target_kind = Some(kind); } else { d.request.mappings.push(TransitionMapping { source, target: None, target_kind: Some(kind), }); } } Event::Resolve(source, field, decision) => { d.request .resolutions .retain(|r| r.source != source || r.field != field); d.request.resolutions.push(FieldResolution { source, field, decision, }); } Event::Apply => { let mut candidate = apply_transition(p, &d.plan).map_err(|e| e.to_string())?; // Behavior is already usable in its dedicated workspace. An // unrequested architecture placement would alter an unrelated // view, including its routes, just to display this operation. if d.request.destination_diagram.is_none() && d.request.sources.first().is_some_and(|source| { crate::behavior::selected_behavior(p, *source).is_some() }) { return Ok(Some(candidate)); } let layer = d.request.direction.target_layer(); let targets: Vec<_> = d .plan .changes .iter() .map(|c| c.target) .filter(|id| candidate.elements.contains_key(id)) .collect(); let diagram = if let Some(id) = d.request.destination_diagram { let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?; if !result_view_compatible(&candidate, view, &targets, layer) { return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into()); } id } else { automatic_result_view(&candidate, &targets, layer) .unwrap_or_else(|| candidate.ensure_diagram(layer)) }; // Explicit behavior destinations retain the core plan's // placements; free-space placement is an architecture aid. if !d.automatic_behavior(p) { place_transition_results(p, &mut candidate, diagram, &targets); } return execute_batch(&candidate, &[]) .map(Some) .map_err(|e| e.to_string()); } _ => {} } d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?; } } Ok(None) } pub fn title(&self) -> &'static str { match self { Self::Semantic(_) => "Review semantics", Self::Ports(state) => state.title(), Self::ManualTrace { .. } => "Link existing counterparts", Self::Relation(_) => "Create relationship", Self::Transition(t) => { if t.request.direction == TransitionDirection::OaToSa { "Operational → System" } else { "System → Operational" } } } } pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> { if let Self::Semantic(state) = self { return state.view(p); } if let Self::Ports(state) = self { return state.view(p); } let mut content = column![text(self.title()).size(23)].spacing(14); let relation_plan = if let Self::Relation(d) = self { Some(d.commands(p)) } else { None }; match self { Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"), Self::ManualTrace { name, source, target, .. } => { let mut choices = p .elements .keys() .chain(p.relations.keys()) .filter(|id| { p.layer_of(**id) .is_some_and(|layer| Some(layer) != p.layer_of(source.id)) }) .map(|id| choice(p, *id)) .collect::>(); choices.sort_by(|a, b| a.label.cmp(&b.label)); content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED)) .push(text(format!("From: {}",source.label)).size(14)) .push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v)))) .push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13)); } Self::Relation(d) => { let kinds = RelationKind::ALL .into_iter() .filter(|kind| { if *kind == RelationKind::ContextualLink { return false; } *kind == RelationKind::ExchangeAllocation || Kind::ALL.into_iter().any(|source| { source.supports(d.layer) && Kind::ALL.into_iter().any(|target| { target.supports(d.layer) && kind.permits(source, target) }) }) }) .collect::>(); let (source_label, target_label) = match d.kind { RelationKind::CapabilityInclude => { ("Including capability", "Included capability") } RelationKind::CapabilityExtend => { ("Extending capability", "Extended capability") } RelationKind::CapabilityGeneralization => { ("Specific capability", "General capability") } RelationKind::MissionCapability => ("Mission", "Exploited capability"), RelationKind::MissionInvolvement => ("Participant", "Mission"), RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"), _ => ("Source", "Target"), }; content = content .push( text(if d.kind == RelationKind::BoundaryExchange { "Component port ↔ component port" } else { d.kind.hint() }) .size(13) .color(style::MUTED), ) .push( pick_list(kinds, Some(d.kind), |v| { Message::Workflow(Event::RelationKind(v)) }) .text_size(13) .padding(9) .width(Fill), ) .push(text(source_label).size(12)) .push( pick_list(d.sources.as_slice(), d.source.as_ref(), |v| { Message::Workflow(Event::Source(v)) }) .placeholder("Choose source") .text_size(13) .padding(9) .width(Fill), ) .push(text(target_label).size(12)) .push( pick_list(d.targets.as_slice(), d.target.as_ref(), |v| { Message::Workflow(Event::Target(v)) }) .placeholder("Choose a compatible target") .text_size(13) .padding(9) .width(Fill), ); if d.source.is_some() && d.targets.is_empty() { content = content.push( text(if d.kind == RelationKind::BoundaryExchange { "No compatible component port on another participant. Select the other system or actor, add a port of the same kind (Standard or Flow), then connect it here. Flow ports also need compatible input/output orientations." } else { "No compatible target yet. Create the needed model element, then connect it here." }) .size(12) .color(style::MUTED), ); } if d.kind == RelationKind::CapabilityExtend { content = content.push(text("Extension condition").size(12)).push( text_editor(&d.condition) .placeholder("When does the extending capability add behavior?") .on_action(|action| Message::Workflow(Event::Condition(action))) .height(72) .padding(9), ); } if matches!( d.kind, RelationKind::BoundaryExchange | RelationKind::CommunicationMean ) { content = content.push( checkbox(d.oriented) .label("Directed source → target (unchecked: bidirectional)") .on_toggle(|v| Message::Workflow(Event::Oriented(v))), ); } if d.kind == RelationKind::FunctionalExchange { content = content.push(text("Function ports and carried items").size(14)) .push(text("Automatic uses one compatible typed port, or creates a new one. Creating this exchange and its ports is one undoable change.").size(12)); for source in [true, false] { let owner = if source { &d.source } else { &d.target }; let selected = if source { &d.source_port } else { &d.target_port }; let mut options = endpoint_choices(p, owner.as_ref().map(|v| v.id), source, &d.items); if !options.contains(selected) { options.push(selected.clone()); } content = content .push(text(if source { "Output port" } else { "Input port" }).size(12)) .push( pick_list(options, Some(selected.clone()), move |v| { Message::Workflow(Event::Endpoint(source, v)) }) .padding(8) .text_size(12) .width(Fill), ); } } if matches!( d.kind, RelationKind::FunctionalExchange | RelationKind::BoundaryExchange | RelationKind::CommunicationMean ) { content = content.push(text("Declared carried items").size(14)); for item in p .elements .values() .filter(|e| e.layer() == d.layer && e.kind() == Kind::ExchangeItem) { let id = item.id; content = content.push( checkbox(d.items.contains(&id)) .label(format!("{} · @{}", item.name, item.alias)) .on_toggle(move |enabled| { Message::Workflow(Event::ExchangeItem(id, enabled)) }), ); } } content = content.push( text_input("Name (generated from endpoints when blank)", &d.name) .on_input(|v| Message::Workflow(Event::RelationName(v))) .padding(10) .size(13), ); if let (Some(source), Some(target)) = (&d.source, &d.target) { content = content.push( text(d.engineering_name(p, source.id, target.id)) .size(13) .color(style::TEAL), ); } } Self::Transition(d) => { content=content.push(text(if d.new_counterpart(p) { "Create a linked counterpart in the other layer. The source element stays in its current layer." } else { "Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…" }).size(12).color(style::MUTED)); let scope = match d.request.scope { DependencyScope::SelectedOnly => "Selected only", DependencyScope::Required => "Required references", DependencyScope::Subtree => "Whole subtree", } .to_string(); content = content.push( row![ text("Include").size(12), pick_list( vec![ "Selected only".to_string(), "Required references".to_string(), "Whole subtree".to_string() ], Some(scope), |v| Message::Workflow(Event::Scope(v)) ) .text_size(12) .padding(8), Space::new().width(Fill), button(text("Refresh preview").size(12)) .style(style::nav) .on_press(Message::Workflow(Event::Preview)) ] .spacing(10) .align_y(iced::Center), ); let (presentation, targets) = d.presentation(p); let target_layer = d.request.direction.target_layer(); let automatic = if d.automatic_behavior(p) { "Automatic · open the counterpart in Behavior".into() } else if let Some(id) = automatic_result_view(&presentation, &targets, target_layer) { format!("Automatic · {}", p.named(id)) } else { format!("Automatic · create {target_layer} overview") }; let mut diagrams = vec![Choice { id: Id::nil(), label: automatic, }]; diagrams.extend( presentation .diagrams .values() .filter(|view| { result_view_compatible(&presentation, view, &targets, target_layer) }) .map(|view| Choice { id: view.id, label: format!("{} · {}", view.name, view.projection), }), ); let selected = d.request.destination_diagram.unwrap_or(Id::nil()); if !diagrams.iter().any(|choice| choice.id == selected) { diagrams.push(Choice { id: selected, label: format!("Unavailable result diagram · {}", p.named(selected)), }); } let selected = diagrams .iter() .find(|choice| choice.id == selected) .cloned(); content = content.push( column![ text("Show result in").size(12), pick_list(diagrams, selected, |choice| Message::Workflow( Event::Destination(choice) )) .width(Fill) .text_size(12) .padding(8) ] .spacing(5), ); if let Some(error) = d.destination_error(p) { content = content.push( text(error) .size(12) .color(iced::Color::from_rgb8(178, 54, 58)), ); } let sources = d .request .sources .iter() .copied() .chain(d.plan.changes.iter().map(|c| c.source)) .chain(d.plan.conflicts.iter().filter_map(|c| c.source)) .collect::>(); for source in sources { if d.request.direction == TransitionDirection::SaToOa && matches!( p.elements.get(&source).and_then(|e| e.shared()), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(_), message_kind: MessageKind::Call | MessageKind::Asynchronous, .. }, .. }) ) { let omission = d .request .operation_omissions .iter() .find(|v| v.source == source); content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13)) .push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled)))); if let Some(omission) = omission { content = content.push( text_input("Required engineering rationale", &omission.rationale) .on_input(move |raw| { Message::Workflow(Event::OperationRationale(source, raw)) }) .padding(8) .size(12), ); } content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11)); } let mapping = d.request.mappings.iter().find(|m| m.source == source); let existing = p .traces .values() .filter_map(|t| { if d.request.direction == TransitionDirection::OaToSa && t.oa == source { Some(t.sa) } else if d.request.direction == TransitionDirection::SaToOa && t.sa == source { Some(t.oa) } else { None } }) .collect::>(); let target = mapping.map(|m| m.target).unwrap_or_else(|| { if existing.len() == 1 { Some(existing[0]) } else { None } }); let mut targets = vec![Choice { id: Id::nil(), label: "Create new counterpart".into(), }]; targets.extend( p.elements .keys() .chain(p.relations.keys()) .filter(|id| { p.layer_of(**id) == Some(d.request.direction.target_layer()) && p.elements.contains_key(&source) == p.elements.contains_key(id) }) .map(|id| choice(p, *id)), ); let selected = targets .iter() .find(|c| Some(c.id) == target.or(Some(Id::nil()))) .cloned(); let mut item = column![ text(format!( "{} · {}", short(p.named(source), 70), p.elements .get(&source) .map(|e| e.kind().label()) .unwrap_or("Relationship") )) .size(14), pick_list(targets, selected, move |v| Message::Workflow( Event::MappingTarget(source, v) )) .text_size(12) .padding(8) .width(Fill) ] .spacing(7); if target.is_none() && let Some(e) = p.elements.get(&source) { let kinds = counterpart_kinds(e.kind(), d.request.direction); if !kinds.is_empty() { let selected = mapping.and_then(|m| m.target_kind).or_else(|| { if kinds.len() == 1 { Some(kinds[0]) } else { None } }); item = item.push( pick_list(kinds, selected, move |kind| { Message::Workflow(Event::MappingKind(source, kind)) }) .placeholder("Choose counterpart kind / system boundary") .text_size(12) .padding(8) .width(Fill), ); if matches!(e.kind(), Kind::Entity | Kind::Actor) && d.request.direction == TransitionDirection::OaToSa { item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11)); } } } if let Some(ports) = d.communication_view(p, source) { item = item.push(ports); } content = content.push( iced::widget::container(item) .padding(12) .style(style::subtle), ); } let mut changes = column![].spacing(8); for change in &d.plan.changes { changes = changes.push( text(format!("{:?} · {}", change.action, p.named(change.source))) .size(12) .color(style::TEAL), ); for field in &change.fields { changes = changes.push( text(format!( "{:?}: {} → {}", field.field, short(&field.before, 90), short(&field.after, 90) )) .size(12), ); } } content = content.push(changes); let mut fields = d .plan .conflicts .iter() .filter_map(|c| Some((c.source?, c.field?))) .chain(d.request.resolutions.iter().map(|r| (r.source, r.field))) .fold(Vec::new(), |mut out, field| { if !out.contains(&field) { out.push(field); } out }); // Keep property widgets in the same order while a resolution // changes the preview, so continuous typing retains focus. fields.sort_by_key(|(source, field)| (*source, *field as u8)); let mut general_conflicts = column![].spacing(8); for conflict in &d.plan.conflicts { if conflict.field.is_none() && !d.boundary_choice(p, conflict) { general_conflicts = general_conflicts.push( text(&conflict.message) .size(12) .color(iced::Color::from_rgb8(178, 54, 58)), ); } } content = content.push(general_conflicts); for (source, field) in fields { let target = d .plan .conflicts .iter() .find(|c| c.source == Some(source) && c.field == Some(field)) .and_then(|c| c.target) .or_else(|| { d.request .mappings .iter() .find(|m| m.source == source) .and_then(|m| m.target) }) .or_else(|| { d.plan .changes .iter() .find(|c| c.source == source) .map(|c| c.target) }) .or_else(|| { p.traces.values().find_map(|t| { if d.request.direction == TransitionDirection::OaToSa && t.oa == source { Some(t.sa) } else if d.request.direction == TransitionDirection::SaToOa && t.sa == source { Some(t.oa) } else { None } }) }); let value = |id| { p.properties(id) .map(|v| match field { TransitionField::Name => v.name, TransitionField::Description => v.description, }) .unwrap_or_default() }; let resolution = d .request .resolutions .iter() .find(|r| r.source == source && r.field == field); let merged = resolution .and_then(|r| { if let FieldDecision::Value(v) = &r.decision { Some(v.as_str()) } else { None } }) .unwrap_or(""); let status = resolution .map(|r| match &r.decision { FieldDecision::KeepTarget => "Keep target", FieldDecision::TakeSource => "Take source", FieldDecision::Value(_) => "Use merged text", }) .unwrap_or("Choose how to resolve this conflict"); let card = column![ text(format!( "{:?} · {} → {}", field, p.named(source), target.map(|id| p.named(id)).unwrap_or("New counterpart") )) .size(14), text(format!( "Current target: {}", target.map(value).unwrap_or_default() )) .size(12), text(format!("Proposed source: {}", value(source))).size(12), text(status).size(12).color(style::TEAL), row![ button(text("Keep target").size(12)) .padding(8) .style(style::nav) .on_press(Message::Workflow(Event::Resolve( source, field, FieldDecision::KeepTarget ))), button(text("Take source").size(12)) .padding(8) .style(style::nav) .on_press(Message::Workflow(Event::Resolve( source, field, FieldDecision::TakeSource ))) ] .spacing(8), text_input("Or enter merged text", merged) .padding(9) .size(12) .on_input(move |v| Message::Workflow(Event::Resolve( source, field, FieldDecision::Value(v) ))) ] .spacing(8); content = content.push( iced::widget::container(card) .padding(12) .style(style::subtle), ); } for warning in &d.plan.warnings { content = content.push( text(format!("Modeling warning · {warning}")) .size(12) .color(style::MUTED), ); } content = content.push( text(format!( "{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings", d.plan.changes.len(), d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(), d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(), d.plan.warnings.len() )) .size(12), ); } } let enabled = match self { Self::Semantic(_) => false, Self::Ports(_) => false, Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(), Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok), Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(), }; if let Some(Err(error)) = relation_plan { content = content.push( text(error) .size(12) .color(iced::Color::from_rgb8(178, 54, 58)), ); } column![ scrollable(content.padding(iced::Padding { right: 14., ..Default::default() })) .height(480), row![ Space::new().width(Fill), button(text("Cancel").size(12)) .padding(10) .style(style::nav) .on_press(Message::Workflow(Event::Cancel)), button( text( if matches!(self, Self::Transition(d) if d.new_counterpart(p)) { "Create counterpart" } else { "Apply reviewed change" } ) .size(12) ) .padding(10) .on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply))) ] .spacing(10) ] .spacing(18) .into() } } fn choice(p: &Project, id: Id) -> Choice { if let Some(element) = p.elements.get(&id) && let Some(contract) = port_contract(element) { let contract = match contract { PortContract::FunctionInput { .. } => "Input".into(), PortContract::FunctionOutput { .. } => "Output".into(), PortContract::Component { kind: ComponentPortKind::Standard, .. } => "Standard".into(), PortContract::Component { orientation, .. } => format!("Flow {orientation:?}"), PortContract::Legacy => "Legacy".into(), }; return Choice { id, label: format!( "{} / {} · {} · @{}", short(element.parent.map_or("Unowned", |owner| p.named(owner)), 18), short(&element.name, 20), contract, element.alias ), }; } Choice { id, label: format!( "{} · {}", p.named(id), p.layer_of(id).map(|l| l.to_string()).unwrap_or_default() ), } } #[cfg(test)] mod port_authoring_tests { use super::*; #[test] fn boundary_picker_matches_exact_commands_across_contracts_and_ownership() { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let actor = p .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) .unwrap(); let function = p .add_element(Kind::Function, Layer::Sa, "Internal work", None) .unwrap(); let mut endpoints = vec![]; let mut standard = vec![]; for owner in [system, actor] { for orientation in [ ComponentPortOrientation::Unset, ComponentPortOrientation::In, ComponentPortOrientation::Out, ComponentPortOrientation::InOut, ] { let id = Id::new_v4(); let port = make_port( &p, id, owner, "Flow", PortContract::Component { kind: ComponentPortKind::Flow, orientation, }, ) .unwrap(); p.elements.insert(id, port); endpoints.push(id); } let id = Id::new_v4(); let port = make_port( &p, id, owner, "Standard", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, ) .unwrap(); p.elements.insert(id, port); endpoints.push(id); standard.push(id); for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] { let id = Id::new_v4(); let mut port = make_port(&p, id, owner, "Legacy", PortContract::Legacy).unwrap(); if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() { *legacy = Some(LegacyPortContract { direction, interface: None, }); } p.elements.insert(id, port); endpoints.push(id); } } for contract in [ PortContract::FunctionInput { incoming_items: vec![], }, PortContract::FunctionOutput { outgoing_items: vec![], }, ] { let id = Id::new_v4(); let port = make_port(&p, id, function, "Unrelated function port", contract).unwrap(); p.elements.insert(id, port); endpoints.push(id); } let interface = p .add_element(Kind::Interface, Layer::Sa, "Telemetry", None) .unwrap(); p.relate( RelationKind::InterfaceRequirement, "Requires telemetry", standard[0], interface, ); p.relate( RelationKind::InterfaceProvision, "Provides telemetry", standard[1], interface, ); let missing_contract = Id::new_v4(); let port = make_port( &p, missing_contract, actor, "Missing provision", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, ) .unwrap(); p.elements.insert(missing_contract, port); endpoints.extend([missing_contract, system, function, Id::new_v4()]); assert!(execute_batch(&p, &[]).is_ok()); for source in &endpoints { let Workflow::Relation(mut draft) = Workflow::relationship_kind( &p, *source, Layer::Sa, RelationKind::BoundaryExchange, false, ) else { unreachable!() }; // Include invalid sources as well as those shown by the UI. draft.source = Some(choice(&p, *source)); for oriented in [false, true] { draft.oriented = oriented; for target in &endpoints { let exact = Relation { id: Id::new_v4(), alias: "boundary_probe".into(), name: "Boundary probe".into(), description: String::new(), body: RelationBody::BoundaryExchange { source: *source, target: *target, oriented, items: vec![], }, }; let accepted = execute_batch(&p, &[Command::CreateRelation(exact)]).is_ok(); assert_eq!( draft.accepts_target(&p, *target), accepted, "source {source}, target {target}, oriented {oriented}" ); } } } } #[test] fn generic_component_port_connection_keeps_its_endpoint_and_direction() { for (kind, orientation) in [ (ComponentPortKind::Flow, ComponentPortOrientation::In), (ComponentPortKind::Flow, ComponentPortOrientation::Out), (ComponentPortKind::Flow, ComponentPortOrientation::InOut), (ComponentPortKind::Flow, ComponentPortOrientation::Unset), (ComponentPortKind::Standard, ComponentPortOrientation::Unset), ] { let mut p = Project::empty(); let actor = p .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) .unwrap(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let incoming = orientation == ComponentPortOrientation::In; let other_orientation = if kind == ComponentPortKind::Standard { ComponentPortOrientation::Unset } else if incoming { ComponentPortOrientation::Out } else { ComponentPortOrientation::In }; let mut ports = vec![]; for (owner, orientation) in [(actor, orientation), (system, other_orientation)] { let id = Id::new_v4(); let port = make_port( &p, id, owner, "Data", PortContract::Component { kind, orientation }, ) .unwrap(); p.elements.insert(id, port); ports.push(id); } let before = p.clone(); let mut workflow = Workflow::relationship(&p, ports[0], Layer::Sa); let Workflow::Relation(draft) = &workflow else { unreachable!() }; assert_eq!(draft.kind, RelationKind::BoundaryExchange); if incoming { assert!(draft.source.is_none()); assert_eq!(draft.target.as_ref().unwrap().id, ports[0]); } else { assert_eq!(draft.source.as_ref().unwrap().id, ports[0]); assert!(draft.target.is_none()); } assert_eq!(draft.oriented, kind == ComponentPortKind::Flow); workflow .update( &p, if incoming { Event::Source(choice(&p, ports[1])) } else { Event::Target(choice(&p, ports[1])) }, ) .unwrap(); let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap(); assert_eq!(p, before, "The modal never mutates its input model"); let (expected_source, expected_target) = if incoming { (ports[1], ports[0]) } else { (ports[0], ports[1]) }; assert!(accepted.relations.values().any(|relation| matches!(relation.body, RelationBody::BoundaryExchange { source, target, oriented, .. } if source == expected_source && target == expected_target && oriented == (kind == ComponentPortKind::Flow)))); } } #[test] fn duplicate_port_names_show_owner_contract_and_unique_alias() { let mut p = Project::empty(); let actor = p .add_element(Kind::SystemActor, Layer::Sa, "Operator", None) .unwrap(); let system = p .add_element(Kind::System, Layer::Sa, "Rescue", None) .unwrap(); let mut labels = BTreeSet::new(); for owner in [actor, actor, system] { let id = Id::new_v4(); let port = make_port( &p, id, owner, "Data", PortContract::Component { kind: ComponentPortKind::Flow, orientation: ComponentPortOrientation::Out, }, ) .unwrap(); p.elements.insert(id, port); let label = choice(&p, id).label; assert!(label.contains(&format!("{} / Data", p.named(owner)))); assert!(label.contains("Flow Out")); assert!(label.contains(&format!("@{}", p.elements[&id].alias))); assert!( labels.insert(label), "Duplicate names must still identify distinct ports" ); } } #[test] fn boundary_picker_recomputes_for_unoriented_reverse_endpoints_without_relaxing_contracts() { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let actor = p .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) .unwrap(); let mut create = |owner, name, contract| { let id = Id::new_v4(); let port = make_port(&p, id, owner, name, contract).unwrap(); p.elements.insert(id, port); id }; let flow = |orientation| PortContract::Component { kind: ComponentPortKind::Flow, orientation, }; let input = create(system, "Receive", flow(ComponentPortOrientation::In)); let output = create(actor, "Send", flow(ComponentPortOrientation::Out)); let incompatible = create(actor, "Other input", flow(ComponentPortOrientation::In)); let same_owner = create(system, "Local output", flow(ComponentPortOrientation::Out)); let standard = create( actor, "Standard", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, ); let legacy = create(actor, "Preserved output", PortContract::Legacy); if let Some(SharedBody::Port { legacy, .. }) = p.elements.get_mut(&legacy).unwrap().shared_mut() { *legacy = Some(LegacyPortContract { direction: PortDirection::Out, interface: None, }); } let before = p.clone(); let mut workflow = Workflow::relationship_kind( &p, input, Layer::Sa, RelationKind::BoundaryExchange, false, ); let Workflow::Relation(draft) = &workflow else { unreachable!() }; assert!(!draft.targets.iter().any(|choice| choice.id == output)); workflow.update(&p, Event::Oriented(false)).unwrap(); let Workflow::Relation(draft) = &workflow else { unreachable!() }; assert!(draft.targets.iter().any(|choice| choice.id == output)); for invalid in [input, incompatible, same_owner, standard, legacy] { assert!( !draft.targets.iter().any(|choice| choice.id == invalid), "Rejected endpoint {invalid}" ); } workflow .update(&p, Event::Target(choice(&p, output))) .unwrap(); let Workflow::Relation(draft) = &workflow else { unreachable!() }; assert!(draft.commands(&p).is_ok()); workflow.update(&p, Event::Oriented(true)).unwrap(); let Workflow::Relation(draft) = &workflow else { unreachable!() }; assert_eq!( draft.target.as_ref().unwrap().id, output, "Keep the selected identity available for correction" ); assert!(!draft.targets.iter().any(|choice| choice.id == output)); assert!( draft.commands(&p).is_err(), "The actual Apply gate rejects the incompatible draft" ); assert!(workflow.update(&p, Event::Apply).is_err()); assert_eq!(p, before); workflow.update(&p, Event::Oriented(false)).unwrap(); let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap(); assert!(accepted.relations.values().any(|r| matches!(r.body, RelationBody::BoundaryExchange { source, target, oriented: false, .. } if source == input && target == output))); } #[test] fn fresh_functions_are_actual_picker_targets_and_plan_enables_apply() { let mut p = Project::empty(); let a = p .add_element(Kind::Function, Layer::Sa, "Source", None) .unwrap(); let b = p .add_element(Kind::Function, Layer::Sa, "Target", None) .unwrap(); let oa = p .add_element(Kind::Activity, Layer::Oa, "Outside layer", None) .unwrap(); let mut w = Workflow::relationship_kind(&p, a, Layer::Sa, RelationKind::FunctionalExchange, false); let Workflow::Relation(d) = &w else { unreachable!() }; assert!(d.targets.iter().any(|v| v.id == b)); assert!(!d.targets.iter().any(|v| v.id == a || v.id == oa)); assert!(d.commands(&p).is_err()); w.update(&p, Event::Target(choice(&p, b))).unwrap(); let Workflow::Relation(d) = &w else { unreachable!() }; assert!( d.commands(&p).is_ok(), "The same plan enables the actual Apply control" ); let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); assert_eq!( accepted .elements .values() .filter(|e| e.kind() == Kind::Port) .count(), 2 ); let invalid = Relation { id: Id::new_v4(), alias: "unbound".into(), name: "Unbound".into(), description: String::new(), body: RelationBody::new(RelationKind::FunctionalExchange, a, b), }; assert!( execute_batch(&p, &[Command::CreateRelation(invalid)]).is_err(), "Exact records remain strict" ); } #[test] fn ordinary_standard_connections_default_unoriented_and_new_roles_exclude_legacy() { let mut p = Project::empty(); let a = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let b = p .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) .unwrap(); let f = p .add_element(Kind::Function, Layer::Sa, "Function", None) .unwrap(); let interface = p .add_element(Kind::Interface, Layer::Sa, "Interface", None) .unwrap(); let mut ids = vec![]; for owner in [a, b] { let id = Id::new_v4(); let port = make_port( &p, id, owner, "Standard", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, ) .unwrap(); p.elements.insert(id, port); ids.push(id); } let legacy = Id::new_v4(); let mut port = make_port(&p, legacy, f, "Preserved", PortContract::Legacy).unwrap(); if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() { *legacy = Some(LegacyPortContract { direction: PortDirection::In, interface: Some(interface), }); } p.elements.insert(legacy, port); let mut w = Workflow::relationship_kind( &p, ids[0], Layer::Sa, RelationKind::BoundaryExchange, false, ); w.update(&p, Event::Target(choice(&p, ids[1]))).unwrap(); let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); assert!(accepted.relations.values().any(|r| matches!( r.body, RelationBody::BoundaryExchange { oriented: false, .. } ))); for kind in [ RelationKind::InterfaceProvision, RelationKind::InterfaceRequirement, ] { let w = Workflow::relationship_kind(&p, legacy, Layer::Sa, kind, false); let Workflow::Relation(d) = w else { unreachable!() }; assert!(d.source.is_none()); assert!(!d.sources.iter().any(|v| v.id == legacy)); assert!(d.sources.iter().any(|v| v.id == ids[0])); } } }