Make participant ports and hierarchical model navigation discoverable
Add focused containment browsing, contextual paged search, clear outline depth and direct component-port creation and connection actions. Share exact boundary feasibility checks to avoid whole-project validation for each picker candidate. Preserve source/property drafts and interrupted-save recovery state, document source-project behavior, and record Windows/Linux native verification and independent review fixes.
This commit is contained in:
@@ -169,33 +169,11 @@ impl Relationship {
|
||||
let Some(source) = self.source.as_ref().map(|source| source.id) else {
|
||||
return false;
|
||||
};
|
||||
let directed = validation::can_connect(p, self.kind, source, target);
|
||||
if self.kind != RelationKind::BoundaryExchange || self.oriented || directed {
|
||||
return directed;
|
||||
if self.kind == RelationKind::BoundaryExchange {
|
||||
validation::can_connect_boundary(p, source, target, self.oriented)
|
||||
} else {
|
||||
validation::can_connect(p, self.kind, source, target)
|
||||
}
|
||||
if ![source, target].iter().all(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|element| element.kind() == Kind::Port)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
// An unoriented connection may use the reverse stored endpoint order.
|
||||
// Ask the exact core rules, including preserved Legacy restrictions;
|
||||
// reversing can_connect alone would incorrectly relax those rules.
|
||||
let candidate = Relation {
|
||||
id: self.id,
|
||||
alias: p.fresh_alias("connection", "relation", self.id),
|
||||
name: "Connection".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
};
|
||||
execute_batch(p, &[Command::CreateRelation(candidate)]).is_ok()
|
||||
}
|
||||
|
||||
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
|
||||
@@ -679,7 +657,15 @@ impl Workflow {
|
||||
_ => ("Source", "Target"),
|
||||
};
|
||||
content = content
|
||||
.push(text(d.kind.hint()).size(13).color(style::MUTED))
|
||||
.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))
|
||||
@@ -709,7 +695,15 @@ impl Workflow {
|
||||
.width(Fill),
|
||||
);
|
||||
if d.source.is_some() && d.targets.is_empty() {
|
||||
content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED));
|
||||
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(
|
||||
@@ -1164,6 +1158,151 @@ fn choice(p: &Project, id: Id) -> Choice {
|
||||
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 [
|
||||
|
||||
Reference in New Issue
Block a user