Model typed SA ports and atomic exchange allocation workflows
This commit is contained in:
@@ -34,11 +34,33 @@ fn occurrence(
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(owner));
|
let items = match p.elements[&interface].shared() {
|
||||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
Some(SharedBody::Interface { items }) => items.clone(),
|
||||||
direction,
|
_ => vec![],
|
||||||
interface: Some(interface),
|
|
||||||
};
|
};
|
||||||
|
let contract = if p.elements[&owner].kind() == Kind::Function {
|
||||||
|
if direction == PortDirection::Out {
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: items,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::FunctionInput {
|
||||||
|
incoming_items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: match direction {
|
||||||
|
PortDirection::In => ComponentPortOrientation::In,
|
||||||
|
PortDirection::Out => ComponentPortOrientation::Out,
|
||||||
|
PortDirection::InOut => ComponentPortOrientation::InOut,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let element = crate::ports::make_port(p, id, owner, name, contract).unwrap();
|
||||||
|
p.elements.insert(id, element);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
fn diagram(
|
fn diagram(
|
||||||
@@ -369,8 +391,8 @@ pub fn coastal_rescue() -> Project {
|
|||||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||||
source: transmit,
|
source: transmit,
|
||||||
target: coordinate,
|
target: coordinate,
|
||||||
source_port: Some(function_out),
|
source_port: function_out,
|
||||||
target_port: Some(function_in),
|
target_port: function_in,
|
||||||
items: vec![information],
|
items: vec![information],
|
||||||
};
|
};
|
||||||
let boundary = p.relate(
|
let boundary = p.relate(
|
||||||
@@ -384,6 +406,18 @@ pub fn coastal_rescue() -> Project {
|
|||||||
{
|
{
|
||||||
items.push(information);
|
items.push(information);
|
||||||
}
|
}
|
||||||
|
p.relate(
|
||||||
|
RelationKind::PortAllocation,
|
||||||
|
"Vessel output represents report output",
|
||||||
|
vessel_port,
|
||||||
|
function_out,
|
||||||
|
);
|
||||||
|
p.relate(
|
||||||
|
RelationKind::PortAllocation,
|
||||||
|
"System input represents assessment input",
|
||||||
|
system_port,
|
||||||
|
function_in,
|
||||||
|
);
|
||||||
p.relate(
|
p.relate(
|
||||||
RelationKind::ExchangeAllocation,
|
RelationKind::ExchangeAllocation,
|
||||||
"Report carried by connection",
|
"Report carried by connection",
|
||||||
@@ -393,13 +427,13 @@ pub fn coastal_rescue() -> Project {
|
|||||||
p.relate(
|
p.relate(
|
||||||
RelationKind::InterfaceProvision,
|
RelationKind::InterfaceProvision,
|
||||||
"Reporting service provided",
|
"Reporting service provided",
|
||||||
actor,
|
vessel_port,
|
||||||
interface,
|
interface,
|
||||||
);
|
);
|
||||||
p.relate(
|
p.relate(
|
||||||
RelationKind::InterfaceRequirement,
|
RelationKind::InterfaceRequirement,
|
||||||
"Reporting service required",
|
"Reporting service required",
|
||||||
system,
|
system_port,
|
||||||
interface,
|
interface,
|
||||||
);
|
);
|
||||||
let sa_occurrence = occurrence(
|
let sa_occurrence = occurrence(
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
pub mod language;
|
pub mod language;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
pub mod ports;
|
||||||
pub mod projection;
|
pub mod projection;
|
||||||
pub mod references;
|
pub mod references;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod transitions;
|
pub mod transitions;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
|
pub use ports::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
mod demo;
|
mod demo;
|
||||||
mod state_validation;
|
mod state_validation;
|
||||||
|
|||||||
+105
-12
@@ -6,7 +6,7 @@ use std::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub type Id = Uuid;
|
pub type Id = Uuid;
|
||||||
pub const SCHEMA_VERSION: u32 = 2;
|
pub const SCHEMA_VERSION: u32 = 3;
|
||||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
@@ -293,6 +293,54 @@ pub enum PortDirection {
|
|||||||
InOut,
|
InOut,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ComponentPortKind {
|
||||||
|
Standard,
|
||||||
|
Flow,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ComponentPortOrientation {
|
||||||
|
Unset,
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
InOut,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LegacyPortContract {
|
||||||
|
pub direction: PortDirection,
|
||||||
|
pub interface: Option<Id>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub enum PortContract {
|
||||||
|
FunctionInput {
|
||||||
|
incoming_items: Vec<Id>,
|
||||||
|
},
|
||||||
|
FunctionOutput {
|
||||||
|
outgoing_items: Vec<Id>,
|
||||||
|
},
|
||||||
|
Component {
|
||||||
|
kind: ComponentPortKind,
|
||||||
|
orientation: ComponentPortOrientation,
|
||||||
|
},
|
||||||
|
Legacy,
|
||||||
|
}
|
||||||
|
impl PortContract {
|
||||||
|
pub fn items(&self) -> &[Id] {
|
||||||
|
match self {
|
||||||
|
Self::FunctionInput { incoming_items } => incoming_items,
|
||||||
|
Self::FunctionOutput { outgoing_items } => outgoing_items,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn is_function(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::FunctionInput { .. } | Self::FunctionOutput { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum StateKind {
|
pub enum StateKind {
|
||||||
State,
|
State,
|
||||||
Mode,
|
Mode,
|
||||||
@@ -468,8 +516,8 @@ pub enum SharedBody {
|
|||||||
items: Vec<Id>,
|
items: Vec<Id>,
|
||||||
},
|
},
|
||||||
Port {
|
Port {
|
||||||
direction: PortDirection,
|
contract: PortContract,
|
||||||
interface: Option<Id>,
|
legacy: Option<LegacyPortContract>,
|
||||||
},
|
},
|
||||||
Scenario {
|
Scenario {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -635,10 +683,8 @@ impl ElementBody {
|
|||||||
unit: String::new(),
|
unit: String::new(),
|
||||||
},
|
},
|
||||||
Kind::Interface => SharedBody::Interface { items: vec![] },
|
Kind::Interface => SharedBody::Interface { items: vec![] },
|
||||||
Kind::Port => SharedBody::Port {
|
// Port creation requires an explicit contract and owner.
|
||||||
direction: PortDirection::InOut,
|
Kind::Port => return None,
|
||||||
interface: None,
|
|
||||||
},
|
|
||||||
Kind::Scenario => SharedBody::Scenario {
|
Kind::Scenario => SharedBody::Scenario {
|
||||||
viewpoint: if layer == Layer::Oa {
|
viewpoint: if layer == Layer::Oa {
|
||||||
ScenarioViewpoint::OperationalActivity
|
ScenarioViewpoint::OperationalActivity
|
||||||
@@ -707,6 +753,7 @@ impl SharedBody {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum RelationKind {
|
pub enum RelationKind {
|
||||||
Allocation,
|
Allocation,
|
||||||
|
PortAllocation,
|
||||||
Interaction,
|
Interaction,
|
||||||
CapabilityInvolvement,
|
CapabilityInvolvement,
|
||||||
ChainInvolvement,
|
ChainInvolvement,
|
||||||
@@ -728,8 +775,9 @@ pub enum RelationKind {
|
|||||||
StateAction,
|
StateAction,
|
||||||
}
|
}
|
||||||
impl RelationKind {
|
impl RelationKind {
|
||||||
pub const ALL: [Self; 20] = [
|
pub const ALL: [Self; 21] = [
|
||||||
Self::Allocation,
|
Self::Allocation,
|
||||||
|
Self::PortAllocation,
|
||||||
Self::Interaction,
|
Self::Interaction,
|
||||||
Self::CapabilityInvolvement,
|
Self::CapabilityInvolvement,
|
||||||
Self::ChainInvolvement,
|
Self::ChainInvolvement,
|
||||||
@@ -753,6 +801,7 @@ impl RelationKind {
|
|||||||
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Allocation => from.is_performer() && to.is_work(),
|
Self::Allocation => from.is_performer() && to.is_work(),
|
||||||
|
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
|
||||||
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
||||||
Self::CapabilityInvolvement => {
|
Self::CapabilityInvolvement => {
|
||||||
(from.is_performer() || from.is_work() || from == Kind::Role)
|
(from.is_performer() || from.is_work() || from == Kind::Role)
|
||||||
@@ -807,6 +856,7 @@ impl RelationKind {
|
|||||||
pub fn hint(self) -> &'static str {
|
pub fn hint(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Allocation => "Performer → leaf activity/function",
|
Self::Allocation => "Performer → leaf activity/function",
|
||||||
|
Self::PortAllocation => "Component port → allocated function port",
|
||||||
Self::Interaction => "OA activity → OA activity",
|
Self::Interaction => "OA activity → OA activity",
|
||||||
Self::CapabilityInvolvement => "Participant/activity/function → capability",
|
Self::CapabilityInvolvement => "Participant/activity/function → capability",
|
||||||
Self::ChainInvolvement => "Process/chain → capability",
|
Self::ChainInvolvement => "Process/chain → capability",
|
||||||
@@ -837,6 +887,7 @@ impl fmt::Display for RelationKind {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.write_str(match self {
|
f.write_str(match self {
|
||||||
Self::Allocation => "Allocation",
|
Self::Allocation => "Allocation",
|
||||||
|
Self::PortAllocation => "Port allocation",
|
||||||
Self::Interaction => "Interaction",
|
Self::Interaction => "Interaction",
|
||||||
Self::CapabilityInvolvement => "Capability involvement",
|
Self::CapabilityInvolvement => "Capability involvement",
|
||||||
Self::ChainInvolvement => "Chain involvement",
|
Self::ChainInvolvement => "Chain involvement",
|
||||||
@@ -875,6 +926,10 @@ pub enum RelationBody {
|
|||||||
source: Id,
|
source: Id,
|
||||||
target: Id,
|
target: Id,
|
||||||
},
|
},
|
||||||
|
PortAllocation {
|
||||||
|
source: Id,
|
||||||
|
target: Id,
|
||||||
|
},
|
||||||
Interaction {
|
Interaction {
|
||||||
source: Id,
|
source: Id,
|
||||||
target: Id,
|
target: Id,
|
||||||
@@ -899,13 +954,14 @@ pub enum RelationBody {
|
|||||||
FunctionalExchange {
|
FunctionalExchange {
|
||||||
source: Id,
|
source: Id,
|
||||||
target: Id,
|
target: Id,
|
||||||
source_port: Option<Id>,
|
source_port: Id,
|
||||||
target_port: Option<Id>,
|
target_port: Id,
|
||||||
items: Vec<Id>,
|
items: Vec<Id>,
|
||||||
},
|
},
|
||||||
BoundaryExchange {
|
BoundaryExchange {
|
||||||
source: Id,
|
source: Id,
|
||||||
target: Id,
|
target: Id,
|
||||||
|
oriented: bool,
|
||||||
items: Vec<Id>,
|
items: Vec<Id>,
|
||||||
},
|
},
|
||||||
MissionCapability {
|
MissionCapability {
|
||||||
@@ -984,6 +1040,7 @@ impl RelationBody {
|
|||||||
pub fn kind(&self) -> RelationKind {
|
pub fn kind(&self) -> RelationKind {
|
||||||
match self {
|
match self {
|
||||||
Self::Allocation { .. } => RelationKind::Allocation,
|
Self::Allocation { .. } => RelationKind::Allocation,
|
||||||
|
Self::PortAllocation { .. } => RelationKind::PortAllocation,
|
||||||
Self::Interaction { .. } => RelationKind::Interaction,
|
Self::Interaction { .. } => RelationKind::Interaction,
|
||||||
Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement,
|
Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement,
|
||||||
Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement,
|
Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement,
|
||||||
@@ -1008,6 +1065,7 @@ impl RelationBody {
|
|||||||
pub fn endpoints(&self) -> (Id, Id) {
|
pub fn endpoints(&self) -> (Id, Id) {
|
||||||
match self {
|
match self {
|
||||||
Self::Allocation { source, target }
|
Self::Allocation { source, target }
|
||||||
|
| Self::PortAllocation { source, target }
|
||||||
| Self::Interaction { source, target, .. }
|
| Self::Interaction { source, target, .. }
|
||||||
| Self::CapabilityInvolvement { source, target }
|
| Self::CapabilityInvolvement { source, target }
|
||||||
| Self::ChainInvolvement { source, target }
|
| Self::ChainInvolvement { source, target }
|
||||||
@@ -1032,6 +1090,7 @@ impl RelationBody {
|
|||||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||||
match self {
|
match self {
|
||||||
Self::Allocation { source, target }
|
Self::Allocation { source, target }
|
||||||
|
| Self::PortAllocation { source, target }
|
||||||
| Self::Interaction { source, target, .. }
|
| Self::Interaction { source, target, .. }
|
||||||
| Self::CapabilityInvolvement { source, target }
|
| Self::CapabilityInvolvement { source, target }
|
||||||
| Self::ChainInvolvement { source, target }
|
| Self::ChainInvolvement { source, target }
|
||||||
@@ -1067,6 +1126,7 @@ impl RelationBody {
|
|||||||
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
||||||
match kind {
|
match kind {
|
||||||
RelationKind::Allocation => Self::Allocation { source, target },
|
RelationKind::Allocation => Self::Allocation { source, target },
|
||||||
|
RelationKind::PortAllocation => Self::PortAllocation { source, target },
|
||||||
RelationKind::Interaction => Self::Interaction {
|
RelationKind::Interaction => Self::Interaction {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
@@ -1079,13 +1139,15 @@ impl RelationBody {
|
|||||||
RelationKind::FunctionalExchange => Self::FunctionalExchange {
|
RelationKind::FunctionalExchange => Self::FunctionalExchange {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
source_port: None,
|
// Unbound drafts must be completed before candidate admission.
|
||||||
target_port: None,
|
source_port: Id::nil(),
|
||||||
|
target_port: Id::nil(),
|
||||||
items: vec![],
|
items: vec![],
|
||||||
},
|
},
|
||||||
RelationKind::BoundaryExchange => Self::BoundaryExchange {
|
RelationKind::BoundaryExchange => Self::BoundaryExchange {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
|
oriented: true,
|
||||||
items: vec![],
|
items: vec![],
|
||||||
},
|
},
|
||||||
RelationKind::MissionCapability => Self::MissionCapability { source, target },
|
RelationKind::MissionCapability => Self::MissionCapability { source, target },
|
||||||
@@ -1584,6 +1646,37 @@ impl Project {
|
|||||||
pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id {
|
pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id {
|
||||||
let id = Id::new_v4();
|
let id = Id::new_v4();
|
||||||
let alias = self.fresh_alias(name, "relation", id);
|
let alias = self.fresh_alias(name, "relation", id);
|
||||||
|
if kind == RelationKind::FunctionalExchange
|
||||||
|
&& self.elements.contains_key(&source)
|
||||||
|
&& self.elements.contains_key(&target)
|
||||||
|
{
|
||||||
|
let request = crate::ports::FunctionalExchangeRequest {
|
||||||
|
id,
|
||||||
|
alias,
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
source_port: crate::ports::PortChoice::Auto,
|
||||||
|
target_port: crate::ports::PortChoice::Auto,
|
||||||
|
items: vec![],
|
||||||
|
};
|
||||||
|
// Project is the unchecked builder; Session validates the complete batch.
|
||||||
|
for command in crate::ports::functional_exchange_commands(self, &request)
|
||||||
|
.expect("fresh exchange identities")
|
||||||
|
{
|
||||||
|
match command {
|
||||||
|
crate::Command::CreateElement(e) => {
|
||||||
|
self.elements.insert(e.id, e);
|
||||||
|
}
|
||||||
|
crate::Command::CreateRelation(r) => {
|
||||||
|
self.relations.insert(r.id, r);
|
||||||
|
}
|
||||||
|
_ => unreachable!("functional creation only creates ports and an exchange"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
let mut body = RelationBody::new(kind, source, target);
|
let mut body = RelationBody::new(kind, source, target);
|
||||||
if self
|
if self
|
||||||
.elements
|
.elements
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ pub struct ProjectedEdge {
|
|||||||
pub source: Id,
|
pub source: Id,
|
||||||
pub target: Id,
|
pub target: Id,
|
||||||
pub trace: bool,
|
pub trace: bool,
|
||||||
|
pub oriented: bool,
|
||||||
pub source_port: Option<Id>,
|
pub source_port: Option<Id>,
|
||||||
pub target_port: Option<Id>,
|
pub target_port: Option<Id>,
|
||||||
}
|
}
|
||||||
@@ -27,7 +28,36 @@ pub struct ProjectedPort {
|
|||||||
/// Placement identity of the owning visible function/participant.
|
/// Placement identity of the owning visible function/participant.
|
||||||
pub owner: Id,
|
pub owner: Id,
|
||||||
pub position: Position,
|
pub position: Position,
|
||||||
pub direction: PortDirection,
|
pub side: PortAnchorSide,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PortAnchorSide {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
|
||||||
|
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(match contract {
|
||||||
|
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
|
||||||
|
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::In,
|
||||||
|
} => PortAnchorSide::Left,
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::Out,
|
||||||
|
} => PortAnchorSide::Right,
|
||||||
|
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
||||||
|
Some(PortDirection::In) => PortAnchorSide::Left,
|
||||||
|
Some(PortDirection::Out) => PortAnchorSide::Right,
|
||||||
|
_ => PortAnchorSide::Bottom,
|
||||||
|
},
|
||||||
|
_ => PortAnchorSide::Bottom,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
pub struct ProjectedView {
|
pub struct ProjectedView {
|
||||||
@@ -36,10 +66,25 @@ pub struct ProjectedView {
|
|||||||
pub ports: Vec<ProjectedPort>,
|
pub ports: Vec<ProjectedPort>,
|
||||||
}
|
}
|
||||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||||
let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0);
|
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
|
||||||
|
.into_iter()
|
||||||
|
.map(|side| {
|
||||||
|
p.children(element)
|
||||||
|
.filter(|port| port_anchor_side(port) == Some(side))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||||
}
|
}
|
||||||
|
let bottom = p
|
||||||
|
.children(element)
|
||||||
|
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
|
||||||
|
.count();
|
||||||
|
if bottom > 0 {
|
||||||
|
size.width = size.width.max(28. * (bottom + 1) as f32);
|
||||||
|
}
|
||||||
size
|
size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,20 +250,27 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|||||||
.filter(|e| e.kind() == Kind::Port)
|
.filter(|e| e.kind() == Kind::Port)
|
||||||
.collect();
|
.collect();
|
||||||
children.sort_by_key(|e| (&e.alias, e.id));
|
children.sort_by_key(|e| (&e.alias, e.id));
|
||||||
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
|
for anchor_side in [
|
||||||
let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect();
|
PortAnchorSide::Left,
|
||||||
|
PortAnchorSide::Right,
|
||||||
|
PortAnchorSide::Bottom,
|
||||||
|
] {
|
||||||
|
let side: Vec<_> = children
|
||||||
|
.iter()
|
||||||
|
.filter(|e| port_anchor_side(e) == Some(anchor_side))
|
||||||
|
.collect();
|
||||||
for (index, port) in side.iter().enumerate() {
|
for (index, port) in side.iter().enumerate() {
|
||||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||||
let position = match direction {
|
let position = match anchor_side {
|
||||||
PortDirection::In => Position {
|
PortAnchorSide::Left => Position {
|
||||||
x: node.position.x,
|
x: node.position.x,
|
||||||
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
||||||
},
|
},
|
||||||
PortDirection::Out => Position {
|
PortAnchorSide::Right => Position {
|
||||||
x: node.position.x + node.size.width,
|
x: node.position.x + node.size.width,
|
||||||
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
|
||||||
},
|
},
|
||||||
PortDirection::InOut => Position {
|
PortAnchorSide::Bottom => Position {
|
||||||
x: node.position.x + node.size.width * fraction,
|
x: node.position.x + node.size.width * fraction,
|
||||||
y: node.position.y + node.size.height,
|
y: node.position.y + node.size.height,
|
||||||
},
|
},
|
||||||
@@ -227,7 +279,7 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|||||||
element: port.id,
|
element: port.id,
|
||||||
owner: node.id,
|
owner: node.id,
|
||||||
position,
|
position,
|
||||||
direction,
|
side: anchor_side,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,10 +322,9 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|||||||
source_port,
|
source_port,
|
||||||
target_port,
|
target_port,
|
||||||
..
|
..
|
||||||
} => (*source_port, *target_port),
|
} => (Some(*source_port), Some(*target_port)),
|
||||||
RelationBody::BoundaryExchange { source, target, .. } => {
|
RelationBody::BoundaryExchange { source, target, .. }
|
||||||
(Some(*source), Some(*target))
|
| RelationBody::PortAllocation { source, target } => (Some(*source), Some(*target)),
|
||||||
}
|
|
||||||
_ => (None, None),
|
_ => (None, None),
|
||||||
};
|
};
|
||||||
edges.push(ProjectedEdge {
|
edges.push(ProjectedEdge {
|
||||||
@@ -281,6 +332,13 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
trace: false,
|
trace: false,
|
||||||
|
oriented: !matches!(
|
||||||
|
&r.body,
|
||||||
|
RelationBody::BoundaryExchange {
|
||||||
|
oriented: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||||
});
|
});
|
||||||
@@ -296,6 +354,7 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
trace: true,
|
trace: true,
|
||||||
|
oriented: true,
|
||||||
source_port: None,
|
source_port: None,
|
||||||
target_port: None,
|
target_port: None,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,10 +45,20 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
|||||||
visit(id);
|
visit(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SharedBody::Port {
|
SharedBody::Port { contract, legacy } => {
|
||||||
interface: Some(id),
|
match contract {
|
||||||
..
|
PortContract::FunctionInput { incoming_items } => {
|
||||||
} => visit(id),
|
incoming_items.iter_mut().for_each(&mut visit)
|
||||||
|
}
|
||||||
|
PortContract::FunctionOutput { outgoing_items } => {
|
||||||
|
outgoing_items.iter_mut().for_each(&mut visit)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(id) = legacy.as_mut().and_then(|old| old.interface.as_mut()) {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
SharedBody::Scenario { context, steps, .. } => {
|
SharedBody::Scenario { context, steps, .. } => {
|
||||||
if let Some(id) = context {
|
if let Some(id) = context {
|
||||||
visit(id);
|
visit(id);
|
||||||
@@ -145,7 +155,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
|||||||
items,
|
items,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
for id in [source_port, target_port].into_iter().flatten() {
|
for id in [source_port, target_port] {
|
||||||
visit(id);
|
visit(id);
|
||||||
}
|
}
|
||||||
for id in items {
|
for id in items {
|
||||||
@@ -207,7 +217,49 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for r in p.relations.values() {
|
for r in p.relations.values() {
|
||||||
if removed.contains(&r.source()) || removed.contains(&r.target()) {
|
let port_removed = matches!(&r.body, RelationBody::FunctionalExchange { source_port, target_port, .. } if removed.contains(source_port) || removed.contains(target_port));
|
||||||
|
let work_allocation_removed = r.kind() == RelationKind::PortAllocation
|
||||||
|
&& p.relations.values().any(|allocation| {
|
||||||
|
allocation.kind() == RelationKind::Allocation
|
||||||
|
&& removed.contains(&allocation.id)
|
||||||
|
&& p.elements.get(&r.source()).and_then(|e| e.parent)
|
||||||
|
== Some(allocation.source())
|
||||||
|
&& p.elements.get(&r.target()).and_then(|e| e.parent)
|
||||||
|
== Some(allocation.target())
|
||||||
|
});
|
||||||
|
if removed.contains(&r.source())
|
||||||
|
|| removed.contains(&r.target())
|
||||||
|
|| port_removed
|
||||||
|
|| work_allocation_removed
|
||||||
|
{
|
||||||
|
removed.insert(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exchange allocation depends on both explicit endpoint allocations.
|
||||||
|
for r in p
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
|
||||||
|
{
|
||||||
|
let (Some(boundary), Some(work)) =
|
||||||
|
(p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} = &work.body
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if p.relations.values().any(|allocation| {
|
||||||
|
allocation.kind() == RelationKind::PortAllocation
|
||||||
|
&& removed.contains(&allocation.id)
|
||||||
|
&& [boundary.source(), boundary.target()].contains(&allocation.source())
|
||||||
|
&& [*source_port, *target_port].contains(&allocation.target())
|
||||||
|
}) {
|
||||||
removed.insert(r.id);
|
removed.insert(r.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +315,20 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
|
|||||||
SharedBody::Event {
|
SharedBody::Event {
|
||||||
body: EventBody::Signal { items },
|
body: EventBody::Signal { items },
|
||||||
} => items.retain(|id| !removed.contains(id)),
|
} => items.retain(|id| !removed.contains(id)),
|
||||||
SharedBody::Port { interface, .. } => clear(interface),
|
SharedBody::Port { contract, legacy } => {
|
||||||
|
match contract {
|
||||||
|
PortContract::FunctionInput { incoming_items } => {
|
||||||
|
incoming_items.retain(|id| !removed.contains(id))
|
||||||
|
}
|
||||||
|
PortContract::FunctionOutput { outgoing_items } => {
|
||||||
|
outgoing_items.retain(|id| !removed.contains(id))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(old) = legacy {
|
||||||
|
clear(&mut old.interface);
|
||||||
|
}
|
||||||
|
}
|
||||||
SharedBody::Scenario { context, .. } => clear(context),
|
SharedBody::Scenario { context, .. } => clear(context),
|
||||||
SharedBody::Lifeline { represented } => clear(represented),
|
SharedBody::Lifeline { represented } => clear(represented),
|
||||||
SharedBody::Constraint { constrained, .. } => {
|
SharedBody::Constraint { constrained, .. } => {
|
||||||
@@ -336,15 +401,8 @@ pub fn delete_cascade(p: &mut Project, id: Id) {
|
|||||||
| RelationBody::BoundaryExchange { items, .. } => {
|
| RelationBody::BoundaryExchange { items, .. } => {
|
||||||
items.retain(|id| !removed.contains(id))
|
items.retain(|id| !removed.contains(id))
|
||||||
}
|
}
|
||||||
RelationBody::FunctionalExchange {
|
RelationBody::FunctionalExchange { items, .. } => {
|
||||||
items,
|
|
||||||
source_port,
|
|
||||||
target_port,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
items.retain(|id| !removed.contains(id));
|
items.retain(|id| !removed.contains(id));
|
||||||
clear(source_port);
|
|
||||||
clear(target_port);
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,6 +453,24 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result<Option<Id
|
|||||||
Some(*id)
|
Some(*id)
|
||||||
}
|
}
|
||||||
Command::Connect(kind, name, source, target) => {
|
Command::Connect(kind, name, source, target) => {
|
||||||
|
if *kind == RelationKind::FunctionalExchange {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let commands = crate::ports::functional_exchange_commands(
|
||||||
|
p,
|
||||||
|
&FunctionalExchangeRequest {
|
||||||
|
id,
|
||||||
|
alias: p.fresh_alias(name, "relation", id),
|
||||||
|
name: name.clone(),
|
||||||
|
description: String::new(),
|
||||||
|
source: *source,
|
||||||
|
target: *target,
|
||||||
|
source_port: PortChoice::Auto,
|
||||||
|
target_port: PortChoice::Auto,
|
||||||
|
items: vec![],
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
return apply_command(p, &Command::Batch(commands), depth + 1);
|
||||||
|
}
|
||||||
Some(p.relate(*kind, name, *source, *target))
|
Some(p.relate(*kind, name, *source, *target))
|
||||||
}
|
}
|
||||||
Command::Delete(id) => {
|
Command::Delete(id) => {
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||||
|
if layer == Layer::Oa
|
||||||
|
&& crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(shared) = e.shared()
|
if let Some(shared) = e.shared()
|
||||||
&& kind == e.kind()
|
&& kind == e.kind()
|
||||||
{
|
{
|
||||||
@@ -232,8 +237,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
|||||||
RelationBody::FunctionalExchange {
|
RelationBody::FunctionalExchange {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
source_port: None,
|
source_port: Id::nil(),
|
||||||
target_port: None,
|
target_port: Id::nil(),
|
||||||
items: items.clone(),
|
items: items.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +325,47 @@ fn value(props: &TracedProperties, field: TransitionField) -> &String {
|
|||||||
TransitionField::Description => &props.description,
|
TransitionField::Description => &props.description,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SA port implementation details have no fabricated OA counterpart. Propagate
|
||||||
|
/// omissions through dependent records, except the FE whose ports are removed
|
||||||
|
/// by its explicit Interaction mapping.
|
||||||
|
fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
|
||||||
|
let mut omitted: BTreeSet<_> = p
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| {
|
||||||
|
e.layer() == Layer::Sa
|
||||||
|
&& crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy)
|
||||||
|
})
|
||||||
|
.map(|e| e.id)
|
||||||
|
.collect();
|
||||||
|
loop {
|
||||||
|
let before = omitted.len();
|
||||||
|
for r in p
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
|
||||||
|
{
|
||||||
|
if crate::references::relation_references(r)
|
||||||
|
.iter()
|
||||||
|
.any(|id| omitted.contains(id))
|
||||||
|
{
|
||||||
|
omitted.insert(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) {
|
||||||
|
if crate::references::element_references(e)
|
||||||
|
.iter()
|
||||||
|
.any(|id| omitted.contains(id))
|
||||||
|
{
|
||||||
|
omitted.insert(e.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before == omitted.len() {
|
||||||
|
return omitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
|
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
|
||||||
match field {
|
match field {
|
||||||
TransitionField::Name => props.name = value,
|
TransitionField::Name => props.name = value,
|
||||||
@@ -341,6 +387,12 @@ pub fn plan_transition(
|
|||||||
commands: vec![],
|
commands: vec![],
|
||||||
};
|
};
|
||||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||||
|
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||||
|
sa_port_omissions(project)
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
};
|
||||||
|
let mut reported_omissions = BTreeSet::new();
|
||||||
if selected.len() != request.sources.len() {
|
if selected.len() != request.sources.len() {
|
||||||
conflict(
|
conflict(
|
||||||
&mut plan,
|
&mut plan,
|
||||||
@@ -364,6 +416,9 @@ pub fn plan_transition(
|
|||||||
let before = selected.len();
|
let before = selected.len();
|
||||||
let existing: Vec<_> = selected.iter().copied().collect();
|
let existing: Vec<_> = selected.iter().copied().collect();
|
||||||
for id in existing {
|
for id in existing {
|
||||||
|
if omitted.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(e) = project.elements.get(&id) {
|
if let Some(e) = project.elements.get(&id) {
|
||||||
selected.extend(crate::references::element_references(e));
|
selected.extend(crate::references::element_references(e));
|
||||||
// Contextual links are owned by the process/chain shared by
|
// Contextual links are owned by the process/chain shared by
|
||||||
@@ -398,14 +453,40 @@ pub fn plan_transition(
|
|||||||
selected.extend(project.children(id).map(|e| e.id));
|
selected.extend(project.children(id).map(|e| e.id));
|
||||||
}
|
}
|
||||||
} else if let Some(r) = project.relations.get(&id) {
|
} else if let Some(r) = project.relations.get(&id) {
|
||||||
|
if request.direction == TransitionDirection::SaToOa
|
||||||
|
&& let RelationBody::FunctionalExchange {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
items,
|
||||||
|
} = &r.body
|
||||||
|
{
|
||||||
|
// An Interaction needs the work/items, never either SA
|
||||||
|
// endpoint port, including an unresolved legacy one.
|
||||||
|
selected.extend([*source, *target]);
|
||||||
|
selected.extend(items);
|
||||||
|
for port in [*source_port, *target_port] {
|
||||||
|
if reported_omissions.insert(port) {
|
||||||
|
plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
selected.extend(crate::references::relation_references(r));
|
selected.extend(crate::references::relation_references(r));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if selected.len() == before {
|
if selected.len() == before {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selected.retain(|id| {
|
||||||
|
if omitted.contains(id) {
|
||||||
|
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
|
||||||
|
false
|
||||||
|
} else { true }
|
||||||
|
});
|
||||||
let mut choices = BTreeMap::new();
|
let mut choices = BTreeMap::new();
|
||||||
for mapping in &request.mappings {
|
for mapping in &request.mappings {
|
||||||
if choices.insert(mapping.source, mapping).is_some() {
|
if choices.insert(mapping.source, mapping).is_some() {
|
||||||
@@ -569,7 +650,17 @@ pub fn plan_transition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut working = project.clone();
|
let mut working = project.clone();
|
||||||
for source in selected.iter().filter(|id| creating.contains(id)) {
|
let ordered_creations: Vec<_> = selected
|
||||||
|
.iter()
|
||||||
|
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
|
||||||
|
.chain(
|
||||||
|
selected
|
||||||
|
.iter()
|
||||||
|
.filter(|id| creating.contains(id) && project.relations.contains_key(id)),
|
||||||
|
)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
for source in &ordered_creations {
|
||||||
let Some(target) = targets.get(source).copied() else {
|
let Some(target) = targets.get(source).copied() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -643,6 +734,12 @@ pub fn plan_transition(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
for dependency in crate::references::relation_references(&created) {
|
for dependency in crate::references::relation_references(&created) {
|
||||||
|
if dependency.is_nil()
|
||||||
|
&& request.direction == TransitionDirection::OaToSa
|
||||||
|
&& r.kind() == RelationKind::Interaction
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !targets.values().any(|id| *id == dependency)
|
if !targets.values().any(|id| *id == dependency)
|
||||||
&& working.layer_of(dependency) != Some(request.direction.target_layer())
|
&& working.layer_of(dependency) != Some(request.direction.target_layer())
|
||||||
{
|
{
|
||||||
@@ -660,10 +757,54 @@ pub fn plan_transition(
|
|||||||
if !request.fields.contains(&TransitionField::Description) {
|
if !request.fields.contains(&TransitionField::Description) {
|
||||||
created.description.clear();
|
created.description.clear();
|
||||||
}
|
}
|
||||||
|
if request.direction == TransitionDirection::OaToSa
|
||||||
|
&& let RelationBody::FunctionalExchange {
|
||||||
|
source,
|
||||||
|
target: destination,
|
||||||
|
items,
|
||||||
|
..
|
||||||
|
} = &created.body
|
||||||
|
{
|
||||||
|
let request = FunctionalExchangeRequest {
|
||||||
|
id: created.id,
|
||||||
|
alias: created.alias.clone(),
|
||||||
|
name: created.name.clone(),
|
||||||
|
description: created.description.clone(),
|
||||||
|
source: *source,
|
||||||
|
target: *destination,
|
||||||
|
source_port: PortChoice::Auto,
|
||||||
|
target_port: PortChoice::Auto,
|
||||||
|
items: items.clone(),
|
||||||
|
};
|
||||||
|
match crate::ports::functional_exchange_commands(&working, &request) {
|
||||||
|
Ok(commands) => {
|
||||||
|
for command in &commands {
|
||||||
|
match command {
|
||||||
|
Command::CreateElement(e) => {
|
||||||
|
working.elements.insert(e.id, e.clone());
|
||||||
|
}
|
||||||
|
Command::CreateRelation(r) => {
|
||||||
|
working.relations.insert(r.id, r.clone());
|
||||||
|
}
|
||||||
|
_ => unreachable!("functional creation only creates objects"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan.commands.extend(commands);
|
||||||
|
}
|
||||||
|
Err(err) => conflict(
|
||||||
|
&mut plan,
|
||||||
|
Some(*source),
|
||||||
|
Some(target),
|
||||||
|
None,
|
||||||
|
format!("Functional endpoint creation: {err}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
working.relations.insert(target, created.clone());
|
working.relations.insert(target, created.clone());
|
||||||
plan.commands.push(Command::CreateRelation(created));
|
plan.commands.push(Command::CreateRelation(created));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for source in &selected {
|
for source in &selected {
|
||||||
let Some(target) = targets.get(source).copied() else {
|
let Some(target) = targets.get(source).copied() else {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -450,21 +450,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SharedBody::Port { interface, .. } => {
|
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
|
||||||
if let Some(id) = interface {
|
|
||||||
expect_element(p, &mut out, e.id, *id, "interface", |k| {
|
|
||||||
k == Kind::Interface
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
warning(
|
|
||||||
&mut out,
|
|
||||||
e.id,
|
|
||||||
"missing_interface",
|
|
||||||
"interface",
|
|
||||||
"Choose a port interface or add an explicit provision/requirement contract.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SharedBody::Scenario {
|
SharedBody::Scenario {
|
||||||
viewpoint,
|
viewpoint,
|
||||||
context,
|
context,
|
||||||
@@ -2140,82 +2126,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
|||||||
for item in r.body.items() {
|
for item in r.body.items() {
|
||||||
expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem);
|
expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem);
|
||||||
}
|
}
|
||||||
if let RelationBody::FunctionalExchange {
|
crate::ports::validate_port_relation(p, r, out);
|
||||||
source_port,
|
|
||||||
target_port,
|
|
||||||
..
|
|
||||||
} = &r.body
|
|
||||||
{
|
|
||||||
for (port, owner, outgoing, field) in [
|
|
||||||
(*source_port, source, true, "source_port"),
|
|
||||||
(*target_port, target, false, "target_port"),
|
|
||||||
] {
|
|
||||||
if let Some(port) = port {
|
|
||||||
match p.elements.get(&port) {
|
|
||||||
Some(e)
|
|
||||||
if e.parent == Some(owner)
|
|
||||||
&& matches!(e.shared(),Some(SharedBody::Port{direction,..})if if outgoing{*direction!=PortDirection::In}else{*direction!=PortDirection::Out}) =>
|
|
||||||
{}
|
|
||||||
_ => error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"functional_port",
|
|
||||||
field,
|
|
||||||
"Functional port must belong to the endpoint function with compatible direction.",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.kind() == RelationKind::BoundaryExchange {
|
|
||||||
for (e, outgoing, field) in [(a, true, "source"), (b, false, "target")] {
|
|
||||||
match e.shared() {
|
|
||||||
Some(SharedBody::Port { direction, .. })
|
|
||||||
if e.parent
|
|
||||||
.and_then(|id| p.elements.get(&id))
|
|
||||||
.is_some_and(|e| e.kind().is_performer())
|
|
||||||
&& (if outgoing {
|
|
||||||
*direction != PortDirection::In
|
|
||||||
} else {
|
|
||||||
*direction != PortDirection::Out
|
|
||||||
}) => {}
|
|
||||||
_ => error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"boundary_port",
|
|
||||||
field,
|
|
||||||
"Boundary exchange uses participant ports with output/input compatible direction.",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let interface_items = |port: &Element| {
|
|
||||||
if let Some(SharedBody::Port {
|
|
||||||
interface: Some(id),
|
|
||||||
..
|
|
||||||
}) = port.shared()
|
|
||||||
&& let Some(SharedBody::Interface { items }) =
|
|
||||||
p.elements.get(id).and_then(Element::shared)
|
|
||||||
{
|
|
||||||
return Some(items);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
};
|
|
||||||
for port in [a, b] {
|
|
||||||
if let Some(items) = interface_items(port) {
|
|
||||||
for item in r.body.items() {
|
|
||||||
if !items.contains(item) {
|
|
||||||
error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"interface_contract",
|
|
||||||
"items",
|
|
||||||
"Exchange item is not included in a referenced port interface.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let RelationBody::StateTransition {
|
if let RelationBody::StateTransition {
|
||||||
trigger,
|
trigger,
|
||||||
guard,
|
guard,
|
||||||
@@ -2417,51 +2328,7 @@ pub fn allocation_summary(p: &Project, work: Id) -> Option<Id> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||||
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
|
crate::ports::validate_exchange_allocation(p, r, out);
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for (function, port, field) in [
|
|
||||||
(work.source(), boundary.source(), "source"),
|
|
||||||
(work.target(), boundary.target(), "target"),
|
|
||||||
] {
|
|
||||||
let performer = p
|
|
||||||
.relations
|
|
||||||
.values()
|
|
||||||
.find(|a| a.kind() == RelationKind::Allocation && a.target() == function)
|
|
||||||
.map(Relation::source);
|
|
||||||
let owner = p.elements.get(&port).and_then(|e| e.parent);
|
|
||||||
if let (Some(performer), Some(owner)) = (performer, owner) {
|
|
||||||
if performer != owner {
|
|
||||||
error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"exchange_allocation_performer",
|
|
||||||
field,
|
|
||||||
"Boundary port owner must perform the corresponding work-exchange endpoint.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warning(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"incomplete_exchange_allocation",
|
|
||||||
field,
|
|
||||||
"Allocate work endpoints to the boundary-port participants.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for item in work.body.items() {
|
|
||||||
if !boundary.body.items().contains(item) {
|
|
||||||
error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"exchange_allocation_items",
|
|
||||||
"items",
|
|
||||||
"The boundary exchange must carry every item of the allocated work exchange.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_performer(p: &Project, work: Id) -> Option<Id> {
|
pub fn effective_performer(p: &Project, work: Id) -> Option<Id> {
|
||||||
@@ -2534,59 +2401,6 @@ fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
|||||||
"A boundary exchange connects different participants.",
|
"A boundary exchange connects different participants.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let ports: Vec<_> = match &r.body {
|
|
||||||
RelationBody::BoundaryExchange { source, target, .. } => vec![*source, *target],
|
|
||||||
RelationBody::FunctionalExchange {
|
|
||||||
source_port,
|
|
||||||
target_port,
|
|
||||||
..
|
|
||||||
} => [*source_port, *target_port].into_iter().flatten().collect(),
|
|
||||||
_ => vec![],
|
|
||||||
};
|
|
||||||
for port in ports {
|
|
||||||
let mut interfaces = BTreeSet::new();
|
|
||||||
if let Some(SharedBody::Port {
|
|
||||||
interface: Some(id),
|
|
||||||
..
|
|
||||||
}) = p.elements.get(&port).and_then(Element::shared)
|
|
||||||
{
|
|
||||||
interfaces.insert(*id);
|
|
||||||
}
|
|
||||||
interfaces.extend(
|
|
||||||
p.relations
|
|
||||||
.values()
|
|
||||||
.filter(|rel| {
|
|
||||||
matches!(
|
|
||||||
rel.kind(),
|
|
||||||
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
|
||||||
) && rel.source() == port
|
|
||||||
})
|
|
||||||
.map(Relation::target),
|
|
||||||
);
|
|
||||||
if !interfaces.is_empty() {
|
|
||||||
let allowed: BTreeSet<_> = interfaces
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| p.elements.get(id))
|
|
||||||
.filter_map(|e| match e.shared() {
|
|
||||||
Some(SharedBody::Interface { items }) => Some(items),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.flatten()
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
for item in r.body.items() {
|
|
||||||
if !allowed.contains(item) {
|
|
||||||
error(
|
|
||||||
out,
|
|
||||||
r.id,
|
|
||||||
"port_interface_items",
|
|
||||||
"items",
|
|
||||||
"The exchange carries an item outside a port's declared interface contract.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matches!(
|
if matches!(
|
||||||
r.kind(),
|
r.kind(),
|
||||||
RelationKind::Availability | RelationKind::StateAction
|
RelationKind::Availability | RelationKind::StateAction
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
use oa_core::{projection::*, transitions::*, validation, *};
|
||||||
|
|
||||||
|
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||||
|
p.add_element(kind, layer, name, None).unwrap()
|
||||||
|
}
|
||||||
|
fn request(p: &Project, source: Id, target: Id, items: Vec<Id>) -> FunctionalExchangeRequest {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
FunctionalExchangeRequest {
|
||||||
|
id,
|
||||||
|
alias: p.fresh_alias("message", "exchange", id),
|
||||||
|
name: "Message".into(),
|
||||||
|
description: "Engineering rationale".into(),
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
source_port: PortChoice::Auto,
|
||||||
|
target_port: PortChoice::Auto,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||||
|
let mut p = Project::empty();
|
||||||
|
let a = add(&mut p, Kind::Function, Layer::Sa, "Send");
|
||||||
|
let b = add(&mut p, Kind::Function, Layer::Sa, "Receive");
|
||||||
|
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Information");
|
||||||
|
let request = request(&p, a, b, vec![item]);
|
||||||
|
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
|
||||||
|
(p, a, b, item, request.id)
|
||||||
|
}
|
||||||
|
fn endpoints(p: &Project, id: Id) -> (Id, Id) {
|
||||||
|
match p.relations[&id].body {
|
||||||
|
RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} => (source_port, target_port),
|
||||||
|
_ => panic!("not a functional exchange"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn allocate(p: &mut Project, a: Id, b: Id) -> (Id, Id) {
|
||||||
|
let first = add(p, Kind::System, Layer::Sa, "System");
|
||||||
|
let second = add(p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||||
|
p.relate(RelationKind::Allocation, "Send allocation", first, a);
|
||||||
|
p.relate(RelationKind::Allocation, "Receive allocation", second, b);
|
||||||
|
(first, second)
|
||||||
|
}
|
||||||
|
fn component_request(p: &Project, fe: Id, kind: ComponentPortKind) -> ComponentExchangeRequest {
|
||||||
|
let id = Id::new_v4();
|
||||||
|
ComponentExchangeRequest {
|
||||||
|
functional_exchange: fe,
|
||||||
|
boundary: ComponentExchangeChoice::Create {
|
||||||
|
id,
|
||||||
|
alias: p.fresh_alias("boundary", "exchange", id),
|
||||||
|
name: "Boundary".into(),
|
||||||
|
description: "Physical realization".into(),
|
||||||
|
port_kind: kind,
|
||||||
|
oriented: kind == ComponentPortKind::Flow,
|
||||||
|
source_port: PortChoice::Auto,
|
||||||
|
target_port: PortChoice::Auto,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn boundary_id(request: &ComponentExchangeRequest) -> Id {
|
||||||
|
match request.boundary {
|
||||||
|
ComponentExchangeChoice::Create { id, .. } | ComponentExchangeChoice::Reuse(id) => id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn code(p: &Project, expected: &str) -> bool {
|
||||||
|
validation::validate(p)
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.code == expected && issue.severity == validation::Severity::Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn functional_creation_reuse_and_item_sync_are_atomic_and_identity_safe() {
|
||||||
|
let (p, a, b, item, fe) = fixture();
|
||||||
|
let (output, input) = endpoints(&p, fe);
|
||||||
|
assert!(
|
||||||
|
matches!(port_contract(&p.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[item])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(port_contract(&p.elements[&input]), Some(PortContract::FunctionInput { incoming_items }) if incoming_items == &[item])
|
||||||
|
);
|
||||||
|
let next = request(&p, a, b, vec![item]);
|
||||||
|
let commands = plan_functional_exchange(&p, &next).unwrap();
|
||||||
|
assert_eq!(commands.len(), 1);
|
||||||
|
let mut session = Session::new(p.clone());
|
||||||
|
session.execute(Command::Batch(commands)).unwrap();
|
||||||
|
assert_eq!(endpoints(session.project(), next.id), (output, input));
|
||||||
|
assert_eq!(session.revision(), 1);
|
||||||
|
session.undo();
|
||||||
|
assert_eq!(session.project(), &p);
|
||||||
|
let mut wrong = request(&p, a, b, vec![item]);
|
||||||
|
wrong.source_port = PortChoice::Reuse(input);
|
||||||
|
assert!(plan_functional_exchange(&p, &wrong).is_err());
|
||||||
|
assert!(plan_function_port_items(&p, output, vec![]).is_err());
|
||||||
|
assert_eq!(session.project(), &p);
|
||||||
|
assert!(ElementBody::new(Kind::Port, Layer::Sa).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambiguous_reuse_creates_a_fresh_port_and_collisions_never_overwrite() {
|
||||||
|
let (mut p, a, b, item, _) = fixture();
|
||||||
|
let other = Id::new_v4();
|
||||||
|
p.elements.insert(
|
||||||
|
other,
|
||||||
|
make_port(
|
||||||
|
&p,
|
||||||
|
other,
|
||||||
|
a,
|
||||||
|
"Alternative output",
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![item],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let req = request(&p, a, b, vec![item]);
|
||||||
|
let expected = Id::new_v5(&req.id, b"function-source-port");
|
||||||
|
let commands = plan_functional_exchange(&p, &req).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&commands).unwrap(),
|
||||||
|
serde_json::to_value(plan_functional_exchange(&p, &req).unwrap()).unwrap()
|
||||||
|
);
|
||||||
|
let result = execute_batch(&p, &commands).unwrap();
|
||||||
|
assert_eq!(endpoints(&result, req.id).0, expected);
|
||||||
|
let occupied = p.elements[&other].clone();
|
||||||
|
let mut blocker = occupied.clone();
|
||||||
|
blocker.id = expected;
|
||||||
|
blocker.alias = "block_generated_identity".into();
|
||||||
|
p.elements.insert(expected, blocker);
|
||||||
|
assert!(
|
||||||
|
plan_functional_exchange(&p, &req)
|
||||||
|
.unwrap_err()
|
||||||
|
.0
|
||||||
|
.contains("port_id_collision")
|
||||||
|
);
|
||||||
|
assert_eq!(p.elements[&other], occupied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_generation_requires_real_allocations_and_reuses_existing_facts() {
|
||||||
|
let (mut p, a, b, _, fe) = fixture();
|
||||||
|
let initial = component_request(&p, fe, ComponentPortKind::Flow);
|
||||||
|
assert!(
|
||||||
|
plan_component_exchange(&p, &initial)
|
||||||
|
.unwrap_err()
|
||||||
|
.0
|
||||||
|
.contains("port_allocation_owner")
|
||||||
|
);
|
||||||
|
allocate(&mut p, a, b);
|
||||||
|
let req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||||
|
let boundary = boundary_id(&req);
|
||||||
|
let result = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::PortAllocation)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let again = ComponentExchangeRequest {
|
||||||
|
functional_exchange: fe,
|
||||||
|
boundary: ComponentExchangeChoice::Reuse(boundary),
|
||||||
|
};
|
||||||
|
assert!(plan_component_exchange(&result, &again).unwrap().is_empty());
|
||||||
|
let mut wrong = result.clone();
|
||||||
|
let source = wrong.relations[&boundary].source();
|
||||||
|
if let Some(SharedBody::Port { contract, .. }) =
|
||||||
|
wrong.elements.get_mut(&source).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
*contract = PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::In,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
assert!(code(&wrong, "boundary_port"));
|
||||||
|
assert!(code(&wrong, "port_allocation_contract"));
|
||||||
|
let mut session = Session::new(p.clone());
|
||||||
|
session
|
||||||
|
.execute(Command::Batch(plan_component_exchange(&p, &req).unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
session.undo();
|
||||||
|
assert_eq!(session.project(), &p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_rejected() {
|
||||||
|
let (mut p, a, b, item, fe) = fixture();
|
||||||
|
allocate(&mut p, a, b);
|
||||||
|
let req = component_request(&p, fe, ComponentPortKind::Standard);
|
||||||
|
let boundary = boundary_id(&req);
|
||||||
|
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||||
|
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||||
|
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&interface)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||||
|
let requirement = p.relate(
|
||||||
|
RelationKind::InterfaceRequirement,
|
||||||
|
"Caller requires service",
|
||||||
|
source,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
let provider = p.relate(
|
||||||
|
RelationKind::InterfaceProvision,
|
||||||
|
"Callee provides service",
|
||||||
|
target,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
validate_candidate(&p).unwrap();
|
||||||
|
assert!(
|
||||||
|
!validation::validate(&p)
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.code == "unsatisfied_interface_requirement")
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
p.relations[&boundary].body,
|
||||||
|
RelationBody::BoundaryExchange {
|
||||||
|
oriented: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let without_provider = execute_batch(&p, &[Command::Delete(provider)]).unwrap();
|
||||||
|
assert!(
|
||||||
|
validation::validate(&without_provider).iter().any(
|
||||||
|
|i| i.element == Some(requirement) && i.code == "unsatisfied_interface_requirement"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if let Some(SharedBody::Port { contract, .. }) =
|
||||||
|
p.elements.get_mut(&source).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
*contract = PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: ComponentPortOrientation::Out,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
assert!(code(&p, "mixed_component_ports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_and_trace() {
|
||||||
|
let (mut p, a, b, item, forward) = fixture();
|
||||||
|
let (old, _) = endpoints(&p, forward);
|
||||||
|
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&interface)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||||
|
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
|
||||||
|
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: Some(interface),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let reverse_req = request(&p, b, a, vec![item]);
|
||||||
|
p = execute_batch(&p, &plan_functional_exchange(&p, &reverse_req).unwrap()).unwrap();
|
||||||
|
if let RelationBody::FunctionalExchange { target_port, .. } =
|
||||||
|
&mut p.relations.get_mut(&reverse_req.id).unwrap().body
|
||||||
|
{
|
||||||
|
*target_port = old;
|
||||||
|
}
|
||||||
|
let artifact = Id::new_v4();
|
||||||
|
p.artifact_links.insert(
|
||||||
|
artifact,
|
||||||
|
ArtifactLink {
|
||||||
|
id: artifact,
|
||||||
|
alias: "rationale".into(),
|
||||||
|
name: "Evidence".into(),
|
||||||
|
description: "Attached original evidence".into(),
|
||||||
|
owner: old,
|
||||||
|
relation: ArtifactRelation::References,
|
||||||
|
uri: "docs/evidence.md".into(),
|
||||||
|
revision: Some("v2".into()),
|
||||||
|
fragment: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let constraint = add(&mut p, Kind::Constraint, Layer::Sa, "Port requirement");
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&constraint)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::Constraint {
|
||||||
|
expression: "original meaning".into(),
|
||||||
|
constrained: vec![old],
|
||||||
|
};
|
||||||
|
let oa_work = add(&mut p, Kind::Activity, Layer::Oa, "Original work");
|
||||||
|
let oa_port = Id::new_v4();
|
||||||
|
let mut legacy_oa = p.elements[&old].clone();
|
||||||
|
legacy_oa.id = oa_port;
|
||||||
|
legacy_oa.alias = "original_oa_port".into();
|
||||||
|
legacy_oa.parent = Some(oa_work);
|
||||||
|
legacy_oa.body = ElementBody::shared_in(
|
||||||
|
Layer::Oa,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
p.elements.insert(oa_port, legacy_oa);
|
||||||
|
let trace = Id::new_v4();
|
||||||
|
p.traces.insert(
|
||||||
|
trace,
|
||||||
|
Trace {
|
||||||
|
id: trace,
|
||||||
|
alias: "port_trace".into(),
|
||||||
|
name: "Original correspondence".into(),
|
||||||
|
description: String::new(),
|
||||||
|
oa: oa_port,
|
||||||
|
sa: old,
|
||||||
|
kind: TraceKind::Item,
|
||||||
|
baseline: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
validate_candidate(&p).unwrap();
|
||||||
|
let req = LegacyWorkPortSplitRequest {
|
||||||
|
port: old,
|
||||||
|
primary: FunctionPortSide::Input,
|
||||||
|
incoming_items: vec![item],
|
||||||
|
outgoing_items: vec![item],
|
||||||
|
};
|
||||||
|
let commands = plan_legacy_work_port_split(&p, &req).unwrap();
|
||||||
|
let mut session = Session::new(p.clone());
|
||||||
|
session.execute(Command::Batch(commands)).unwrap();
|
||||||
|
let result = session.project();
|
||||||
|
let opposite = Id::new_v5(&old, b"split-opposite-port");
|
||||||
|
assert_eq!(endpoints(result, forward).0, opposite);
|
||||||
|
assert_eq!(endpoints(result, reverse_req.id).1, old);
|
||||||
|
assert_eq!(
|
||||||
|
result.elements[&old].description,
|
||||||
|
p.elements[&old].description
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(result.elements[&old].shared(), Some(SharedBody::Port { contract: PortContract::FunctionInput { .. }, legacy: Some(LegacyPortContract { direction: PortDirection::InOut, interface: Some(id) }) }) if *id == interface)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.artifact_links[&artifact],
|
||||||
|
p.artifact_links[&artifact]
|
||||||
|
);
|
||||||
|
assert_eq!(result.traces[&trace], p.traces[&trace]);
|
||||||
|
assert_eq!(result.elements[&constraint], p.elements[&constraint]);
|
||||||
|
session.undo();
|
||||||
|
assert_eq!(session.project(), &p);
|
||||||
|
assert!(
|
||||||
|
plan_legacy_port_resolution(
|
||||||
|
&p,
|
||||||
|
&LegacyPortResolution {
|
||||||
|
port: oa_port,
|
||||||
|
contract: PortContract::FunctionInput {
|
||||||
|
incoming_items: vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletion_preserves_independent_ports_but_removes_required_dependents() {
|
||||||
|
let (mut p, a, b, _, fe) = fixture();
|
||||||
|
allocate(&mut p, a, b);
|
||||||
|
let req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||||
|
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||||
|
let (output, input) = endpoints(&p, fe);
|
||||||
|
let allocation = p
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.find(|r| r.kind() == RelationKind::PortAllocation)
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let removed_allocation = execute_batch(&p, &[Command::Delete(allocation)]).unwrap();
|
||||||
|
assert!(removed_allocation.elements.contains_key(&output));
|
||||||
|
assert!(
|
||||||
|
removed_allocation
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.all(|r| r.kind() != RelationKind::ExchangeAllocation)
|
||||||
|
);
|
||||||
|
let removed_fe = execute_batch(&p, &[Command::Delete(fe)]).unwrap();
|
||||||
|
assert!(removed_fe.elements.contains_key(&output) && removed_fe.elements.contains_key(&input));
|
||||||
|
let removed_port = execute_batch(&p, &[Command::Delete(output)]).unwrap();
|
||||||
|
assert!(!removed_port.relations.contains_key(&fe));
|
||||||
|
assert!(removed_port.elements.contains_key(&input));
|
||||||
|
let new = Id::new_v4();
|
||||||
|
let changed = execute_batch(&p, &[Command::Reidentify(output, new)]).unwrap();
|
||||||
|
assert_eq!(endpoints(&changed, fe).0, new);
|
||||||
|
assert!(
|
||||||
|
changed
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.any(|r| r.kind() == RelationKind::PortAllocation && r.target() == new)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity() {
|
||||||
|
let (mut p, _, _, item, fe) = fixture();
|
||||||
|
let output = endpoints(&p, fe).0;
|
||||||
|
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&interface)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||||
|
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::Out,
|
||||||
|
interface: Some(interface),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let role = p.relate(
|
||||||
|
RelationKind::InterfaceRequirement,
|
||||||
|
"Original unreviewed role",
|
||||||
|
output,
|
||||||
|
interface,
|
||||||
|
);
|
||||||
|
let req = LegacyPortResolution {
|
||||||
|
port: output,
|
||||||
|
contract: PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![item],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = execute_batch(&p, &plan_legacy_port_resolution(&p, &req).unwrap()).unwrap();
|
||||||
|
assert_eq!(result.relations[&role], p.relations[&role]);
|
||||||
|
assert_eq!(result.elements[&output].alias, p.elements[&output].alias);
|
||||||
|
assert!(
|
||||||
|
validation::validate(&result)
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.element == Some(role) && issue.code == "legacy_interface_role")
|
||||||
|
);
|
||||||
|
let replacement = Id::new_v4();
|
||||||
|
let remapped = execute_batch(&result, &[Command::Reidentify(interface, replacement)]).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(remapped.elements[&output].shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { interface: Some(id), .. }), .. }) if *id == replacement)
|
||||||
|
);
|
||||||
|
let removed = execute_batch(&remapped, &[Command::Delete(replacement)]).unwrap();
|
||||||
|
assert!(removed.elements.contains_key(&item));
|
||||||
|
assert!(!removed.relations.contains_key(&role));
|
||||||
|
assert!(
|
||||||
|
matches!(removed.elements[&output].shared(), Some(SharedBody::Port { contract: PortContract::FunctionOutput { outgoing_items }, legacy: Some(LegacyPortContract { interface: None, .. }) }) if outgoing_items == &[item])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
|
||||||
|
let mut oa = Project::empty();
|
||||||
|
let a = add(&mut oa, Kind::Activity, Layer::Oa, "Send");
|
||||||
|
let b = add(&mut oa, Kind::Activity, Layer::Oa, "Receive");
|
||||||
|
let interaction = oa.relate(RelationKind::Interaction, "Operational exchange", a, b);
|
||||||
|
let mut req = TransitionRequest::new(TransitionDirection::OaToSa, vec![interaction]);
|
||||||
|
req.scope = DependencyScope::Required;
|
||||||
|
let plan = plan_transition(&oa, &req).unwrap();
|
||||||
|
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||||
|
let sa = apply_transition(&oa, &plan).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sa.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Sa)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sa.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let (mut p, a, b, _, fe) = fixture();
|
||||||
|
allocate(&mut p, a, b);
|
||||||
|
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||||
|
let boundary = boundary_id(&component_req);
|
||||||
|
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
|
||||||
|
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
|
||||||
|
reverse.scope = DependencyScope::Required;
|
||||||
|
let plan = plan_transition(&p, &reverse).unwrap();
|
||||||
|
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||||
|
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
|
||||||
|
let result = apply_transition(&p, &plan).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.any(|r| r.kind() == RelationKind::Interaction)
|
||||||
|
);
|
||||||
|
for e in p.elements.values() {
|
||||||
|
assert_eq!(result.elements[&e.id], *e);
|
||||||
|
}
|
||||||
|
for r in p.relations.values() {
|
||||||
|
assert_eq!(result.relations[&r.id], *r);
|
||||||
|
}
|
||||||
|
// A required FE dependency is portless in OA even while an SA endpoint
|
||||||
|
// retains unresolved legacy semantics. No incidental legacy copy is made.
|
||||||
|
let (mut legacy, _, _, _, legacy_fe) = fixture();
|
||||||
|
let legacy_port = endpoints(&legacy, legacy_fe).0;
|
||||||
|
*legacy
|
||||||
|
.elements
|
||||||
|
.get_mut(&legacy_port)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let mut req = TransitionRequest::new(TransitionDirection::SaToOa, vec![legacy_fe]);
|
||||||
|
req.scope = DependencyScope::Required;
|
||||||
|
let plan = plan_transition(&legacy, &req).unwrap();
|
||||||
|
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||||
|
let result = apply_transition(&legacy, &plan).unwrap();
|
||||||
|
assert!(
|
||||||
|
!result
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.any(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_identity() {
|
||||||
|
let (mut p, a, b, _, fe) = fixture();
|
||||||
|
let (system, actor) = allocate(&mut p, a, b);
|
||||||
|
let req = component_request(&p, fe, ComponentPortKind::Standard);
|
||||||
|
let boundary = boundary_id(&req);
|
||||||
|
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||||
|
let diagram = p.ensure_diagram(Layer::Sa);
|
||||||
|
for (index, id) in [system, actor, a, b].into_iter().enumerate() {
|
||||||
|
p.place(
|
||||||
|
diagram,
|
||||||
|
id,
|
||||||
|
Position {
|
||||||
|
x: 50. + index as f32 * 280.,
|
||||||
|
y: 100.,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let view = project_view(&p, diagram);
|
||||||
|
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||||
|
assert!(!edge.oriented);
|
||||||
|
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||||
|
assert!(
|
||||||
|
view.ports
|
||||||
|
.iter()
|
||||||
|
.filter(|port| [
|
||||||
|
p.relations[&boundary].source(),
|
||||||
|
p.relations[&boundary].target()
|
||||||
|
]
|
||||||
|
.contains(&port.element))
|
||||||
|
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||||
|
);
|
||||||
|
let (output, input) = endpoints(&p, fe);
|
||||||
|
assert_eq!(
|
||||||
|
view.ports
|
||||||
|
.iter()
|
||||||
|
.find(|port| port.element == output)
|
||||||
|
.unwrap()
|
||||||
|
.side,
|
||||||
|
PortAnchorSide::Right
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.ports
|
||||||
|
.iter()
|
||||||
|
.find(|port| port.element == input)
|
||||||
|
.unwrap()
|
||||||
|
.side,
|
||||||
|
PortAnchorSide::Left
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
p.diagrams[&diagram]
|
||||||
|
.nodes
|
||||||
|
.values()
|
||||||
|
.all(|node| p.elements[&node.element].kind() != Kind::Port)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,6 +122,26 @@ fn fixture(layer: Layer) -> Fixture {
|
|||||||
| RelationBody::FunctionalExchange { items, .. } => items.push(item),
|
| RelationBody::FunctionalExchange { items, .. } => items.push(item),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
|
if let RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} = p.relations[&exchange].body
|
||||||
|
{
|
||||||
|
for port in [source_port, target_port] {
|
||||||
|
match p.elements.get_mut(&port).unwrap().shared_mut() {
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::FunctionInput { incoming_items },
|
||||||
|
..
|
||||||
|
}) => incoming_items.push(item),
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::FunctionOutput { outgoing_items },
|
||||||
|
..
|
||||||
|
}) => outgoing_items.push(item),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let call = add_occurrence(
|
let call = add_occurrence(
|
||||||
&mut p,
|
&mut p,
|
||||||
scenario,
|
scenario,
|
||||||
@@ -1010,9 +1030,27 @@ fn role_lifelines_require_explicit_work_assignment_not_just_a_shared_realizing_e
|
|||||||
validation::semantic_representation_compatible(&p, target, work),
|
validation::semantic_representation_compatible(&p, target, work),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
let port = p
|
let port = Id::new_v4();
|
||||||
.add_element(Kind::Port, Layer::Oa, "Controller port", Some(entity))
|
p.elements.insert(
|
||||||
.unwrap();
|
port,
|
||||||
|
Element {
|
||||||
|
id: port,
|
||||||
|
alias: "legacy_controller_port".into(),
|
||||||
|
name: "Controller port".into(),
|
||||||
|
description: String::new(),
|
||||||
|
parent: Some(entity),
|
||||||
|
body: ElementBody::shared_in(
|
||||||
|
Layer::Oa,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validation::semantic_representation_compatible(&p, entity, port),
|
validation::semantic_representation_compatible(&p, entity, port),
|
||||||
Some(true)
|
Some(true)
|
||||||
|
|||||||
@@ -334,11 +334,29 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
|||||||
.shared_mut()
|
.shared_mut()
|
||||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
|
let id = Id::new_v4();
|
||||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
let contract = if p.elements[&parent].kind() == Kind::Function {
|
||||||
direction,
|
if direction == PortDirection::Out {
|
||||||
interface: Some(interface),
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![item],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::FunctionInput {
|
||||||
|
incoming_items: vec![item],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Flow,
|
||||||
|
orientation: if direction == PortDirection::Out {
|
||||||
|
ComponentPortOrientation::Out
|
||||||
|
} else {
|
||||||
|
ComponentPortOrientation::In
|
||||||
|
},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
p.elements
|
||||||
|
.insert(id, make_port(p, id, parent, name, contract).unwrap());
|
||||||
id
|
id
|
||||||
};
|
};
|
||||||
let out = port(&mut p, "System output", system, PortDirection::Out);
|
let out = port(&mut p, "System output", system, PortDirection::Out);
|
||||||
@@ -355,10 +373,14 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
|||||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||||
source: f1,
|
source: f1,
|
||||||
target: f2,
|
target: f2,
|
||||||
source_port: Some(fp1),
|
source_port: fp1,
|
||||||
target_port: Some(fp2),
|
target_port: fp2,
|
||||||
items: vec![item],
|
items: vec![item],
|
||||||
};
|
};
|
||||||
|
p.relate(RelationKind::Allocation, "Send allocation", system, f1);
|
||||||
|
p.relate(RelationKind::Allocation, "Receive allocation", actor, f2);
|
||||||
|
p.relate(RelationKind::PortAllocation, "Output allocation", out, fp1);
|
||||||
|
p.relate(RelationKind::PortAllocation, "Input allocation", input, fp2);
|
||||||
p.relate(
|
p.relate(
|
||||||
RelationKind::ExchangeAllocation,
|
RelationKind::ExchangeAllocation,
|
||||||
"Carried on",
|
"Carried on",
|
||||||
@@ -381,7 +403,7 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
|||||||
if let RelationBody::FunctionalExchange { source_port, .. } =
|
if let RelationBody::FunctionalExchange { source_port, .. } =
|
||||||
&mut p.relations.get_mut(&exchange).unwrap().body
|
&mut p.relations.get_mut(&exchange).unwrap().body
|
||||||
{
|
{
|
||||||
*source_port = Some(input);
|
*source_port = input;
|
||||||
}
|
}
|
||||||
rejected(&p, "functional_port");
|
rejected(&p, "functional_port");
|
||||||
}
|
}
|
||||||
@@ -721,15 +743,36 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
|||||||
};
|
};
|
||||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||||
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
|
let port = Id::new_v4();
|
||||||
|
p.elements.insert(
|
||||||
|
port,
|
||||||
|
make_port(
|
||||||
|
&p,
|
||||||
|
port,
|
||||||
|
function,
|
||||||
|
"Output",
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![item],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
|
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
|
||||||
if let RelationBody::FunctionalExchange {
|
if let RelationBody::FunctionalExchange {
|
||||||
source_port, items, ..
|
source_port, items, ..
|
||||||
} = &mut p.relations.get_mut(&rel).unwrap().body
|
} = &mut p.relations.get_mut(&rel).unwrap().body
|
||||||
{
|
{
|
||||||
*source_port = Some(port);
|
*source_port = port;
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
if let RelationBody::FunctionalExchange { target_port, .. } = p.relations[&rel].body
|
||||||
|
&& let Some(SharedBody::Port {
|
||||||
|
contract: PortContract::FunctionInput { incoming_items },
|
||||||
|
..
|
||||||
|
}) = p.elements.get_mut(&target_port).unwrap().shared_mut()
|
||||||
|
{
|
||||||
|
incoming_items.push(item);
|
||||||
|
}
|
||||||
let dtype_new = Id::new_v4();
|
let dtype_new = Id::new_v4();
|
||||||
let port_new = Id::new_v4();
|
let port_new = Id::new_v4();
|
||||||
let result = execute_batch(
|
let result = execute_batch(
|
||||||
@@ -744,7 +787,7 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
|||||||
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
|
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
|
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:id,..}if *id==port_new)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Typed port core milestone handoff
|
||||||
|
|
||||||
|
Implemented in `D:/CodexWorktrees/Capella2-typed-ports`, branch `codex/typed-port-contracts`, based on `c20a207`. This is the core portion of [the adopted port contract](release-port-contract.md). It must be integrated with the migration/source worker's companion changes; desktop/native acceptance remains the root task's work.
|
||||||
|
|
||||||
|
## Delivered behavior
|
||||||
|
|
||||||
|
- Schema 3 persists `PortContract::{FunctionInput, FunctionOutput, Component, Legacy}`, preserved `LegacyPortContract`, Standard/Flow component kind and separate orientation, mandatory FE port UUIDs, `BoundaryExchange.oriented`, and identified `PortAllocation` relations. Legacy association data is preserved through typed resolution and remains reference-aware without becoming an invented provided/required declaration.
|
||||||
|
- Typed function/component ownership, direct item declarations, empty-contract handling, actual work allocation, endpoint direction, same-layer references, unique PortAllocation pairs, explicit interface roles, and exchange/port allocation correspondence are validated in `ports.rs` through the normal candidate validator. Standard-to-Flow component exchanges are structural errors, reflecting the root's later native observation. Legacy compatibility remains explicitly warned.
|
||||||
|
- Public `plan_functional_exchange`, `plan_component_exchange`, `plan_legacy_port_resolution`, and `plan_legacy_work_port_split` implement the contract request types and return concrete existing commands. Public `plan_function_port_items` supplies explicit item synchronization. Planners validate complete candidate batches; intermediate generation does not modify the original project.
|
||||||
|
- FE creation reuses exactly one compatible typed endpoint or creates deterministic new endpoints. Explicit incompatible reuse fails; ambiguous Auto creates a fresh port. Component generation creates/reuses both component ports, two PortAllocations, the boundary exchange, and ExchangeAllocation together. It requires actual work allocations and never invents interfaces or provider/requirement roles. Repeated generation with an existing boundary reuses facts and identities.
|
||||||
|
- Split preserves the original legacy record, primary UUID/alias/prose, artifacts, constraints, and traces; it creates one opposite typed port and rewires source/target FE references. Resolution retains original interface metadata and legacy role identities. API/source can represent preserved legacy roles on resolved ports, with clear warnings; normal typed function ports use item contracts.
|
||||||
|
- Reidentify visits typed item declarations, required FE endpoints, legacy interface associations, and allocation relations. Deleting a function port cascades incident FEs; deleting an FE retains independent ports. Deleting a work Allocation cascades dependent PortAllocations, and deleting a required PortAllocation cascades its dependent ExchangeAllocation. Interface deletion clears the preserved optional association and removes role relations while retaining items/ports.
|
||||||
|
- OA-to-SA Interaction creation creates typed SA endpoints through the FE builder. SA-to-OA FE mapping remains portless, and Required scope does not copy even Legacy SA endpoint ports solely because an FE used them. Typed SA ports, boundary implementation, and dependent role/allocation records have explicit omission warnings. Original SA objects remain intact. Existing counterpart updates retain their endpoint identities.
|
||||||
|
- The demo now uses typed ports, explicit function/component port allocations, and port-level interface roles. Projection anchors derive from visible owners without requiring port placements; orientation is independent of anchor side and actual exchange endpoint IDs are retained.
|
||||||
|
|
||||||
|
## Integration APIs and changed rendering fields
|
||||||
|
|
||||||
|
All port helpers and request types are re-exported through `oa_core::*`; the bounded implementation module is `oa_core::ports`.
|
||||||
|
|
||||||
|
- `make_port(project, id, owner, name, contract) -> Result<Element, ModelError>` builds an explicit port element for a caller's transaction. It does not adopt a model or independently validate unrelated candidate state.
|
||||||
|
- `ElementBody::new(Kind::Port, ..)` now returns None: contextual authoring must choose a typed contract and owner. Do not restore a generic InOut default to make old forms compile.
|
||||||
|
- `RelationBody::new(FunctionalExchange, ..)` is an explicitly unbound draft with nil endpoint IDs; it cannot pass candidate validation. Normal GUI/API authoring should use the public planner or a concrete complete Batch. `Command::Connect(FunctionalExchange, ..)` and `Project::relate(FunctionalExchange, ..)` now create/reuse real endpoint ports. The latter remains the existing unchecked Project builder; Session validates its resulting transaction.
|
||||||
|
- `ProjectedPort.direction` is replaced by `side: PortAnchorSide::{Left, Right, Bottom}`. `projection::port_anchor_side(&Element)` is public. Glyph semantics must inspect the element's actual PortContract; Standard/Unset and InOut Flow are not interchangeable.
|
||||||
|
- `ProjectedEdge` adds `oriented: bool`; BoundaryExchange uses its persisted value and other edges use true. The root's newer main-only `summary: bool`, routing, and layout changes must be merged alongside this field, not overwritten by this older checkpoint's projection body.
|
||||||
|
- `item_satisfies` is identity equality until R6 specialization lands. `items_satisfy`, `actual_performer`, `component_allows`, `has_port_allocation`, and `port_contract` provide the small shared validation/authoring seams.
|
||||||
|
|
||||||
|
Preview binding to the current revision/fingerprint and UI display of non-FE references during split belong to the existing desktop preview/usage surface. Port planners return concrete commands; they do not introduce a second preview transaction engine. The UI must present preserved legacy associations distinctly and expose source/form/connection paths using these helpers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Build output is isolated at `D:/CodexBuilds/Capella2-typed-ports-core`.
|
||||||
|
|
||||||
|
- `cargo test -p oa-core`: all 124 tests passed after core/source/migration integration, including unchanged legacy admission/location tests. One additional directed-legacy-resolution regression was then added to the focused port suite.
|
||||||
|
- `cargo test -p oa-core --test release_ports`: all 9 focused tests passed, covering atomic creation/reuse/collisions, actual allocation/generation, provider/requirement independence, Standard/Flow rejection, Legacy split and resolution, preserved attachments and references, deletion/remap/undo, portless transitions, and contract-aware geometry.
|
||||||
|
- `cargo clippy -p oa-core --all-targets -- -D warnings`: passed after the final core/test changes.
|
||||||
|
- Existing scenario14, diagram3, and workflow28 suites pass, including remapping/deleting every demo identity and checking the remaining candidate. Source/migration tests and server checks are owned/reported by the companion worker.
|
||||||
|
|
||||||
|
No CUA, desktop/native acceptance, deployment, future R6 item specialization, or R3 semantic baseline engine is claimed. Current R3 name/description-only drift remains the separately documented release seam; this milestone does not disguise it as completed semantic review.
|
||||||
|
|
||||||
|
## Owned commit scope
|
||||||
|
|
||||||
|
Core: `model.rs`, `ports.rs`, `lib.rs`, `validation.rs`, `references.rs`, `session.rs`, `transitions.rs`, `demo.rs`, `projection.rs`.
|
||||||
|
|
||||||
|
Tests: new `release_ports.rs`; typed fixture adaptations in `workflows.rs` and `release_scenarios.rs`.
|
||||||
|
|
||||||
|
Handoff: this file only. Migration/source/server/documentation files and their tests belong to the companion worker and are intentionally excluded from the core commit.
|
||||||
Reference in New Issue
Block a user