Implement bounded typed data and identified interface contracts

This commit is contained in:
2026-09-06 03:44:09 +03:00
parent 1230ab8a3e
commit 0b58107097
15 changed files with 3127 additions and 177 deletions
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -34,10 +34,7 @@ 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 items = match p.elements[&interface].shared() { let items = crate::data::interface_items(p, interface);
Some(SharedBody::Interface { items }) => items.clone(),
_ => vec![],
};
let contract = if p.elements[&owner].kind() == Kind::Function { let contract = if p.elements[&owner].kind() == Kind::Function {
if direction == PortDirection::Out { if direction == PortDirection::Out {
PortContract::FunctionOutput { PortContract::FunctionOutput {
@@ -168,6 +165,11 @@ pub fn coastal_rescue() -> Project {
"Distress report", "Distress report",
None, None,
); );
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&oa_item).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
p.relate(RelationKind::Allocation, "Reports distress", vessel, report); p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
p.relate( p.relate(
RelationKind::Allocation, RelationKind::Allocation,
@@ -308,6 +310,11 @@ pub fn coastal_rescue() -> Project {
"Digital distress report", "Digital distress report",
None, None,
); );
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&information).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
let material = add( let material = add(
&mut p, &mut p,
Kind::ExchangeItem, Kind::ExchangeItem,
@@ -317,6 +324,8 @@ pub fn coastal_rescue() -> Project {
); );
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { *p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material, kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Flow,
supertype: None,
}; };
let energy = add( let energy = add(
&mut p, &mut p,
@@ -327,6 +336,8 @@ pub fn coastal_rescue() -> Project {
); );
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { *p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy, kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
}; };
let field = add( let field = add(
&mut p, &mut p,
@@ -340,6 +351,9 @@ pub fn coastal_rescue() -> Project {
minimum: 1, minimum: 1,
maximum: Some(1), maximum: Some(1),
unit: "degrees north".into(), unit: "degrees north".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
}; };
let interface = add( let interface = add(
&mut p, &mut p,
@@ -348,13 +362,12 @@ pub fn coastal_rescue() -> Project {
"Distress reporting interface", "Distress reporting interface",
None, None,
); );
*p.elements p.relate(
.get_mut(&interface) RelationKind::InterfaceItemAllocation,
.unwrap() "Distress payload",
.shared_mut() interface,
.unwrap() = SharedBody::Interface { information,
items: vec![information], );
};
let vessel_port = port( let vessel_port = port(
&mut p, &mut p,
actor, actor,
+1
View File
@@ -1,4 +1,5 @@
//! Framework-independent OA/SA model, transactional commands and editing services. //! Framework-independent OA/SA model, transactional commands and editing services.
pub mod data;
pub mod language; pub mod language;
pub mod model; pub mod model;
pub mod persistence; pub mod persistence;
+149 -14
View File
@@ -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 = 3; pub const SCHEMA_VERSION: u32 = 4;
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)]
@@ -265,17 +265,79 @@ pub enum ExchangeItemKind {
Information, Information,
Material, Material,
Energy, Energy,
Event,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ExchangeMechanism {
#[default]
Unspecified,
Flow,
Event,
Operation,
SharedData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ParameterDirection {
#[default]
Unspecified,
In,
Out,
InOut,
Return,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FieldStorage {
#[default]
Value,
Reference,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataValue {
Boolean(bool),
Integer(i64),
Real(f64),
String(String),
Enumeration(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TransmissionProtocol {
#[default]
Unspecified,
Synchronous,
Asynchronous,
Unicast,
Multicast,
Broadcast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AcquisitionProtocol {
#[default]
Unspecified,
Read,
Accept,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub enum DataTypeDefinition { pub enum DataTypeDefinition {
Boolean, Boolean,
Integer, Integer {
Real, minimum: Option<i64>,
String, maximum: Option<i64>,
Enumeration { literals: Vec<String> }, },
Record, Real {
minimum: Option<f64>,
maximum: Option<f64>,
},
String {
minimum_length: u32,
maximum_length: Option<u32>,
},
Enumeration {
literals: Vec<String>,
},
Record {
supertype: Option<Id>,
},
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -453,6 +515,8 @@ pub struct LifelineBinding {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MessageValue { pub struct MessageValue {
#[serde(default)]
pub path: Vec<Id>,
pub field: Id, pub field: Id,
pub expression: String, pub expression: String,
} }
@@ -502,18 +566,25 @@ pub enum OccurrenceBody {
pub enum SharedBody { pub enum SharedBody {
ExchangeItem { ExchangeItem {
kind: ExchangeItemKind, kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
supertype: Option<Id>,
}, },
DataType { DataType {
definition: DataTypeDefinition, definition: DataTypeDefinition,
default: Option<DataValue>,
unit: String,
}, },
DataField { DataField {
type_ref: TypeRef, type_ref: TypeRef,
minimum: u32, minimum: u32,
maximum: Option<u32>, maximum: Option<u32>,
unit: String, unit: String,
direction: ParameterDirection,
storage: FieldStorage,
default: Option<Vec<DataValue>>,
}, },
Interface { Interface {
items: Vec<Id>, supertype: Option<Id>,
}, },
Port { Port {
contract: PortContract, contract: PortContract,
@@ -672,17 +743,27 @@ impl ElementBody {
match k { match k {
Kind::ExchangeItem => SharedBody::ExchangeItem { Kind::ExchangeItem => SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information, kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
}, },
Kind::DataType => SharedBody::DataType { Kind::DataType => SharedBody::DataType {
definition: DataTypeDefinition::String, definition: DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
default: None,
unit: String::new(),
}, },
Kind::DataField => SharedBody::DataField { Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String, type_ref: TypeRef::String,
minimum: 1, minimum: 1,
maximum: Some(1), maximum: Some(1),
unit: String::new(), unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
}, },
Kind::Interface => SharedBody::Interface { items: vec![] }, Kind::Interface => SharedBody::Interface { supertype: None },
// Port creation requires an explicit contract and owner. // Port creation requires an explicit contract and owner.
Kind::Port => return None, Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario { Kind::Scenario => SharedBody::Scenario {
@@ -752,6 +833,8 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind { pub enum RelationKind {
InterfaceItemAllocation,
DataAssociation,
Allocation, Allocation,
PortAllocation, PortAllocation,
Interaction, Interaction,
@@ -775,7 +858,9 @@ pub enum RelationKind {
StateAction, StateAction,
} }
impl RelationKind { impl RelationKind {
pub const ALL: [Self; 21] = [ pub const ALL: [Self; 23] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation, Self::Allocation,
Self::PortAllocation, Self::PortAllocation,
Self::Interaction, Self::Interaction,
@@ -800,6 +885,8 @@ 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::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
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::PortAllocation => from == Kind::Port && to == Kind::Port,
Self::Interaction => from == Kind::Activity && to == Kind::Activity, Self::Interaction => from == Kind::Activity && to == Kind::Activity,
@@ -855,6 +942,8 @@ impl RelationKind {
} }
pub fn hint(self) -> &'static str { pub fn hint(self) -> &'static str {
match self { match self {
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
Self::DataAssociation => "Record type → record type",
Self::Allocation => "Performer → leaf activity/function", Self::Allocation => "Performer → leaf activity/function",
Self::PortAllocation => "Component port → allocated function port", Self::PortAllocation => "Component port → allocated function port",
Self::Interaction => "OA activity → OA activity", Self::Interaction => "OA activity → OA activity",
@@ -886,6 +975,8 @@ impl RelationKind {
impl fmt::Display for RelationKind { 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::InterfaceItemAllocation => "Interface item allocation",
Self::DataAssociation => "Data association",
Self::Allocation => "Allocation", Self::Allocation => "Allocation",
Self::PortAllocation => "Port allocation", Self::PortAllocation => "Port allocation",
Self::Interaction => "Interaction", Self::Interaction => "Interaction",
@@ -922,6 +1013,25 @@ pub struct Relation {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub enum RelationBody { pub enum RelationBody {
InterfaceItemAllocation {
source: Id,
target: Id,
order: u32,
transmission: TransmissionProtocol,
acquisition: AcquisitionProtocol,
},
DataAssociation {
source: Id,
target: Id,
source_role: String,
target_role: String,
source_minimum: u32,
source_maximum: Option<u32>,
target_minimum: u32,
target_maximum: Option<u32>,
source_navigable: bool,
target_navigable: bool,
},
Allocation { Allocation {
source: Id, source: Id,
target: Id, target: Id,
@@ -1039,6 +1149,8 @@ impl Relation {
impl RelationBody { impl RelationBody {
pub fn kind(&self) -> RelationKind { pub fn kind(&self) -> RelationKind {
match self { match self {
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
Self::DataAssociation { .. } => RelationKind::DataAssociation,
Self::Allocation { .. } => RelationKind::Allocation, Self::Allocation { .. } => RelationKind::Allocation,
Self::PortAllocation { .. } => RelationKind::PortAllocation, Self::PortAllocation { .. } => RelationKind::PortAllocation,
Self::Interaction { .. } => RelationKind::Interaction, Self::Interaction { .. } => RelationKind::Interaction,
@@ -1064,7 +1176,9 @@ impl RelationBody {
} }
pub fn endpoints(&self) -> (Id, Id) { pub fn endpoints(&self) -> (Id, Id) {
match self { match self {
Self::Allocation { source, target } Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target } | Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. } | Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target } | Self::CapabilityInvolvement { source, target }
@@ -1089,7 +1203,9 @@ 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::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target } | Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. } | Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target } | Self::CapabilityInvolvement { source, target }
@@ -1125,6 +1241,25 @@ 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::InterfaceItemAllocation => Self::InterfaceItemAllocation {
source,
target,
order: 0,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
RelationKind::DataAssociation => Self::DataAssociation {
source,
target,
source_role: String::new(),
target_role: String::new(),
source_minimum: 0,
source_maximum: None,
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: false,
},
RelationKind::Allocation => Self::Allocation { source, target }, RelationKind::Allocation => Self::Allocation { source, target },
RelationKind::PortAllocation => Self::PortAllocation { source, target }, RelationKind::PortAllocation => Self::PortAllocation { source, target },
RelationKind::Interaction => Self::Interaction { RelationKind::Interaction => Self::Interaction {
+8 -18
View File
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
pub outgoing_items: Vec<Id>, pub outgoing_items: Vec<Id>,
} }
/// R6 specialization has one explicit seam; current models support identity only. /// A specialized carried item satisfies a declared base item.
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool { pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
carried == declared crate::data::item_satisfies(project, carried, declared)
} }
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool { pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
carried.iter().all(|item| { carried.iter().all(|item| {
@@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
Some( Some(
interfaces interfaces
.into_iter() .into_iter()
.filter_map(|id| p.elements.get(&id)) .flat_map(|id| crate::data::interface_items(p, id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect(), .collect(),
) )
} }
@@ -903,14 +897,10 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
.. ..
}) => { }) => {
let mut allowed = declared_interface_items(p, port.id); let mut allowed = declared_interface_items(p, port.id);
if let Some(SharedBody::Interface { items }) = legacy if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
.as_ref() let items = crate::data::interface_items(p, interface);
.and_then(|l| l.interface)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
{
if r.kind() == RelationKind::BoundaryExchange if r.kind() == RelationKind::BoundaryExchange
&& !items_satisfy(p, r.body.items(), items) && !items_satisfy(p, r.body.items(), &items)
{ {
error( error(
out, out,
@@ -1173,7 +1163,7 @@ pub(crate) fn validate_boundary_interface_requirements(
if !p.relations.values().any(|role| { if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id && role.source() == peer.id
&& role.target() == required.target() && crate::data::interface_satisfies(p, role.target(), required.target())
}) { }) {
warning( warning(
out, out,
+8 -2
View File
@@ -335,6 +335,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
.collect(); .collect();
let mut edges = Vec::new(); let mut edges = Vec::new();
for r in p.relations.values() { for r in p.relations.values() {
// Interface allocations are selectable nested dictionary rows. Their
// independently identified contracts do not add noisy diagram arrows.
if r.kind() == RelationKind::InterfaceItemAllocation {
continue;
}
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation { if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue; continue;
} }
@@ -370,7 +375,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
} }
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target())) if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target && (source != target
|| (d.projection.state_machine().is_some() || ((d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target() && r.source() == r.target()
&& nodes && nodes
.iter() .iter()
@@ -396,7 +402,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange { RelationBody::BoundaryExchange {
oriented: false, oriented: false,
.. ..
} } | RelationBody::DataAssociation { .. }
), ),
source_port: source_port.filter(|id| visible_ports.contains(id)), source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)), target_port: target_port.filter(|id| visible_ports.contains(id)),
+43 -10
View File
@@ -33,8 +33,13 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
type_ref: TypeRef::Named(id), type_ref: TypeRef::Named(id),
.. ..
} => visit(id), } => visit(id),
SharedBody::Interface { items } => { SharedBody::Interface { supertype }
for id in items { | SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => {
if let Some(id) = supertype {
visit(id); visit(id);
} }
} }
@@ -83,6 +88,9 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
visit(id); visit(id);
} }
for value in values { for value in values {
for id in &mut value.path {
visit(id);
}
visit(&mut value.field); visit(&mut value.field);
} }
} }
@@ -377,7 +385,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
} }
if let Some(shared) = e.shared_mut() { if let Some(shared) = e.shared_mut() {
match shared { match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)), SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => clear(supertype),
SharedBody::Event { SharedBody::Event {
body: EventBody::Signal { items }, body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)), } => items.retain(|id| !removed.contains(id)),
@@ -413,7 +426,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
clear(sender); clear(sender);
clear(receiver); clear(receiver);
clear(reply_to); clear(reply_to);
values.retain(|value| !removed.contains(&value.field)); values.retain(|value| {
!removed.contains(&value.field)
&& value.path.iter().all(|id| !removed.contains(id))
});
} }
OccurrenceBody::Action { action, lifeline } => { OccurrenceBody::Action { action, lifeline } => {
clear(action); clear(action);
@@ -485,20 +501,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
exchange: Some(id), .. exchange: Some(id), ..
}, },
.. ..
}) => p }) => p.relations.get(id).map(|r| {
.relations let Some(SharedBody::Occurrence {
.get(id) body: OccurrenceBody::Exchange { values, .. },
.map(|r| (e.id, crate::validation::carried_fields(p, r))), ..
}) = e.shared()
else {
unreachable!()
};
let retained: Vec<_> = values
.iter()
.filter(|value| {
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|| (value.path.is_empty()
&& crate::data::carried_field_paths(p, r)
.iter()
.any(|f| f.field == value.field))
})
.cloned()
.collect();
(e.id, retained)
}),
_ => None, _ => None,
}) })
.collect(); .collect();
for (id, fields) in allowed { for (id, retained) in allowed {
if let Some(SharedBody::Occurrence { if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. }, body: OccurrenceBody::Exchange { values, .. },
.. ..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut) }) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{ {
values.retain(|value| fields.contains(&value.field)); *values = retained;
} }
} }
} }
+128 -3
View File
@@ -1,7 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model; //! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export. //! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView}; use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, RelationKind, Size}; use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt; use std::fmt;
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds, pub bounds: RoutingBounds,
} }
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning { pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id }, PinInsideObstacle { pin: usize, obstacle: Id },
@@ -466,6 +582,14 @@ fn route_diagram_impl(
// never changes paths or authored metadata. Previews retain other cached // never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture. // labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new(); let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered { for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else { let Some(routed) = edges.get_mut(&edge.id) else {
continue; continue;
@@ -579,7 +703,7 @@ fn route_edge(
let mut candidates = Vec::new(); let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) { for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) { for b in endpoints(target, edge.target_port, view) {
// A real self-transition must leave and return at distinct anchors. // A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub. // Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target if edge.source == edge.target
&& edge.source_port.is_none() && edge.source_port.is_none()
@@ -1107,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() { let text = match relation.kind() {
RelationKind::Interaction RelationKind::Interaction
| RelationKind::FunctionalExchange | RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange => relation.name.as_str(), | RelationKind::BoundaryExchange
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»", RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»", RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes", RelationKind::CapabilityGeneralization => "specializes",
+17
View File
@@ -114,6 +114,7 @@ pub struct PreparedCommand {
struct IdentityProvenance { struct IdentityProvenance {
original: BTreeSet<Id>, original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>, current_to_original: BTreeMap<Id, Id>,
explicit_replacement: bool,
} }
impl IdentityProvenance { impl IdentityProvenance {
@@ -123,10 +124,12 @@ impl IdentityProvenance {
Self { Self {
original, original,
current_to_original, current_to_original,
explicit_replacement: false,
} }
} }
fn replace(&mut self, project: &Project) { fn replace(&mut self, project: &Project) {
self.explicit_replacement = true;
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original = self self.current_to_original = self
.original .original
@@ -180,6 +183,13 @@ pub fn prepare_command(
)?; )?;
crate::references::finish_deletions(&mut candidate, &pending_deletions); crate::references::finish_deletions(&mut candidate, &pending_deletions);
provenance.retain_present(&candidate); provenance.retain_present(&candidate);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?; validate_candidate(&candidate)?;
Ok(PreparedCommand { Ok(PreparedCommand {
project: candidate, project: candidate,
@@ -287,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
)?; )?;
} }
crate::references::finish_deletions(&mut candidate, &pending_deletions); crate::references::finish_deletions(&mut candidate, &pending_deletions);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?; validate_candidate(&candidate)?;
Ok(candidate) Ok(candidate)
} }
+20
View File
@@ -251,6 +251,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
} }
( (
RelationBody::Allocation { .. } RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
| RelationBody::DataAssociation { .. }
| RelationBody::ExchangeAllocation { .. } | RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. } | RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. } | RelationBody::CapabilityInclude { .. }
@@ -421,6 +423,24 @@ pub fn plan_transition(
} }
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));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
let contracts: Vec<_> = project
.relations
.values()
.filter_map(|r| {
((r.kind() == RelationKind::InterfaceItemAllocation
&& r.source() == id)
|| (r.kind() == RelationKind::DataAssociation
&& selected.contains(&r.source())
&& selected.contains(&r.target())))
.then_some(r.id)
})
.collect();
selected.extend(contracts);
}
// Contextual links are owned by the process/chain shared by // Contextual links are owned by the process/chain shared by
// their endpoint involvements, independently of list order. // their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
+119 -93
View File
@@ -388,68 +388,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
} }
if let Some(shared) = e.shared() { if let Some(shared) = e.shared() {
match shared { match shared {
SharedBody::DataType { SharedBody::DataType { .. }
definition: DataTypeDefinition::Enumeration { literals }, | SharedBody::DataField { .. }
} => { | SharedBody::Interface { .. }
let mut seen = BTreeSet::new(); | SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
for literal in literals {
text(&mut out, e.id, "literals", literal);
if !seen.insert(literal) {
error(
&mut out,
e.id,
"duplicate_literal",
"literals",
"Enumeration literals must be unique.",
);
}
}
}
SharedBody::DataField {
type_ref,
minimum,
maximum,
unit,
} => {
if maximum.is_some_and(|max| max < *minimum) {
error(
&mut out,
e.id,
"invalid_multiplicity",
"maximum",
"Maximum multiplicity must be at least minimum, or unbounded.",
);
}
text(&mut out, e.id, "unit", unit);
if let TypeRef::Named(id) = type_ref {
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
}
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == Kind::DataType
&& !matches!(
parent.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record
})
)
{
error(
&mut out,
e.id,
"field_owner",
"parent",
"Fields under a data type require a Record type.",
);
}
}
SharedBody::Interface { items } => {
check_ids(&mut out, e.id, "items", items);
for id in items {
expect_element(p, &mut out, e.id, *id, "items", |k| {
k == Kind::ExchangeItem
});
}
}
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out), SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario { SharedBody::Scenario {
viewpoint, viewpoint,
@@ -643,9 +585,11 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, r.id, "name", &r.name); text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description); text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out); validate_relation(p, r, &mut out);
crate::data::validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out); crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out); validate_relation_context(p, r, &mut out);
} }
crate::data::validate_record_cycles(p, &mut out);
for (key, d) in &p.diagrams { for (key, d) in &p.diagrams {
if *key != d.id { if *key != d.id {
error( error(
@@ -1436,23 +1380,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
} }
/// Fields carried by an exchange, including fields of nested named records. /// Fields carried by an exchange, including fields of nested named records.
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> { pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
let mut pending = relation.body.items().to_vec(); crate::data::carried_field_paths(p, relation)
let mut seen = BTreeSet::new(); .into_iter()
let mut fields = BTreeSet::new(); .map(|f| f.field)
while let Some(owner) = pending.pop() { .collect()
if !seen.insert(owner) {
continue;
}
for field in p.children(owner) {
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
fields.insert(field.id);
if let TypeRef::Named(id) = type_ref {
pending.push(*id);
}
}
}
}
fields
} }
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool { fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
match body { match body {
@@ -1683,36 +1614,125 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
} }
} }
} }
let allowed = exchange let relation = exchange.and_then(|id| p.relations.get(&id));
.and_then(|id| p.relations.get(&id)) if message_kind == &MessageKind::Call
.map(|r| carried_fields(p, r)); && relation.is_some_and(|r| {
!r.body
.items()
.iter()
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
})
{
warning(
out,
e.id,
"call_without_operation",
"exchange",
"This generic call carries no Operation item; define an operation contract when appropriate.",
);
}
let mut fields = BTreeSet::new(); let mut fields = BTreeSet::new();
for value in values { for value in values {
expect_element(p, out, e.id, value.field, "values.field", |kind| { expect_element(p, out, e.id, value.field, "values.field", |kind| {
kind == Kind::DataField kind == Kind::DataField
}); });
for hop in &value.path {
expect_element(p, out, e.id, *hop, "values.path", |kind| {
kind == Kind::DataField
});
}
text(out, e.id, "values.expression", &value.expression); text(out, e.id, "values.expression", &value.expression);
if !fields.insert(value.field) { if !fields.insert((&value.path, value.field)) {
error( error(
out, out,
e.id, e.id,
"duplicate_message_value", "duplicate_message_value",
"values", "values",
"Each carried field has at most one contextual value annotation in a message.", "Each qualified carried field has at most one contextual value annotation.",
); );
} }
if allowed if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
.as_ref() error(
.is_some_and(|allowed| !allowed.contains(&value.field)) out,
{ e.id,
"message_value_path_limit",
"values.path",
"The value path exceeds the bounded authoring depth.",
);
}
if let Some(r) = relation {
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
let permitted = match message_kind {
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
};
if !permitted {
error(
out,
e.id,
"message_parameter_phase",
"values.path",
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
);
}
if path.direction == ParameterDirection::Unspecified {
warning(
out,
e.id,
"message_parameter_direction",
"values.path",
"The containing operation parameter has no direction yet.",
);
}
}
} else {
let legacy = value
.path
.is_empty()
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
.flatten();
if matches!(
legacy,
Some(
crate::data::LegacyFieldPathResolution::Unique(_)
| crate::data::LegacyFieldPathResolution::Ambiguous
)
) {
warning(
out,
e.id,
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
"message_value_ambiguous_path"
} else {
"message_value_missing_path"
},
"values.path",
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
);
} else {
error( error(
out, out,
e.id, e.id,
"message_value_field", "message_value_field",
"values.field", "values.path",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.", "The qualified field must be carried by the exchange, following Value record fields only.",
); );
} }
}
}
if value.expression.trim().is_empty() { if value.expression.trim().is_empty() {
warning( warning(
out, out,
@@ -2130,7 +2150,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
}; };
if a.layer() != b.layer() if a.layer() != b.layer()
|| !r.kind().permits(a.kind(), b.kind()) || !r.kind().permits(a.kind(), b.kind())
|| (a.id == b.id && r.kind() != RelationKind::StateTransition) || (a.id == b.id
&& !matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::DataAssociation
))
{ {
error( error(
out, out,
@@ -2381,8 +2405,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
owner.layer() == layer owner.layer() == layer
&& parent_permits(owner.kind(), child_kind) && parent_permits(owner.kind(), child_kind)
&& match owner.shared() { && match owner.shared() {
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => { Some(SharedBody::DataType { definition, .. })
*definition == DataTypeDefinition::Record if child_kind == Kind::DataField =>
{
matches!(definition, DataTypeDefinition::Record { .. })
} }
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => { Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
matches!( matches!(
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -447,11 +447,12 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let (source, target) = p.relations[&boundary].body.endpoints(); let (source, target) = p.relations[&boundary].body.endpoints();
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service"); let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
*p.elements p.relate(
.get_mut(&interface) RelationKind::InterfaceItemAllocation,
.unwrap() "Interface payload",
.shared_mut() interface,
.unwrap() = SharedBody::Interface { items: vec![item] }; item,
);
let requirement = p.relate( let requirement = p.relate(
RelationKind::InterfaceRequirement, RelationKind::InterfaceRequirement,
"Caller requires service", "Caller requires service",
@@ -499,11 +500,12 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
let (mut p, a, b, item, forward) = fixture(); let (mut p, a, b, item, forward) = fixture();
let (old, _) = endpoints(&p, forward); let (old, _) = endpoints(&p, forward);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface"); let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
*p.elements p.relate(
.get_mut(&interface) RelationKind::InterfaceItemAllocation,
.unwrap() "Interface payload",
.shared_mut() interface,
.unwrap() = SharedBody::Interface { items: vec![item] }; item,
);
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into(); p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port { *p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy, contract: PortContract::Legacy,
@@ -659,11 +661,12 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
let (mut p, _, _, item, fe) = fixture(); let (mut p, _, _, item, fe) = fixture();
let output = endpoints(&p, fe).0; let output = endpoints(&p, fe).0;
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service"); let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
*p.elements p.relate(
.get_mut(&interface) RelationKind::InterfaceItemAllocation,
.unwrap() "Interface payload",
.shared_mut() interface,
.unwrap() = SharedBody::Interface { items: vec![item] }; item,
);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port { *p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy, contract: PortContract::Legacy,
legacy: Some(LegacyPortContract { legacy: Some(LegacyPortContract {
+4 -1
View File
@@ -94,7 +94,9 @@ fn fixture(layer: Layer) -> Fixture {
.add_element(Kind::DataType, layer, "Request record", None) .add_element(Kind::DataType, layer, "Request record", None)
.unwrap(); .unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record, definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
}; };
let field = p let field = p
.add_element(Kind::DataField, layer, "Requested level", Some(record)) .add_element(Kind::DataField, layer, "Requested level", Some(record))
@@ -160,6 +162,7 @@ fn fixture(layer: Layer) -> Fixture {
}) = p.elements.get_mut(&call).unwrap().shared_mut() }) = p.elements.get_mut(&call).unwrap().shared_mut()
{ {
values.push(MessageValue { values.push(MessageValue {
path: vec![payload],
field, field,
expression: "requested_level".into(), expression: "requested_level".into(),
}); });
+47 -9
View File
@@ -328,11 +328,12 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None); let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None); let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None); let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
*p.elements p.relate(
.get_mut(&interface) RelationKind::InterfaceItemAllocation,
.unwrap() "Interface payload",
.shared_mut() interface,
.unwrap() = SharedBody::Interface { items: vec![item] }; item,
);
let port = |p: &mut Project, name: &str, parent, direction| { let port = |p: &mut Project, name: &str, parent, direction| {
let id = Id::new_v4(); let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function { let contract = if p.elements[&parent].kind() == Kind::Function {
@@ -740,6 +741,9 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
minimum: 0, minimum: 0,
maximum: None, maximum: None,
unit: "items".into(), unit: "items".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
}; };
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);
@@ -804,8 +808,40 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
"Reidentification must preserve all values and order" "Reidentification must preserve all values and order"
); );
if id != p.id { if id != p.id {
let deleted = execute_batch(&p, &[Command::Delete(id)]) let deleted = if p
.unwrap_or_else(|err| panic!("Delete {id}: {err}")); .relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
{
// An explicitly empty interface cannot silently accept the
// still-carried payload. Removing its definition needs a
// reviewed edit of that dependent contract in the same batch.
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Delete(id))
.unwrap_err()
.0
.contains("port_interface_items")
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
let item = p.relations[&id].target();
let mut commands = vec![Command::Delete(id)];
commands.extend(
p.relations
.values()
.filter(|r| {
r.kind() == RelationKind::BoundaryExchange
&& r.body.items().contains(&item)
})
.map(|r| Command::Delete(r.id)),
);
execute_batch(&p, &commands).unwrap()
} else {
execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
};
accepted(&deleted); accepted(&deleted);
} }
} }
@@ -890,13 +926,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
assert!(p.elements.values().any(|e| matches!( assert!(p.elements.values().any(|e| matches!(
e.shared(), e.shared(),
Some(SharedBody::ExchangeItem { Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material kind: ExchangeItemKind::Material,
..
}) })
))); )));
assert!(p.elements.values().any(|e| matches!( assert!(p.elements.values().any(|e| matches!(
e.shared(), e.shared(),
Some(SharedBody::ExchangeItem { Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy kind: ExchangeItemKind::Energy,
..
}) })
))); )));
} }