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

610 lines
24 KiB
Rust

//! 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,
FunctionalExchange,
BoundaryExchange,
}
impl Tool {
pub fn for_layer(layer: Layer) -> &'static [Self] {
match layer {
Layer::Oa => &[Self::Interaction],
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
}
}
pub fn label(self) -> &'static str {
match self {
Self::Interaction => "Interaction",
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::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::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::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<Id> {
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<Command, ModelError> {
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::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::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]);
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 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.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![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 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::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);
}
}