//! Semantic endpoints for direct canvas connections. Gesture state and visible //! hit targets belong to the canvas; ordinary core commands own model changes. use oa_core::*; use std::collections::BTreeSet; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { Interaction, CommunicationMean, FunctionalExchange, BoundaryExchange, } impl Tool { pub fn for_layer(layer: Layer) -> &'static [Self] { match layer { Layer::Oa => &[Self::Interaction, Self::CommunicationMean], Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange], } } pub fn label(self) -> &'static str { match self { Self::Interaction => "Interaction", Self::CommunicationMean => "Communication means", Self::FunctionalExchange => "Functional exchange", Self::BoundaryExchange => "Boundary exchange", } } pub fn hint(self) -> &'static str { match self { Self::Interaction => "Click a source activity, then a target activity.", Self::CommunicationMean => { "Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation." } Self::FunctionalExchange => { "Click an output port or source function, then an input port or target function. Function cards use automatic ports." } Self::BoundaryExchange => { "Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented." } } } pub fn can_start(self, project: &Project, source: Id) -> bool { match self { Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| { e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor) }), Self::Interaction => project .elements .get(&source) .is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity), Self::FunctionalExchange => function_endpoint(project, source, true).is_some(), Self::BoundaryExchange => component_endpoint(project, source) .is_some_and(|contract| component_allows(contract, true)), } } /// Cheap feasibility for highlighting candidates. The caller intersects this /// with the actual projected nodes and ports; hidden objects are not hits. pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool { if !self.can_start(project, source) { return false; } match self { Self::CommunicationMean => { validation::can_connect(project, RelationKind::CommunicationMean, source, target) } Self::Interaction => { validation::can_connect(project, RelationKind::Interaction, source, target) } Self::FunctionalExchange => { let (Some((source, _)), Some((target, _))) = ( function_endpoint(project, source, true), function_endpoint(project, target, false), ) else { return false; }; validation::can_connect(project, RelationKind::FunctionalExchange, source, target) } Self::BoundaryExchange => { component_endpoint(project, target).is_some() && validation::can_connect_boundary( project, source, target, boundary_oriented(project, source), ) } } } pub fn targets(self, project: &Project, source: Id) -> BTreeSet { if !self.can_start(project, source) { return BTreeSet::new(); } project .elements .keys() .copied() .filter(|target| self.can_connect(project, source, *target)) .collect() } /// Return one validated history operation without modifying the input. /// FE endpoint generation uses the same atomic planner as the form workflow. /// No carried items are inferred from a port's allowed item declarations. pub fn command(self, project: &Project, source: Id, target: Id) -> Result { if !self.can_connect(project, source, target) { return Err(ModelError(format!( "Choose compatible endpoints for {}. {}", self.label(), self.hint() ))); } let id = Id::new_v4(); if self == Self::FunctionalExchange { let (source, source_port) = function_endpoint(project, source, true) .expect("Functional source was checked above"); let (target, target_port) = function_endpoint(project, target, false) .expect("Functional target was checked above"); let name = engineering_name(project, self, source, target); return plan_functional_exchange( project, &FunctionalExchangeRequest { id, alias: project.fresh_alias(&name, "exchange", id), name, description: String::new(), source, target, source_port, target_port, items: Vec::new(), }, ) .map(Command::Batch); } let name = engineering_name(project, self, source, target); let body = match self { Self::CommunicationMean => { RelationBody::new(RelationKind::CommunicationMean, source, target) } Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target), Self::BoundaryExchange => RelationBody::BoundaryExchange { source, target, oriented: boundary_oriented(project, source), items: Vec::new(), }, Self::FunctionalExchange => unreachable!("Functional planner returned above"), }; let command = Command::CreateRelation(Relation { id, alias: project.fresh_alias(&name, "relation", id), name, description: String::new(), body, }); prepare_command(project, &command)?; Ok(command) } } /// A clicked port determines its own function owner. It cannot be paired with /// a different remembered owner or silently substituted by automatic selection. fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> { let element = project.elements.get(&id)?; if element.layer() != Layer::Sa { return None; } if element.kind() == Kind::Function { return Some((id, PortChoice::Auto)); } match (port_contract(element)?, outgoing) { (PortContract::FunctionOutput { .. }, true) | (PortContract::FunctionInput { .. }, false) => {} _ => return None, } let owner = project.elements.get(&element.parent?)?; (owner.layer() == Layer::Sa && owner.kind() == Kind::Function) .then_some((owner.id, PortChoice::Reuse(id))) } fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> { let element = project.elements.get(&id)?; let owner = project.elements.get(&element.parent?)?; if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() { return None; } let contract = port_contract(element)?; match contract { PortContract::Component { kind: ComponentPortKind::Flow, .. } | PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, } => Some(contract), _ => None, } } fn boundary_oriented(project: &Project, source: Id) -> bool { matches!( component_endpoint(project, source), Some(PortContract::Component { kind: ComponentPortKind::Flow, .. }) ) } fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String { let endpoint = |id| { if tool == Tool::BoundaryExchange && let Some(owner) = project.elements.get(&id).and_then(|e| e.parent) { format!("{} / {}", project.named(owner), project.named(id)) } else { project.named(id).to_owned() } }; let arrow = if tool == Tool::CommunicationMean || (tool == Tool::BoundaryExchange && !boundary_oriented(project, source)) { "—" } else { "→" }; format!( "{}: {} {arrow} {}", tool.label(), endpoint(source), endpoint(target) ) } #[cfg(test)] mod tests { use super::*; use crate::document::Document; use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle}; fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id { p.add_element(kind, layer, name, None).unwrap() } fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id { let id = Id::new_v4(); let port = make_port(p, id, owner, name, contract).unwrap(); p.elements.insert(id, port); id } fn flow(orientation: ComponentPortOrientation) -> PortContract { PortContract::Component { kind: ComponentPortKind::Flow, orientation, } } fn input() -> PortContract { PortContract::FunctionInput { incoming_items: Vec::new(), } } fn output() -> PortContract { PortContract::FunctionOutput { outgoing_items: Vec::new(), } } fn created(project: &Project, command: &Command) -> (Project, Relation) { let prepared = prepare_command(project, command).unwrap(); let relation = prepared.project.relations[&prepared.selected.unwrap()].clone(); (prepared.project, relation) } #[test] fn operational_interaction_targets_only_distinct_oa_activities() { let mut p = Project::empty(); let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report"); let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report"); let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher"); let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report"); let before = p.clone(); assert_eq!( Tool::for_layer(Layer::Oa), &[Tool::Interaction, Tool::CommunicationMean] ); assert_eq!( Tool::Interaction.targets(&p, source), BTreeSet::from([target]) ); for invalid in [source, actor, function, Id::new_v4()] { assert!(Tool::Interaction.command(&p, source, invalid).is_err()); } let command = Tool::Interaction.command(&p, source, target).unwrap(); assert!(matches!(command, Command::CreateRelation(_))); let (_, relation) = created(&p, &command); assert_eq!(relation.body.endpoints(), (source, target)); assert_eq!(relation.name, "Interaction: Send report → Assess report"); assert_eq!(p, before); } #[test] fn operational_communication_uses_participants_without_creating_ports() { let mut p = Project::empty(); let a = element(&mut p, Kind::Entity, Layer::Oa, "Field team"); let b = element(&mut p, Kind::Actor, Layer::Oa, "Coordinator"); let activity = element(&mut p, Kind::Activity, Layer::Oa, "Report"); let sa = element(&mut p, Kind::System, Layer::Sa, "Controller"); let before = p.clone(); assert_eq!(Tool::CommunicationMean.targets(&p, a), BTreeSet::from([b])); for invalid in [a, activity, sa, Id::new_v4()] { assert!(Tool::CommunicationMean.command(&p, a, invalid).is_err()); } let command = Tool::CommunicationMean.command(&p, a, b).unwrap(); let (accepted, relation) = created(&p, &command); assert!(matches!(relation.body, RelationBody::CommunicationMean { source, target, oriented: false, ref items, } if source == a && target == b && items.is_empty())); assert_eq!(accepted.elements, p.elements); assert_eq!(p, before); } #[test] fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() { let mut p = Project::empty(); let a = element(&mut p, Kind::Function, Layer::Sa, "Detect"); let b = element(&mut p, Kind::Function, Layer::Sa, "Assess"); let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report"); let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result"); let source = port( &mut p, a, "Reports out", PortContract::FunctionOutput { outgoing_items: vec![report], }, ); let target = port( &mut p, b, "Results in", PortContract::FunctionInput { incoming_items: vec![result], }, ); let other = port(&mut p, a, "Other output", output()); let before = p.clone(); let command = Tool::FunctionalExchange .command(&p, source, target) .unwrap(); assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1)); let (accepted, relation) = created(&p, &command); assert!(matches!(relation.body, RelationBody::FunctionalExchange { source: s, target: t, source_port: sp, target_port: tp, ref items, } if (s,t,sp,tp) == (a,b,source,target) && items.is_empty())); assert_eq!(accepted.elements, p.elements); assert_eq!(relation.name, "Functional exchange: Detect → Assess"); for (s, t) in [ (target, source), (source, other), (source, a), (source, source), ] { assert!(!Tool::FunctionalExchange.can_connect(&p, s, t)); assert!(Tool::FunctionalExchange.command(&p, s, t).is_err()); } assert_eq!(p, before); } #[test] fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() { let mut p = Project::empty(); let a = element(&mut p, Kind::Function, Layer::Sa, "Detect"); let b = element(&mut p, Kind::Function, Layer::Sa, "Assess"); let out = port(&mut p, a, "Report out", output()); let input = port(&mut p, b, "Report in", input()); let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap()); assert!( matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. } if (source_port,target_port) == (out,input)) ); let second = port(&mut p, a, "Other report out", output()); let command = Tool::FunctionalExchange.command(&p, a, input).unwrap(); assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2)); let (accepted, generated) = created(&p, &command); let RelationBody::FunctionalExchange { source_port, target_port, .. } = generated.body else { panic!("Expected functional exchange") }; assert_eq!(target_port, input); assert!(![out, second].contains(&source_port)); assert_eq!(accepted.elements[&source_port].parent, Some(a)); assert_eq!(accepted.elements.len(), p.elements.len() + 1); } #[test] fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() { let mut p = Project::empty(); let a = element(&mut p, Kind::System, Layer::Sa, "Response system"); let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher"); let mut endpoints = Vec::new(); for owner in [a, b] { for orientation in [ ComponentPortOrientation::Out, ComponentPortOrientation::In, ComponentPortOrientation::InOut, ComponentPortOrientation::Unset, ] { endpoints.push(port( &mut p, owner, &format!("Flow {orientation:?}"), flow(orientation), )); } endpoints.push(port( &mut p, owner, "Control", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, )); } for source in &endpoints { let oriented = boundary_oriented(&p, *source); let targets = Tool::BoundaryExchange.targets(&p, *source); for target in &endpoints { let expected = validation::can_connect_boundary(&p, *source, *target, oriented); assert_eq!(targets.contains(target), expected, "{source} → {target}"); let command = Tool::BoundaryExchange.command(&p, *source, *target); assert_eq!(command.is_ok(), expected); if let Ok(command) = command { let (_, relation) = created(&p, &command); assert!( matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented) ); assert!(relation.name.contains(" / ")); assert_eq!(relation.name.contains('→'), oriented); } } } } #[test] fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() { let mut p = Project::empty(); let a = element(&mut p, Kind::System, Layer::Sa, "System"); let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner"); let standard = PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }; let source = port(&mut p, a, "Service", standard.clone()); let target = port(&mut p, b, "Required service", standard); let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report"); let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result"); let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting"); let required = element(&mut p, Kind::Interface, Layer::Sa, "Results"); for (interface, item) in [(provided, report), (required, result)] { p.relate( RelationKind::InterfaceItemAllocation, "Allocated item", interface, item, ); } p.relate( RelationKind::InterfaceProvision, "Provides reporting", source, provided, ); let requirement = p.relate( RelationKind::InterfaceRequirement, "Requires results", target, required, ); validate_candidate(&p).unwrap(); let before = p.clone(); assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target)); assert!(validation::can_connect_boundary(&p, source, target, false)); let (accepted, relation) = created( &p, &Tool::BoundaryExchange.command(&p, source, target).unwrap(), ); let warnings: Vec<_> = validation::validate(&accepted) .into_iter() .filter(|issue| issue.code == "unsatisfied_interface_requirement") .collect(); assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].severity, validation::Severity::Warning); assert_eq!(warnings[0].element, Some(requirement)); assert_eq!(warnings[0].field.as_deref(), Some("target")); let mut carrying = relation.clone(); let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else { unreachable!() }; items.push(result); assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err()); assert_eq!(p, before, "Rejected carried items never mutate the input"); p.relate( RelationKind::InterfaceProvision, "Provides results", source, required, ); let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap(); assert!( validation::validate(&prepared.project) .iter() .all(|issue| issue.code != "unsatisfied_interface_requirement") ); } #[test] fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() { let mut p = Project::empty(); let function = element(&mut p, Kind::Function, Layer::Sa, "Function"); let system = element(&mut p, Kind::System, Layer::Sa, "System"); let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity"); let function_on_system = port(&mut p, system, "Wrong function owner", output()); let component_on_function = port( &mut p, function, "Wrong component owner", flow(ComponentPortOrientation::Out), ); let oa_function_port = port(&mut p, activity, "Wrong layer", output()); let malformed_standard = port( &mut p, system, "Wrong Standard orientation", PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Out, }, ); for source in [ function_on_system, component_on_function, oa_function_port, malformed_standard, ] { for tool in Tool::for_layer(Layer::Sa) { assert!(!tool.can_start(&p, source)); assert!(tool.targets(&p, source).is_empty()); assert!(tool.command(&p, source, function).is_err()); } } } #[test] fn direct_connections_share_exact_source_history_and_model_parity() { let mut p = Project::empty(); let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident"); let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident"); let oa_team = element(&mut p, Kind::Entity, Layer::Oa, "Response team"); let oa_actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher"); let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report"); let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report"); let system = element(&mut p, Kind::System, Layer::Sa, "System"); let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor"); let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out)); let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In)); let mut bundle = format_bundle(&p); bundle .files .get_mut(&bundle.entry.clone()) .unwrap() .insert_str(0, "// Preserve engineering rationale exactly.\n"); let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); for (tool, source, target) in [ (Tool::Interaction, oa_a, oa_b), (Tool::CommunicationMean, oa_team, oa_actor), (Tool::FunctionalExchange, sa_a, sa_b), (Tool::BoundaryExchange, boundary_a, boundary_b), ] { let before = document.bundle().clone(); let original = document.project().clone(); let revision = document.revision(); let command = tool.command(document.project(), source, target).unwrap(); let expected = prepare_command(document.project(), &command).unwrap(); let selected = document.execute(command).unwrap(); assert_eq!(selected, expected.selected); assert_eq!(document.project(), &expected.project); assert_eq!(document.revision(), revision + 1); assert_eq!( compile_bundle(document.bundle(), ProjectLimits::default()) .unwrap() .decoded .project, expected.project ); let changed = document.bundle().clone(); assert!(document.undo()); assert_eq!(document.bundle(), &before); assert_eq!(document.project(), &original); assert!(document.redo()); assert_eq!(document.bundle(), &changed); } } #[test] fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() { let mut p = Project::empty(); let a = element(&mut p, Kind::System, Layer::Sa, "System"); let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner"); let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out)); let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In)); assert!(Tool::BoundaryExchange.can_connect(&p, source, target)); // An indexed key alone is not proof of a live model identity. Preserve // the reference check even when inspecting an inconsistent input model. p.elements.get_mut(&target).unwrap().id = Id::new_v4(); let before = p.clone(); assert!(!validation::can_connect_boundary(&p, source, target, true)); assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target)); assert!(Tool::BoundaryExchange.command(&p, source, target).is_err()); assert_eq!(p, before); } #[test] fn final_command_validation_rejects_invalid_models_without_mutation() { let mut p = Project::empty(); let a = element(&mut p, Kind::Activity, Layer::Oa, "Send"); let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive"); p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone(); let before = p.clone(); assert!( Tool::Interaction.can_connect(&p, a, b), "Candidate highlighting checks the proposed link; commit checks the whole model" ); assert!(Tool::Interaction.command(&p, a, b).is_err()); assert_eq!(p, before); } }