From 0b5810709702b3c16445093e4d364e9e44babaf1 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 03:44:09 +0300 Subject: [PATCH] Implement bounded typed data and identified interface contracts --- crates/oa-core/src/data.rs | 1202 ++++++++++++++++++ crates/oa-core/src/demo.rs | 35 +- crates/oa-core/src/lib.rs | 1 + crates/oa-core/src/model.rs | 163 ++- crates/oa-core/src/ports.rs | 26 +- crates/oa-core/src/projection.rs | 10 +- crates/oa-core/src/references.rs | 53 +- crates/oa-core/src/routing.rs | 131 +- crates/oa-core/src/session.rs | 17 + crates/oa-core/src/transitions.rs | 20 + crates/oa-core/src/validation.rs | 214 ++-- crates/oa-core/tests/r6_data.rs | 1338 +++++++++++++++++++++ crates/oa-core/tests/release_ports.rs | 33 +- crates/oa-core/tests/release_scenarios.rs | 5 +- crates/oa-core/tests/workflows.rs | 56 +- 15 files changed, 3127 insertions(+), 177 deletions(-) create mode 100644 crates/oa-core/src/data.rs create mode 100644 crates/oa-core/tests/r6_data.rs diff --git a/crates/oa-core/src/data.rs b/crates/oa-core/src/data.rs new file mode 100644 index 00000000..df061a8c --- /dev/null +++ b/crates/oa-core/src/data.rs @@ -0,0 +1,1202 @@ +//! Bounded data dictionary traversal and validation, shared by every editor. +use crate::validation::{Issue, error, valid_text, warning}; +use crate::*; +use std::collections::{BTreeMap, BTreeSet}; + +/// A contextual field identity is `(path, field)`; item identifies its origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CarriedFieldPath { + pub item: Id, + pub path: Vec, + pub field: Id, + pub direction: ParameterDirection, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LegacyFieldPathResolution { + Missing, + Unique(Vec), + Ambiguous, +} + +pub fn supertype(e: &Element) -> Option { + match e.shared()? { + SharedBody::ExchangeItem { supertype, .. } + | SharedBody::Interface { supertype } + | SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } => *supertype, + _ => None, + } +} +pub fn is_record(p: &Project, id: Id) -> bool { + matches!( + p.elements.get(&id).and_then(Element::shared), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Record { .. }, + .. + }) + ) +} +fn ancestry(p: &Project, id: Id) -> Vec { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + result.push(id); + current = p.elements.get(&id).and_then(supertype); + } + result +} +/// Base fields precede local fields. Original field IDs and owners are retained. +/// Scan the model once, so a deep empty inheritance chain does not rescan every +/// element for every ancestor during validation. +pub fn effective_fields(p: &Project, owner: Id) -> Vec<&Element> { + let ranks: BTreeMap<_, _> = ancestry(p, owner) + .into_iter() + .rev() + .enumerate() + .map(|(rank, id)| (id, rank)) + .collect(); + let mut fields: Vec<_> = p + .elements + .values() + .filter(|e| { + e.kind() == Kind::DataField && e.parent.is_some_and(|id| ranks.contains_key(&id)) + }) + .collect(); + fields.sort_by_key(|e| (ranks[&e.parent.expect("field owner was filtered")], e.id)); + fields +} +pub fn effective_interface_allocations(p: &Project, interface: Id) -> Vec<&Relation> { + let ranks: BTreeMap<_, _> = ancestry(p, interface) + .into_iter() + .rev() + .enumerate() + .map(|(rank, id)| (id, rank)) + .collect(); + let mut allocations: Vec<_> = p + .relations + .values() + .filter(|r| { + r.kind() == RelationKind::InterfaceItemAllocation && ranks.contains_key(&r.source()) + }) + .collect(); + allocations.sort_by_key(|r| { + let RelationBody::InterfaceItemAllocation { order, .. } = &r.body else { + unreachable!() + }; + (ranks[&r.source()], *order, &r.alias, r.id) + }); + allocations +} +pub fn interface_items(p: &Project, interface: Id) -> Vec { + effective_interface_allocations(p, interface) + .into_iter() + .map(Relation::target) + .collect() +} +/// An association role and multiplicity belong to the named endpoint. +pub fn association_end_label(relation: &Relation, source_end: bool) -> Option { + let RelationBody::DataAssociation { + source_role, + target_role, + source_minimum, + source_maximum, + target_minimum, + target_maximum, + .. + } = &relation.body + else { + return None; + }; + let (role, minimum, maximum) = if source_end { + (source_role, source_minimum, source_maximum) + } else { + (target_role, target_minimum, target_maximum) + }; + Some(format!( + "{} [{}..{}]", + role, + minimum, + maximum.map_or_else(|| "*".into(), |n| n.to_string()) + )) +} +pub fn item_satisfies(p: &Project, carried: Id, declared: Id) -> bool { + p.elements + .get(&carried) + .is_some_and(|e| e.kind() == Kind::ExchangeItem) + && p.elements + .get(&declared) + .is_some_and(|e| e.kind() == Kind::ExchangeItem) + && ancestry(p, carried).contains(&declared) +} +pub fn interface_satisfies(p: &Project, provided: Id, required: Id) -> bool { + p.elements + .get(&provided) + .is_some_and(|e| e.kind() == Kind::Interface) + && p.elements + .get(&required) + .is_some_and(|e| e.kind() == Kind::Interface) + && ancestry(p, provided).contains(&required) +} +pub fn item_mechanism(p: &Project, item: Id) -> Option { + match p.elements.get(&item)?.shared()? { + SharedBody::ExchangeItem { mechanism, .. } => Some(*mechanism), + _ => None, + } +} + +const MAX_FIELD_PATHS: usize = 16_384; +pub const MAX_VALUE_PATH_DEPTH: usize = 128; + +/// Enumerate finite representative paths, stopping recursive record descent. +/// Work exhaustion is separate from a valid recursive type and is diagnosed. +pub fn try_carried_field_paths( + p: &Project, + relation: &Relation, +) -> Result, String> { + let mut result = Vec::new(); + let mut identities = BTreeSet::new(); + let mut pending: Vec<_> = relation + .body + .items() + .iter() + .rev() + .map(|id| { + ( + *id, + *id, + Vec::::new(), + ParameterDirection::Unspecified, + BTreeSet::::new(), + ) + }) + .collect(); + let mut work = 0usize; + while let Some((item, owner, path, inherited_direction, mut ancestors)) = pending.pop() { + if !ancestors.insert(owner) { + continue; + } + if path.len() > MAX_VALUE_PATH_DEPTH { + return Err(format!( + "Carried field paths exceed the {MAX_VALUE_PATH_DEPTH}-hop authoring limit." + )); + } + for field in effective_fields(p, owner) { + work += 1; + if work > MAX_FIELD_PATHS { + return Err(format!( + "Carried field expansion exceeds the {MAX_FIELD_PATHS}-field authoring limit." + )); + } + let Some(SharedBody::DataField { + direction, + storage, + type_ref, + .. + }) = field.shared() + else { + continue; + }; + let direction = if path.is_empty() { + *direction + } else { + inherited_direction + }; + if identities.insert((path.clone(), field.id)) { + result.push(CarriedFieldPath { + item, + path: path.clone(), + field: field.id, + direction, + }); + } + if *storage == FieldStorage::Value + && let TypeRef::Named(id) = type_ref + && is_record(p, *id) + { + let mut nested = path.clone(); + nested.push(field.id); + pending.push((item, *id, nested, direction, ancestors.clone())); + } + } + } + Ok(result) +} +/// UI convenience; validation always uses the checked traversal above. +pub fn carried_field_paths(p: &Project, relation: &Relation) -> Vec { + try_carried_field_paths(p, relation).unwrap_or_default() +} +/// Unlike the finite picker, legacy inference accounts for paths that revisit +/// a recursive Value record. A reachable cycle before this field is ambiguous. +pub fn resolve_legacy_field_path( + p: &Project, + relation: &Relation, + field: Id, +) -> Result { + let paths: Vec<_> = try_carried_field_paths(p, relation)? + .into_iter() + .filter(|value| value.field == field) + .collect(); + if paths.is_empty() { + return Ok(LegacyFieldPathResolution::Missing); + } + if paths.len() > 1 { + return Ok(LegacyFieldPathResolution::Ambiguous); + } + let mut graph: BTreeMap> = BTreeMap::new(); + let mut reverse: BTreeMap> = BTreeMap::new(); + let mut targets = Vec::new(); + let mut pending = relation.body.items().to_vec(); + while let Some(owner) = pending.pop() { + if graph.contains_key(&owner) { + continue; + } + let mut edges = Vec::new(); + for e in effective_fields(p, owner) { + if e.id == field { + targets.push(owner); + } + if let Some(SharedBody::DataField { + storage: FieldStorage::Value, + type_ref: TypeRef::Named(target), + .. + }) = e.shared() + && is_record(p, *target) + { + edges.push(*target); + reverse.entry(*target).or_default().push(owner); + pending.push(*target); + } + } + graph.insert(owner, edges); + } + let mut relevant = BTreeSet::new(); + while let Some(owner) = targets.pop() { + if relevant.insert(owner) { + targets.extend(reverse.get(&owner).into_iter().flatten()); + } + } + let mut remaining: BTreeMap<_, _> = graph + .iter() + .filter(|(owner, _)| relevant.contains(owner)) + .map(|(owner, edges)| { + ( + *owner, + edges + .iter() + .filter(|target| relevant.contains(target)) + .count(), + ) + }) + .collect(); + let mut pending: Vec<_> = remaining + .iter() + .filter_map(|(owner, count)| (*count == 0).then_some(*owner)) + .collect(); + while let Some(owner) = pending.pop() { + remaining.remove(&owner); + for source in reverse.get(&owner).into_iter().flatten() { + if let Some(count) = remaining.get_mut(source) { + *count -= 1; + if *count == 0 { + pending.push(*source); + } + } + } + } + Ok(if remaining.is_empty() { + LegacyFieldPathResolution::Unique(paths[0].path.clone()) + } else { + LegacyFieldPathResolution::Ambiguous + }) +} +/// Resolve an explicit finite path, including a finite path into a recursive type. +pub fn carried_value_path( + p: &Project, + relation: &Relation, + path: &[Id], + field: Id, +) -> Option { + if path.len() > MAX_VALUE_PATH_DEPTH { + return None; + } + for item in relation.body.items() { + let mut owner = *item; + let mut direction = ParameterDirection::Unspecified; + let mut valid = true; + for (index, hop) in path.iter().chain(std::iter::once(&field)).enumerate() { + let Some(e) = effective_fields(p, owner) + .into_iter() + .find(|e| e.id == *hop) + else { + valid = false; + break; + }; + let Some(SharedBody::DataField { + direction: parameter_direction, + storage, + type_ref, + .. + }) = e.shared() + else { + valid = false; + break; + }; + if index == 0 { + direction = *parameter_direction; + } + if index < path.len() { + match (storage, type_ref) { + (FieldStorage::Value, TypeRef::Named(id)) if is_record(p, *id) => owner = *id, + _ => { + valid = false; + break; + } + } + } + } + if valid { + return Some(CarriedFieldPath { + item: *item, + path: path.to_vec(), + field, + direction, + }); + } + } + None +} + +/// Existing annotations whose inherited field disappears follow the ordinary +/// deletion pruning policy. Explicitly edited/new annotations still validate. +pub(crate) fn prune_removed_inheritance( + before: &Project, + after: &mut Project, + origins: &BTreeMap, +) { + let current: BTreeMap<_, _> = origins.iter().map(|(now, old)| (*old, *now)).collect(); + let removed: BTreeSet<_> = before + .elements + .values() + .filter_map(|e| { + let base = supertype(e)?; + let now = after.elements.get(current.get(&e.id)?)?; + (supertype(now) != current.get(&base).copied()).then_some(e.id) + }) + .collect(); + if removed.is_empty() { + return; + } + let mut pruned = BTreeMap::new(); + for e in after.elements.values() { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(exchange), + values, + .. + }, + .. + }) = e.shared() + else { + continue; + }; + let Some(old) = origins.get(&e.id).and_then(|id| before.elements.get(id)) else { + continue; + }; + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(old_exchange), + values: old_values, + .. + }, + .. + }) = old.shared() + else { + continue; + }; + if origins.get(exchange) != Some(old_exchange) { + continue; + } + let (Some(relation), Some(old_relation)) = ( + after.relations.get(exchange), + before.relations.get(old_exchange), + ) else { + continue; + }; + let mut keep = Vec::new(); + for value in values { + if carried_value_path(after, relation, &value.path, value.field).is_some() + || (value.path.is_empty() + && matches!( + resolve_legacy_field_path(after, relation, value.field), + Ok(LegacyFieldPathResolution::Unique(_) + | LegacyFieldPathResolution::Ambiguous) + )) + { + keep.push(value.clone()); + continue; + } + let old_value = MessageValue { + path: value + .path + .iter() + .map(|id| origins.get(id).copied().unwrap_or(*id)) + .collect(), + field: origins.get(&value.field).copied().unwrap_or(value.field), + expression: value.expression.clone(), + }; + let prior_paths = if old_value.path.is_empty() + && carried_value_path(before, old_relation, &old_value.path, old_value.field) + .is_none() + { + carried_field_paths(before, old_relation) + .into_iter() + .filter(|path| path.field == old_value.field) + .map(|path| MessageValue { + path: path.path, + ..old_value.clone() + }) + .collect::>() + } else { + vec![old_value.clone()] + }; + let lost = old_values.contains(&old_value) + && prior_paths.iter().any(|old_value| { + old_relation.body.items().iter().any(|item| { + if carried_value_path( + before, + old_relation, + &old_value.path, + old_value.field, + ) + .is_none() + { + return false; + } + let mut owner = *item; + for hop in old_value + .path + .iter() + .chain(std::iter::once(&old_value.field)) + { + let Some(field) = effective_fields(before, owner) + .into_iter() + .find(|e| e.id == *hop) + else { + return false; + }; + let mut inherited_owner = Some(owner); + let mut seen = BTreeSet::new(); + while let Some(id) = inherited_owner { + if Some(id) == field.parent || !seen.insert(id) { + break; + } + if removed.contains(&id) { + return true; + } + inherited_owner = before.elements.get(&id).and_then(supertype); + } + if let Some(SharedBody::DataField { + type_ref: TypeRef::Named(id), + .. + }) = field.shared() + { + owner = *id; + } + } + false + }) + }); + if !lost { + keep.push(value.clone()); + } + } + if keep != *values { + pruned.insert(e.id, keep); + } + } + for (id, keep) in pruned { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = after.elements.get_mut(&id).and_then(Element::shared_mut) + { + *values = keep; + } + } +} + +pub fn validate_data_value( + p: &Project, + type_ref: &TypeRef, + value: &DataValue, +) -> Result<(), String> { + let primitive = match type_ref { + TypeRef::Boolean => DataTypeDefinition::Boolean, + TypeRef::Integer => DataTypeDefinition::Integer { + minimum: None, + maximum: None, + }, + TypeRef::Real => DataTypeDefinition::Real { + minimum: None, + maximum: None, + }, + TypeRef::String => DataTypeDefinition::String { + minimum_length: 0, + maximum_length: None, + }, + TypeRef::Named(id) => { + return match p.elements.get(id).and_then(Element::shared) { + Some(SharedBody::DataType { definition, .. }) => validate_scalar(definition, value), + _ => Err("The named scalar type does not exist.".into()), + }; + } + }; + validate_scalar(&primitive, value) +} +fn validate_scalar(definition: &DataTypeDefinition, value: &DataValue) -> Result<(), String> { + let valid = + match (definition, value) { + (DataTypeDefinition::Boolean, DataValue::Boolean(_)) => true, + (DataTypeDefinition::Integer { minimum, maximum }, DataValue::Integer(n)) => { + minimum.is_none_or(|min| *n >= min) && maximum.is_none_or(|max| *n <= max) + } + (DataTypeDefinition::Real { minimum, maximum }, DataValue::Real(n)) => { + n.is_finite() + && minimum.is_none_or(|min| *n >= min) + && maximum.is_none_or(|max| *n <= max) + } + ( + DataTypeDefinition::String { + minimum_length, + maximum_length, + }, + DataValue::String(s), + ) => { + valid_text(s) + && s.chars().count() >= *minimum_length as usize + && maximum_length.is_none_or(|max| s.chars().count() <= max as usize) + } + (DataTypeDefinition::Enumeration { literals }, DataValue::Enumeration(s)) => { + literals.contains(s) + } + (DataTypeDefinition::Record { .. }, _) => { + return Err("Record/object literals are not supported as scalar defaults.".into()); + } + _ => return Err( + "The default must have exactly the declared scalar type; values are not coerced." + .into(), + ), + }; + if valid { + Ok(()) + } else { + Err( + "Default is nonfinite, outside the declared bounds, or not a declared enum literal." + .into(), + ) + } +} +pub fn effective_field_unit<'a>(p: &'a Project, field: &'a Element) -> Option<&'a str> { + let SharedBody::DataField { type_ref, unit, .. } = field.shared()? else { + return None; + }; + if !unit.is_empty() { + return Some(unit); + } + match type_ref { + TypeRef::Named(id) => match p.elements.get(id)?.shared()? { + SharedBody::DataType { unit, .. } => Some(unit), + _ => None, + }, + _ => Some(unit), + } +} +pub fn effective_element_default<'a>(p: &'a Project, field: &'a Element) -> Option<&'a DataValue> { + let SharedBody::DataField { + type_ref: TypeRef::Named(id), + default: None, + .. + } = field.shared()? + else { + return None; + }; + match p.elements.get(id)?.shared()? { + SharedBody::DataType { default, .. } => default.as_ref(), + _ => None, + } +} + +fn check_text(out: &mut Vec, id: Id, field: &str, value: &str) { + if !valid_text(value) { + error( + out, + id, + "invalid_text", + field, + "Text contains a forbidden XML character.", + ); + } +} +fn numeric_type(p: &Project, reference: &TypeRef) -> bool { + match reference { + TypeRef::Integer | TypeRef::Real => true, + TypeRef::Named(id) => matches!( + p.elements.get(id).and_then(Element::shared), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { .. } | DataTypeDefinition::Real { .. }, + .. + }) + ), + _ => false, + } +} +pub(crate) fn validate_element(p: &Project, e: &Element, out: &mut Vec) { + if let Some(base) = supertype(e) { + let compatible = p.elements.get(&base).is_some_and(|b| { + b.kind() == e.kind() + && b.layer() == e.layer() + && (e.kind() != Kind::DataType || is_record(p, base)) + }); + if !compatible { + error( + out, + e.id, + "data_supertype", + "supertype", + "Choose a same-family supertype in this layer.", + ); + } + if ancestry(p, base).contains(&e.id) { + error( + out, + e.id, + "data_inheritance_cycle", + "supertype", + "Single specialization must be acyclic.", + ); + } + if e.kind() != Kind::Interface { + let names: BTreeSet<_> = effective_fields(p, base) + .into_iter() + .map(|f| &f.name) + .collect(); + for local in p.children(e.id).filter(|f| f.kind() == Kind::DataField) { + if names.contains(&local.name) { + error( + out, + local.id, + "inherited_field_shadow", + "name", + "A local field cannot shadow an inherited field name.", + ); + } + } + } + } + match e.shared() { + Some(SharedBody::ExchangeItem { + kind, + mechanism, + supertype: base, + }) => { + if *mechanism == ExchangeMechanism::Unspecified { + warning( + out, + e.id, + "unspecified_exchange_mechanism", + "mechanism", + "Choose the exchange mechanism when its contract is known.", + ); + } + if *kind != ExchangeItemKind::Information + && matches!( + mechanism, + ExchangeMechanism::Event + | ExchangeMechanism::Operation + | ExchangeMechanism::SharedData + ) + { + warning( + out, + e.id, + "physical_exchange_mechanism", + "mechanism", + "This mechanism usually describes information about a physical exchange; review the nature.", + ); + } + if let Some(SharedBody::ExchangeItem { + kind: base_kind, + mechanism: base_mechanism, + .. + }) = base + .and_then(|id| p.elements.get(&id)) + .and_then(Element::shared) + && (kind != base_kind + || (*base_mechanism != ExchangeMechanism::Unspecified + && mechanism != base_mechanism)) + { + error( + out, + e.id, + "exchange_specialization_conflict", + "supertype", + "An item specialization preserves its base nature and any specified base mechanism.", + ); + } + let fields = effective_fields(p, e.id); + if *mechanism == ExchangeMechanism::Operation { + for field in fields.iter().filter(|f| { + f.parent != Some(e.id) + && matches!( + f.shared(), + Some(SharedBody::DataField { + direction: ParameterDirection::Unspecified, + .. + }) + ) + }) { + warning( + out, + e.id, + "unspecified_inherited_parameter_direction", + "supertype", + format!( + "Inherited parameter '{}' has no direction in this operation.", + field.name + ), + ); + } + } + if *mechanism == ExchangeMechanism::Operation + && fields + .iter() + .filter(|f| { + matches!( + f.shared(), + Some(SharedBody::DataField { + direction: ParameterDirection::Return, + .. + }) + ) + }) + .count() + > 1 + { + error( + out, + e.id, + "multiple_return_parameters", + "supertype", + "An operation has at most one Return parameter, including inherited parameters.", + ); + } + if fields.is_empty() + && !matches!( + mechanism, + ExchangeMechanism::Event | ExchangeMechanism::Operation + ) + && p.relations.values().any(|r| { + r.body.items().contains(&e.id) + || (r.kind() == RelationKind::InterfaceItemAllocation && r.target() == e.id) + }) + { + warning( + out, + e.id, + "empty_exchange_item", + "body", + "This carried or allocated item has no defined fields; an empty notification or void operation may be intentional.", + ); + } + } + Some(SharedBody::DataType { + definition, + default, + unit, + }) => { + if matches!(definition, DataTypeDefinition::Record { .. }) + && effective_fields(p, e.id).is_empty() + && p.elements.values().any(|field| matches!(field.shared(), Some(SharedBody::DataField { type_ref: TypeRef::Named(id), .. }) if *id == e.id)) + { + warning(out,e.id,"empty_record","definition","This referenced Record has no defined fields yet."); + } + let invalid = match definition { + DataTypeDefinition::Integer { minimum, maximum } => { + minimum.zip(*maximum).is_some_and(|(a, b)| a > b) + } + DataTypeDefinition::Real { minimum, maximum } => { + minimum.is_some_and(|n| !n.is_finite()) + || maximum.is_some_and(|n| !n.is_finite()) + || minimum.zip(*maximum).is_some_and(|(a, b)| a > b) + } + DataTypeDefinition::String { + minimum_length, + maximum_length, + } => maximum_length.is_some_and(|n| n < *minimum_length), + _ => false, + }; + if invalid { + error( + out, + e.id, + "invalid_data_bounds", + "definition", + "Bounds must be ordered and real bounds must be finite.", + ); + } + if let DataTypeDefinition::Enumeration { literals } = definition { + if literals.is_empty() { + warning( + out, + e.id, + "empty_enumeration", + "literals", + "Add enumeration literals when the available values are known.", + ); + } + let mut seen = BTreeSet::new(); + for literal in literals { + check_text(out, e.id, "literals", literal); + if literal.is_empty() { + error( + out, + e.id, + "empty_literal", + "literals", + "Enumeration literals must be nonempty.", + ); + } + if !seen.insert(literal) { + error( + out, + e.id, + "duplicate_literal", + "literals", + "Enumeration literals must be unique and case-sensitive.", + ); + } + } + } + if let Some(value) = default + && let Err(message) = validate_scalar(definition, value) + { + error(out, e.id, "invalid_data_default", "default", message); + } + check_text(out, e.id, "unit", unit); + if !unit.is_empty() + && !matches!( + definition, + DataTypeDefinition::Integer { .. } | DataTypeDefinition::Real { .. } + ) + { + warning( + out, + e.id, + "nonnumeric_unit", + "unit", + "Units on nonnumeric types are preserved as annotations without conversion semantics.", + ); + } + } + Some(SharedBody::DataField { + type_ref, + minimum, + maximum, + unit, + direction, + storage, + default, + }) => { + if maximum.is_some_and(|max| max < *minimum) { + error( + out, + e.id, + "invalid_multiplicity", + "maximum", + "Maximum multiplicity must be at least minimum, or unbounded.", + ); + } + if let TypeRef::Named(id) = type_ref + && !p + .elements + .get(id) + .is_some_and(|t| t.kind() == Kind::DataType && t.layer() == e.layer()) + { + error( + out, + e.id, + "field_type", + "type_ref", + "Choose a data type in the field's layer.", + ); + } + if let Some(owner) = e.parent.and_then(|id| p.elements.get(&id)) + && owner.kind() == Kind::DataType + && !is_record(p, owner.id) + { + error( + out, + e.id, + "field_owner", + "parent", + "Fields under a data type require a Record type.", + ); + } + let operation = + e.parent.and_then(|id| item_mechanism(p, id)) == Some(ExchangeMechanism::Operation); + if !operation && *direction != ParameterDirection::Unspecified { + error( + out, + e.id, + "parameter_direction", + "direction", + "Only directly owned operation parameters may declare a direction.", + ); + } else if operation && *direction == ParameterDirection::Unspecified { + warning( + out, + e.id, + "unspecified_parameter_direction", + "direction", + "Choose the parameter direction from the provider's perspective.", + ); + } + if *storage == FieldStorage::Reference + && (!matches!(type_ref, TypeRef::Named(id) if is_record(p,*id)) + || default.is_some()) + { + error( + out, + e.id, + "reference_field_contract", + "storage", + "Reference storage requires a named Record type and no scalar default.", + ); + } + if let Some(values) = default { + if values.len() < *minimum as usize + || maximum.is_some_and(|max| values.len() > max as usize) + { + error( + out, + e.id, + "field_default_multiplicity", + "default", + "The explicit default collection must satisfy the field's multiplicity.", + ); + } + if !values.is_empty() && matches!(type_ref, TypeRef::Named(id) if is_record(p,*id)) + { + error( + out, + e.id, + "record_field_default", + "default", + "Record and object-reference defaults are not supported.", + ); + } + for value in values { + if let Err(message) = validate_data_value(p, type_ref, value) { + error(out, e.id, "invalid_field_default", "default", message); + } + } + } + check_text(out, e.id, "unit", unit); + if !unit.is_empty() && !numeric_type(p, type_ref) { + warning( + out, + e.id, + "nonnumeric_unit", + "unit", + "Units on nonnumeric fields are preserved as annotations.", + ); + } + if let TypeRef::Named(id) = type_ref + && let Some(SharedBody::DataType { + unit: inherited, .. + }) = p.elements.get(id).and_then(Element::shared) + && !unit.is_empty() + && !inherited.is_empty() + && unit != inherited + { + warning( + out, + e.id, + "field_unit_override", + "unit", + "The field unit differs from its named type; conversion compatibility is not evaluated.", + ); + } + } + Some(SharedBody::Interface { .. }) + if effective_interface_allocations(p, e.id).is_empty() => + { + warning( + out, + e.id, + "empty_interface", + "body", + "Allocate existing exchange items to define this interface's contract.", + ); + } + _ => {} + } +} + +pub(crate) fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { + match &r.body { + RelationBody::InterfaceItemAllocation { source, target, .. } => { + if effective_interface_allocations(p, *source) + .into_iter() + .any(|other| other.id != r.id && other.target() == *target) + { + error( + out, + r.id, + "duplicate_interface_item", + "target", + "An interface cannot allocate an item already allocated locally or inherited from a base.", + ); + } + } + RelationBody::DataAssociation { + source, + target, + source_role, + target_role, + source_minimum, + source_maximum, + target_minimum, + target_maximum, + source_navigable, + target_navigable, + } => { + if !is_record(p, *source) || !is_record(p, *target) { + error( + out, + r.id, + "association_record_endpoints", + "endpoints", + "A data association connects two Record data types.", + ); + } + for (field, min, max, role) in [ + ("source", source_minimum, source_maximum, source_role), + ("target", target_minimum, target_maximum, target_role), + ] { + if max.is_some_and(|max| max < *min) { + error( + out, + r.id, + "association_multiplicity", + field, + "An association end maximum must be at least its minimum.", + ); + } + check_text(out, r.id, field, role); + if role.trim().is_empty() { + warning( + out, + r.id, + "empty_association_role", + field, + "Name this association end to explain its role.", + ); + } + } + if source == target + && *source_navigable + && *target_navigable + && (source_role.trim().is_empty() + || target_role.trim().is_empty() + || source_role == target_role) + { + error( + out, + r.id, + "self_association_roles", + "source_role", + "A self-association with two navigable ends requires distinct nonempty role names.", + ); + } + if p.relations.values().any(|other| other.id != r.id && matches!(&other.body, + RelationBody::DataAssociation { source:s,target:t,source_role:sr,target_role:tr,.. } if + (s == source && t == target && sr == source_role && tr == target_role) || + (s == target && t == source && sr == target_role && tr == source_role))) { + warning(out,r.id,"duplicate_association_roles","endpoints","Another identified association describes these same endpoint roles; review whether both are intended."); + } + } + _ => {} + } + if !r.body.items().is_empty() + && let Err(message) = try_carried_field_paths(p, r) + { + error(out, r.id, "data_path_expansion_limit", "items", message); + } +} + +pub(crate) fn validate_record_cycles(p: &Project, out: &mut Vec) { + let graph: BTreeMap<_, Vec<_>> = p + .elements + .values() + .filter(|e| is_record(p, e.id)) + .map(|e| { + let edges = effective_fields(p, e.id) + .into_iter() + .filter_map(|f| match f.shared() { + Some(SharedBody::DataField { + storage: FieldStorage::Value, + minimum, + type_ref: TypeRef::Named(id), + .. + }) if *minimum > 0 && is_record(p, *id) => Some(*id), + _ => None, + }) + .collect(); + (e.id, edges) + }) + .collect(); + // Remove finite sinks, then their predecessors. Remaining records either + // contain a mandatory cycle or necessarily contain a record that does. + let mut remaining: BTreeMap<_, _> = + graph.iter().map(|(id, edges)| (*id, edges.len())).collect(); + let mut incoming: BTreeMap> = BTreeMap::new(); + for (id, edges) in &graph { + for target in edges { + incoming.entry(*target).or_default().push(*id); + } + } + let mut pending: Vec<_> = remaining + .iter() + .filter_map(|(id, count)| (*count == 0).then_some(*id)) + .collect(); + while let Some(id) = pending.pop() { + remaining.remove(&id); + for source in incoming.get(&id).into_iter().flatten() { + if let Some(count) = remaining.get_mut(source) { + *count -= 1; + if *count == 0 { + pending.push(*source); + } + } + } + } + for id in remaining.keys() { + warning( + out, + *id, + "record_no_finite_value", + "definition", + "Mandatory Value record fields require a cycle; this type cannot have a finite fully populated value.", + ); + } +} diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index 8e9676b2..fb4e1e7d 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -34,10 +34,7 @@ fn occurrence( id } fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id { - let items = match p.elements[&interface].shared() { - Some(SharedBody::Interface { items }) => items.clone(), - _ => vec![], - }; + let items = crate::data::interface_items(p, interface); let contract = if p.elements[&owner].kind() == Kind::Function { if direction == PortDirection::Out { PortContract::FunctionOutput { @@ -168,6 +165,11 @@ pub fn coastal_rescue() -> Project { "Distress report", 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, @@ -308,6 +310,11 @@ pub fn coastal_rescue() -> Project { "Digital distress report", None, ); + if let Some(SharedBody::ExchangeItem { mechanism, .. }) = + p.elements.get_mut(&information).unwrap().shared_mut() + { + *mechanism = ExchangeMechanism::Event; + } let material = add( &mut p, Kind::ExchangeItem, @@ -317,6 +324,8 @@ pub fn coastal_rescue() -> Project { ); *p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind: ExchangeItemKind::Material, + mechanism: ExchangeMechanism::Flow, + supertype: None, }; let energy = add( &mut p, @@ -327,6 +336,8 @@ pub fn coastal_rescue() -> Project { ); *p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind: ExchangeItemKind::Energy, + mechanism: ExchangeMechanism::Flow, + supertype: None, }; let field = add( &mut p, @@ -340,6 +351,9 @@ pub fn coastal_rescue() -> Project { minimum: 1, maximum: Some(1), unit: "degrees north".into(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, }; let interface = add( &mut p, @@ -348,13 +362,12 @@ pub fn coastal_rescue() -> Project { "Distress reporting interface", None, ); - *p.elements - .get_mut(&interface) - .unwrap() - .shared_mut() - .unwrap() = SharedBody::Interface { - items: vec![information], - }; + p.relate( + RelationKind::InterfaceItemAllocation, + "Distress payload", + interface, + information, + ); let vessel_port = port( &mut p, actor, diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 8236da2f..fa53961a 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -1,4 +1,5 @@ //! Framework-independent OA/SA model, transactional commands and editing services. +pub mod data; pub mod language; pub mod model; pub mod persistence; diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index b7d3d3c7..de6f4b6f 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -6,7 +6,7 @@ use std::{ use uuid::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.; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -265,17 +265,79 @@ pub enum ExchangeItemKind { Information, Material, 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)] pub enum DataTypeDefinition { Boolean, - Integer, - Real, - String, - Enumeration { literals: Vec }, - Record, + Integer { + minimum: Option, + maximum: Option, + }, + Real { + minimum: Option, + maximum: Option, + }, + String { + minimum_length: u32, + maximum_length: Option, + }, + Enumeration { + literals: Vec, + }, + Record { + supertype: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -453,6 +515,8 @@ pub struct LifelineBinding { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MessageValue { + #[serde(default)] + pub path: Vec, pub field: Id, pub expression: String, } @@ -502,18 +566,25 @@ pub enum OccurrenceBody { pub enum SharedBody { ExchangeItem { kind: ExchangeItemKind, + mechanism: ExchangeMechanism, + supertype: Option, }, DataType { definition: DataTypeDefinition, + default: Option, + unit: String, }, DataField { type_ref: TypeRef, minimum: u32, maximum: Option, unit: String, + direction: ParameterDirection, + storage: FieldStorage, + default: Option>, }, Interface { - items: Vec, + supertype: Option, }, Port { contract: PortContract, @@ -672,17 +743,27 @@ impl ElementBody { match k { Kind::ExchangeItem => SharedBody::ExchangeItem { kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Unspecified, + supertype: None, }, 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 { type_ref: TypeRef::String, minimum: 1, maximum: Some(1), 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. Kind::Port => return None, Kind::Scenario => SharedBody::Scenario { @@ -752,6 +833,8 @@ impl SharedBody { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RelationKind { + InterfaceItemAllocation, + DataAssociation, Allocation, PortAllocation, Interaction, @@ -775,7 +858,9 @@ pub enum RelationKind { StateAction, } impl RelationKind { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 23] = [ + Self::InterfaceItemAllocation, + Self::DataAssociation, Self::Allocation, Self::PortAllocation, Self::Interaction, @@ -800,6 +885,8 @@ impl RelationKind { ]; pub fn permits(self, from: Kind, to: Kind) -> bool { 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::PortAllocation => from == Kind::Port && to == Kind::Port, Self::Interaction => from == Kind::Activity && to == Kind::Activity, @@ -855,6 +942,8 @@ impl RelationKind { } pub fn hint(self) -> &'static str { match self { + Self::InterfaceItemAllocation => "Interface → allocated exchange item", + Self::DataAssociation => "Record type → record type", Self::Allocation => "Performer → leaf activity/function", Self::PortAllocation => "Component port → allocated function port", Self::Interaction => "OA activity → OA activity", @@ -886,6 +975,8 @@ impl RelationKind { impl fmt::Display for RelationKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { + Self::InterfaceItemAllocation => "Interface item allocation", + Self::DataAssociation => "Data association", Self::Allocation => "Allocation", Self::PortAllocation => "Port allocation", Self::Interaction => "Interaction", @@ -922,6 +1013,25 @@ pub struct Relation { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] 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, + target_minimum: u32, + target_maximum: Option, + source_navigable: bool, + target_navigable: bool, + }, Allocation { source: Id, target: Id, @@ -1039,6 +1149,8 @@ impl Relation { impl RelationBody { pub fn kind(&self) -> RelationKind { match self { + Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation, + Self::DataAssociation { .. } => RelationKind::DataAssociation, Self::Allocation { .. } => RelationKind::Allocation, Self::PortAllocation { .. } => RelationKind::PortAllocation, Self::Interaction { .. } => RelationKind::Interaction, @@ -1064,7 +1176,9 @@ impl RelationBody { } pub fn endpoints(&self) -> (Id, Id) { match self { - Self::Allocation { source, target } + Self::InterfaceItemAllocation { source, target, .. } + | Self::DataAssociation { source, target, .. } + | Self::Allocation { source, target } | Self::PortAllocation { source, target } | Self::Interaction { source, target, .. } | Self::CapabilityInvolvement { source, target } @@ -1089,7 +1203,9 @@ impl RelationBody { } pub fn set_endpoints(&mut self, a: Id, b: Id) { match self { - Self::Allocation { source, target } + Self::InterfaceItemAllocation { source, target, .. } + | Self::DataAssociation { source, target, .. } + | Self::Allocation { source, target } | Self::PortAllocation { source, target } | Self::Interaction { source, target, .. } | Self::CapabilityInvolvement { source, target } @@ -1125,6 +1241,25 @@ impl RelationBody { } pub fn new(kind: RelationKind, source: Id, target: Id) -> Self { 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::PortAllocation => Self::PortAllocation { source, target }, RelationKind::Interaction => Self::Interaction { diff --git a/crates/oa-core/src/ports.rs b/crates/oa-core/src/ports.rs index b013ca26..90c06833 100644 --- a/crates/oa-core/src/ports.rs +++ b/crates/oa-core/src/ports.rs @@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest { pub outgoing_items: Vec, } -/// R6 specialization has one explicit seam; current models support identity only. -pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool { - carried == declared +/// A specialized carried item satisfies a declared base item. +pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool { + crate::data::item_satisfies(project, carried, declared) } pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool { carried.iter().all(|item| { @@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option> { Some( interfaces .into_iter() - .filter_map(|id| p.elements.get(&id)) - .filter_map(|e| match e.shared() { - Some(SharedBody::Interface { items }) => Some(items), - _ => None, - }) - .flatten() - .copied() + .flat_map(|id| crate::data::interface_items(p, id)) .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); - if let Some(SharedBody::Interface { items }) = legacy - .as_ref() - .and_then(|l| l.interface) - .and_then(|id| p.elements.get(&id)) - .and_then(Element::shared) - { + if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) { + let items = crate::data::interface_items(p, interface); if r.kind() == RelationKind::BoundaryExchange - && !items_satisfy(p, r.body.items(), items) + && !items_satisfy(p, r.body.items(), &items) { error( out, @@ -1173,7 +1163,7 @@ pub(crate) fn validate_boundary_interface_requirements( if !p.relations.values().any(|role| { role.kind() == RelationKind::InterfaceProvision && role.source() == peer.id - && role.target() == required.target() + && crate::data::interface_satisfies(p, role.target(), required.target()) }) { warning( out, diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index 4dc26a23..9e06706b 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -335,6 +335,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { .collect(); let mut edges = Vec::new(); 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 { 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())) && (source != target - || (d.projection.state_machine().is_some() + || ((d.projection.state_machine().is_some() + || r.kind() == RelationKind::DataAssociation) && r.source() == r.target() && nodes .iter() @@ -396,7 +402,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { RelationBody::BoundaryExchange { oriented: false, .. - } + } | RelationBody::DataAssociation { .. } ), source_port: source_port.filter(|id| visible_ports.contains(id)), target_port: target_port.filter(|id| visible_ports.contains(id)), diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index d2a8f002..757a3fd0 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -33,8 +33,13 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) { type_ref: TypeRef::Named(id), .. } => visit(id), - SharedBody::Interface { items } => { - for id in items { + SharedBody::Interface { supertype } + | SharedBody::ExchangeItem { supertype, .. } + | SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } => { + if let Some(id) = supertype { visit(id); } } @@ -83,6 +88,9 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) { visit(id); } for value in values { + for id in &mut value.path { + visit(id); + } visit(&mut value.field); } } @@ -377,7 +385,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { } if let Some(shared) = e.shared_mut() { 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 { body: EventBody::Signal { items }, } => items.retain(|id| !removed.contains(id)), @@ -413,7 +426,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { clear(sender); clear(receiver); 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 } => { clear(action); @@ -485,20 +501,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { exchange: Some(id), .. }, .. - }) => p - .relations - .get(id) - .map(|r| (e.id, crate::validation::carried_fields(p, r))), + }) => p.relations.get(id).map(|r| { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = 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, }) .collect(); - for (id, fields) in allowed { + for (id, retained) in allowed { if let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = p.elements.get_mut(&id).and_then(Element::shared_mut) { - values.retain(|value| fields.contains(&value.field)); + *values = retained; } } } diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 4bd72b27..0148db4c 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -1,7 +1,7 @@ //! Deterministic, bounded diagram geometry. Only authored pins belong in the model; //! callers share this derived geometry for drawing, picking, fitting and export. 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::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::fmt; @@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry { 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 { + let RelationBody::DataAssociation { + source_navigable, + target_navigable, + .. + } = &relation.body + else { + return Vec::new(); + }; + let mut ends: Vec = 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::() + } 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)] pub enum RouteWarning { PinInsideObstacle { pin: usize, obstacle: Id }, @@ -466,6 +582,14 @@ fn route_diagram_impl( // never changes paths or authored metadata. Previews retain other cached // labels and avoid the paths actually shown during the gesture. 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 { let Some(routed) = edges.get_mut(&edge.id) else { continue; @@ -579,7 +703,7 @@ fn route_edge( let mut candidates = Vec::new(); for a in endpoints(source, edge.source_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. if edge.source == edge.target && edge.source_port.is_none() @@ -1107,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String { let text = match relation.kind() { RelationKind::Interaction | RelationKind::FunctionalExchange - | RelationKind::BoundaryExchange => relation.name.as_str(), + | RelationKind::BoundaryExchange + | RelationKind::DataAssociation => relation.name.as_str(), RelationKind::CapabilityInclude => "«include»", RelationKind::CapabilityExtend => "«extend»", RelationKind::CapabilityGeneralization => "specializes", diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 84d1395d..37b42951 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -114,6 +114,7 @@ pub struct PreparedCommand { struct IdentityProvenance { original: BTreeSet, current_to_original: BTreeMap, + explicit_replacement: bool, } impl IdentityProvenance { @@ -123,10 +124,12 @@ impl IdentityProvenance { Self { original, current_to_original, + explicit_replacement: false, } } fn replace(&mut self, project: &Project) { + self.explicit_replacement = true; let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); self.current_to_original = self .original @@ -180,6 +183,13 @@ pub fn prepare_command( )?; crate::references::finish_deletions(&mut candidate, &pending_deletions); provenance.retain_present(&candidate); + if !provenance.explicit_replacement { + crate::data::prune_removed_inheritance( + project, + &mut candidate, + &provenance.current_to_original, + ); + } validate_candidate(&candidate)?; Ok(PreparedCommand { project: candidate, @@ -287,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result Option = 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 // their endpoint involvements, independently of list order. if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index d8315383..5077103a 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -388,68 +388,10 @@ pub fn validate(p: &Project) -> Vec { } if let Some(shared) = e.shared() { match shared { - SharedBody::DataType { - definition: DataTypeDefinition::Enumeration { literals }, - } => { - let mut seen = BTreeSet::new(); - 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::DataType { .. } + | SharedBody::DataField { .. } + | SharedBody::Interface { .. } + | SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out), SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out), SharedBody::Scenario { viewpoint, @@ -643,9 +585,11 @@ pub fn validate(p: &Project) -> Vec { text(&mut out, r.id, "name", &r.name); text(&mut out, r.id, "description", &r.description); validate_relation(p, r, &mut out); + crate::data::validate_relation(p, r, &mut out); crate::ports::validate_boundary_interface_requirements(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 { if *key != d.id { error( @@ -1436,23 +1380,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet { } /// Fields carried by an exchange, including fields of nested named records. pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet { - let mut pending = relation.body.items().to_vec(); - let mut seen = BTreeSet::new(); - let mut fields = BTreeSet::new(); - while let Some(owner) = pending.pop() { - 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 + crate::data::carried_field_paths(p, relation) + .into_iter() + .map(|f| f.field) + .collect() } fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool { match body { @@ -1683,36 +1614,125 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V } } } - let allowed = exchange - .and_then(|id| p.relations.get(&id)) - .map(|r| carried_fields(p, r)); + let relation = exchange.and_then(|id| p.relations.get(&id)); + if message_kind == &MessageKind::Call + && 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(); for value in values { expect_element(p, out, e.id, value.field, "values.field", |kind| { 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); - if !fields.insert(value.field) { + if !fields.insert((&value.path, value.field)) { error( out, e.id, "duplicate_message_value", "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 - .as_ref() - .is_some_and(|allowed| !allowed.contains(&value.field)) - { + if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH { error( out, e.id, - "message_value_field", - "values.field", - "Value annotations must reference fields carried by this message's exchange, including nested record fields.", + "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( + out, + e.id, + "message_value_field", + "values.path", + "The qualified field must be carried by the exchange, following Value record fields only.", + ); + } + } + } if value.expression.trim().is_empty() { warning( out, @@ -2130,7 +2150,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { }; if a.layer() != b.layer() || !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( out, @@ -2381,8 +2405,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option { - *definition == DataTypeDefinition::Record + Some(SharedBody::DataType { definition, .. }) + if child_kind == Kind::DataField => + { + matches!(definition, DataTypeDefinition::Record { .. }) } Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => { matches!( diff --git a/crates/oa-core/tests/r6_data.rs b/crates/oa-core/tests/r6_data.rs new file mode 100644 index 00000000..bd64e0f0 --- /dev/null +++ b/crates/oa-core/tests/r6_data.rs @@ -0,0 +1,1338 @@ +use oa_core::{ + data::*, + references, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn add(p: &mut Project, kind: Kind, name: &str, parent: Option) -> Id { + p.add_element(kind, Layer::Oa, name, parent).unwrap() +} +fn body(p: &mut Project, id: Id, body: SharedBody) { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body; +} +fn make_record(p: &mut Project, name: &str, supertype: Option) -> Id { + let id = add(p, Kind::DataType, name, None); + body( + p, + id, + SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + default: None, + unit: String::new(), + }, + ); + id +} +fn field( + p: &mut Project, + owner: Id, + name: &str, + type_ref: TypeRef, + direction: ParameterDirection, +) -> Id { + let id = add(p, Kind::DataField, name, Some(owner)); + body( + p, + id, + SharedBody::DataField { + type_ref, + minimum: 1, + maximum: Some(1), + unit: String::new(), + direction, + storage: FieldStorage::Value, + default: None, + }, + ); + id +} +fn edited(p: &Project, id: Id, change: impl FnOnce(&mut SharedBody)) -> Command { + let mut e = p.elements[&id].clone(); + change(e.shared_mut().unwrap()); + Command::EditElement(id, e) +} +fn valid(p: &Project) { + let errors: Vec<_> = validation::validate(p) + .into_iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} +fn code(p: &Project, code: &str, severity: Severity) -> bool { + validation::validate(p) + .iter() + .any(|i| i.code == code && i.severity == severity) +} +fn values(p: &Project, id: Id) -> &Vec { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements[&id].shared() + else { + panic!() + }; + values +} +#[allow(clippy::too_many_arguments)] +fn message( + p: &mut Project, + scenario: Id, + exchange: Id, + kind: MessageKind, + reply_to: Option, + name: &str, + values: Vec, +) -> Id { + let id = add(p, Kind::Occurrence, name, Some(scenario)); + body( + p, + id, + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: kind, + reply_to, + values, + }, + precondition: String::new(), + postcondition: String::new(), + }, + ); + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} +struct Payload { + p: Project, + item: Id, + record: Id, + leaf: Id, + origin: Id, + destination: Id, + exchange: Id, + call: Id, + reply: Id, +} +fn payload() -> Payload { + let mut p = Project::empty(); + let item = add(&mut p, Kind::ExchangeItem, "Move", None); + body( + &mut p, + item, + SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Operation, + supertype: None, + }, + ); + let record = make_record(&mut p, "Coordinate", None); + let leaf = field( + &mut p, + record, + "latitude", + TypeRef::Real, + ParameterDirection::Unspecified, + ); + let origin = field( + &mut p, + item, + "origin", + TypeRef::Named(record), + ParameterDirection::In, + ); + let destination = field( + &mut p, + item, + "destination", + TypeRef::Named(record), + ParameterDirection::Out, + ); + let a = add(&mut p, Kind::Activity, "Request movement", None); + let b = add(&mut p, Kind::Activity, "Perform movement", None); + let exchange = p.relate(RelationKind::Interaction, "Movement invocation", a, b); + if let RelationBody::Interaction { items, .. } = + &mut p.relations.get_mut(&exchange).unwrap().body + { + items.push(item); + } + let scenario = add(&mut p, Kind::Scenario, "Movement conversation", None); + let call = message( + &mut p, + scenario, + exchange, + MessageKind::Call, + None, + "Call", + vec![MessageValue { + path: vec![origin], + field: leaf, + expression: "45.0".into(), + }], + ); + let reply = message( + &mut p, + scenario, + exchange, + MessageKind::Reply, + Some(call), + "Reply", + vec![MessageValue { + path: vec![destination], + field: leaf, + expression: "46.0".into(), + }], + ); + valid(&p); + Payload { + p, + item, + record, + leaf, + origin, + destination, + exchange, + call, + reply, + } +} + +#[test] +fn scalar_bounds_defaults_unicode_units_and_failed_batches_are_exact() { + let mut p = Project::empty(); + let integer = add(&mut p, Kind::DataType, "Full precision integer", None); + body( + &mut p, + integer, + SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: Some(i64::MAX - 1), + maximum: Some(i64::MAX), + }, + default: Some(DataValue::Integer(i64::MAX)), + unit: "count".into(), + }, + ); + let text = add(&mut p, Kind::DataType, "Two scalars", None); + body( + &mut p, + text, + SharedBody::DataType { + definition: DataTypeDefinition::String { + minimum_length: 2, + maximum_length: Some(2), + }, + default: Some(DataValue::String("é🙂".into())), + unit: String::new(), + }, + ); + let real = add(&mut p, Kind::DataType, "Mass flow", None); + body( + &mut p, + real, + SharedBody::DataType { + definition: DataTypeDefinition::Real { + minimum: Some(0.0), + maximum: Some(25.0), + }, + default: Some(DataValue::Real(1.0)), + unit: "kg/s".into(), + }, + ); + let item = add(&mut p, Kind::ExchangeItem, "Fuel", None); + body( + &mut p, + item, + SharedBody::ExchangeItem { + kind: ExchangeItemKind::Material, + mechanism: ExchangeMechanism::Flow, + supertype: None, + }, + ); + let amount = field( + &mut p, + item, + "amount", + TypeRef::Named(real), + ParameterDirection::Unspecified, + ); + assert_eq!(effective_field_unit(&p, &p.elements[&amount]), Some("kg/s")); + assert_eq!( + effective_element_default(&p, &p.elements[&amount]), + Some(&DataValue::Real(1.0)) + ); + valid(&p); + assert!( + validate_data_value( + &p, + &TypeRef::Named(integer), + &DataValue::Integer(i64::MAX - 2) + ) + .is_err() + ); + assert!(validate_data_value(&p, &TypeRef::Named(real), &DataValue::Integer(1)).is_err()); + assert!(validate_data_value(&p, &TypeRef::Real, &DataValue::Real(f64::INFINITY)).is_err()); + assert!( + validate_data_value(&p, &TypeRef::Named(text), &DataValue::String("é".into())).is_err() + ); + let mut session = Session::new(p.clone()); + let invalid = edited(&p, integer, |s| { + if let SharedBody::DataType { default, .. } = s { + *default = Some(DataValue::Integer(i64::MIN)); + } + }); + assert!( + session + .execute(Command::Batch(vec![ + Command::Rename(item, "Unsaved name".into()), + invalid + ])) + .is_err() + ); + assert_eq!(session.project(), &p); + assert_eq!(session.revision(), 0); + assert!(!session.can_undo()); + let invalid = edited(&p, real, |s| { + if let SharedBody::DataType { definition, .. } = s { + *definition = DataTypeDefinition::Real { + minimum: Some(f64::NAN), + maximum: None, + }; + } + }); + assert!(session.execute(invalid).is_err()); + let empty = edited(&p, amount, |s| { + if let SharedBody::DataField { + minimum, default, .. + } = s + { + *minimum = 0; + *default = Some(vec![]); + } + }); + session.execute(empty).unwrap(); + valid(session.project()); + session.undo(); + assert_eq!(session.project(), &p); +} + +#[test] +fn enum_literal_edits_validate_type_and_field_defaults_in_one_candidate() { + let mut p = Project::empty(); + let enumeration = add(&mut p, Kind::DataType, "Mode", None); + body( + &mut p, + enumeration, + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { + literals: vec!["Ready".into(), "Busy".into()], + }, + default: Some(DataValue::Enumeration("Ready".into())), + unit: String::new(), + }, + ); + let item = add(&mut p, Kind::ExchangeItem, "Status", None); + let mode = field( + &mut p, + item, + "mode", + TypeRef::Named(enumeration), + ParameterDirection::Unspecified, + ); + if let Some(SharedBody::DataField { default, .. }) = + p.elements.get_mut(&mode).unwrap().shared_mut() + { + *default = Some(vec![DataValue::Enumeration("Ready".into())]); + } + let mut session = Session::new(p.clone()); + let change_type = edited(&p, enumeration, |s| { + if let SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + default, + .. + } = s + { + literals[0] = "Idle".into(); + *default = Some(DataValue::Enumeration("Idle".into())); + } + }); + assert!(session.execute(change_type.clone()).is_err()); + assert_eq!(session.project(), &p); + let change_field = edited(&p, mode, |s| { + if let SharedBody::DataField { default, .. } = s { + *default = Some(vec![DataValue::Enumeration("Idle".into())]); + } + }); + session + .execute(Command::Batch(vec![change_type, change_field])) + .unwrap(); + valid(session.project()); + session.undo(); + assert_eq!(session.project(), &p); +} + +#[test] +fn an_empty_enumeration_is_a_located_incomplete_draft() { + let mut p = Project::empty(); + let enumeration = add(&mut p, Kind::DataType, "Pending values", None); + body( + &mut p, + enumeration, + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals: vec![] }, + default: None, + unit: String::new(), + }, + ); + valid(&p); + assert!( + validation::validate(&p) + .iter() + .any(|issue| issue.code == "empty_enumeration" + && issue.severity == Severity::Warning + && issue.element == Some(enumeration) + && issue.field.as_deref() == Some("literals")) + ); + Session::try_new(p).unwrap(); +} + +#[test] +fn inherited_interfaces_and_item_specializations_satisfy_typed_port_contracts() { + let mut p = Project::empty(); + let a = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let b = p + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let ca = p + .add_element(Kind::System, Layer::Sa, "Client", None) + .unwrap(); + let cb = p + .add_element(Kind::SystemActor, Layer::Sa, "Provider", None) + .unwrap(); + p.relate(RelationKind::Allocation, "Client work", ca, a); + p.relate(RelationKind::Allocation, "Provider work", cb, b); + let item = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Base item", None) + .unwrap(); + let specialized = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Specialized item", None) + .unwrap(); + body( + &mut p, + specialized, + SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Unspecified, + supertype: Some(item), + }, + ); + let fe = Id::new_v4(); + let request = FunctionalExchangeRequest { + id: fe, + alias: "specialized_exchange".into(), + name: "Specialized exchange".into(), + description: String::new(), + source: a, + target: b, + source_port: PortChoice::Create, + target_port: PortChoice::Create, + items: vec![item], + }; + p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap(); + let mut changed = p.relations[&fe].clone(); + if let RelationBody::FunctionalExchange { items, .. } = &mut changed.body { + *items = vec![specialized]; + } + p = execute_batch(&p, &[Command::EditRelation(fe, changed)]).unwrap(); + let boundary = Id::new_v4(); + let request = ComponentExchangeRequest { + functional_exchange: fe, + boundary: ComponentExchangeChoice::Create { + id: boundary, + alias: "service_boundary".into(), + name: "Service boundary".into(), + description: String::new(), + port_kind: ComponentPortKind::Standard, + oriented: false, + source_port: PortChoice::Create, + target_port: PortChoice::Create, + }, + }; + p = execute_batch(&p, &plan_component_exchange(&p, &request).unwrap()).unwrap(); + let interface = p + .add_element(Kind::Interface, Layer::Sa, "Service", None) + .unwrap(); + let extended = p + .add_element(Kind::Interface, Layer::Sa, "Extended service", None) + .unwrap(); + body( + &mut p, + extended, + SharedBody::Interface { + supertype: Some(interface), + }, + ); + p.relate( + RelationKind::InterfaceItemAllocation, + "Service item", + interface, + item, + ); + let (source, target) = p.relations[&boundary].body.endpoints(); + p.relate( + RelationKind::InterfaceRequirement, + "Required service", + source, + interface, + ); + let provider = p.relate( + RelationKind::InterfaceProvision, + "Provided service", + target, + extended, + ); + valid(&p); + assert!(!code( + &p, + "unsatisfied_interface_requirement", + Severity::Warning + )); + let unrelated = p + .add_element(Kind::Interface, Layer::Sa, "Unrelated service", None) + .unwrap(); + p.relate( + RelationKind::InterfaceItemAllocation, + "Other item contract", + unrelated, + item, + ); + p.relations + .get_mut(&provider) + .unwrap() + .body + .set_endpoints(target, unrelated); + valid(&p); + assert!(code( + &p, + "unsatisfied_interface_requirement", + Severity::Warning + )); +} + +#[test] +fn repeated_record_paths_enforce_call_reply_phase_and_survive_identity_changes() { + let Payload { + p, + item, + record, + leaf, + origin, + destination, + exchange, + call, + reply, + } = payload(); + let paths = carried_field_paths(&p, &p.relations[&exchange]); + assert_eq!(paths.iter().filter(|f| f.field == leaf).count(), 2); + let mut session = Session::new(p.clone()); + let wrong = edited(&p, call, |s| { + if let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + } = s + { + values[0].path = vec![destination]; + } + }); + assert!( + session + .execute(wrong) + .unwrap_err() + .0 + .contains("message_parameter_phase") + ); + assert_eq!(session.project(), &p); + let new_origin = Id::new_v4(); + let new_leaf = Id::new_v4(); + session + .execute(Command::Batch(vec![ + Command::Reidentify(origin, new_origin), + Command::Reidentify(leaf, new_leaf), + ])) + .unwrap(); + assert_eq!(values(session.project(), call)[0].path, vec![new_origin]); + assert_eq!(values(session.project(), call)[0].field, new_leaf); + assert_eq!(values(session.project(), reply)[0].field, new_leaf); + session.undo(); + assert_eq!(session.project(), &p); + session.execute(Command::Delete(origin)).unwrap(); + assert!(values(session.project(), call).is_empty()); + assert_eq!(values(session.project(), reply).len(), 1); + assert!(session.project().elements.contains_key(&record)); + assert!(session.project().elements.contains_key(&leaf)); + session.undo(); + assert_eq!(session.project(), &p); + let reference = edited(&p, origin, |s| { + if let SharedBody::DataField { storage, .. } = s { + *storage = FieldStorage::Reference; + } + }); + assert!( + session + .execute(reference) + .unwrap_err() + .0 + .contains("message_value_field") + ); + let direction = edited(&p, origin, |s| { + if let SharedBody::DataField { direction, .. } = s { + *direction = ParameterDirection::Unspecified; + } + }); + let generic = edited(&p, item, |s| { + if let SharedBody::ExchangeItem { mechanism, .. } = s { + *mechanism = ExchangeMechanism::Flow; + } + }); + assert!( + session + .execute(Command::Batch(vec![direction, generic])) + .is_err() + ); +} + +#[test] +fn ambiguous_legacy_annotations_warn_and_reference_fields_stop_payload_descent() { + let Payload { + mut p, + origin, + destination, + call, + reply, + leaf, + exchange, + .. + } = payload(); + for message in [call, reply] { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&message).unwrap().shared_mut() + { + values[0].path.clear(); + } + } + valid(&p); + assert!(code(&p, "message_value_ambiguous_path", Severity::Warning)); + if let Some(SharedBody::DataField { storage, .. }) = + p.elements.get_mut(&origin).unwrap().shared_mut() + { + *storage = FieldStorage::Reference; + } + let paths = carried_field_paths(&p, &p.relations[&exchange]); + assert!(paths.iter().any(|f| f.field == origin && f.path.is_empty())); + assert_eq!( + paths + .iter() + .filter(|f| f.field == leaf) + .map(|f| &f.path) + .collect::>(), + vec![&vec![destination]] + ); +} + +#[test] +fn identified_allocations_keep_protocols_order_artifacts_and_inherited_identity() { + let mut p = Project::empty(); + let item = add(&mut p, Kind::ExchangeItem, "Telemetry", None); + let other_item = add(&mut p, Kind::ExchangeItem, "Command", None); + let first = add(&mut p, Kind::Interface, "Primary", None); + let second = add(&mut p, Kind::Interface, "Secondary", None); + let derived = add(&mut p, Kind::Interface, "Specialized primary", None); + body( + &mut p, + derived, + SharedBody::Interface { + supertype: Some(first), + }, + ); + let a = p.relate( + RelationKind::InterfaceItemAllocation, + "Primary telemetry", + first, + item, + ); + let b = p.relate( + RelationKind::InterfaceItemAllocation, + "Secondary telemetry", + second, + item, + ); + if let RelationBody::InterfaceItemAllocation { + transmission, + acquisition, + .. + } = &mut p.relations.get_mut(&a).unwrap().body + { + *transmission = TransmissionProtocol::Synchronous; + *acquisition = AcquisitionProtocol::Read; + } + if let RelationBody::InterfaceItemAllocation { + transmission, + order, + .. + } = &mut p.relations.get_mut(&b).unwrap().body + { + *transmission = TransmissionProtocol::Broadcast; + *order = 4; + } + let local = p.relate( + RelationKind::InterfaceItemAllocation, + "Local command", + derived, + other_item, + ); + assert_eq!( + effective_interface_allocations(&p, derived) + .iter() + .map(|r| r.id) + .collect::>(), + vec![a, local] + ); + assert!(interface_satisfies(&p, derived, first)); + assert!(!interface_satisfies(&p, first, derived)); + let artifact_id = Id::new_v4(); + p.artifact_links.insert( + artifact_id, + ArtifactLink { + id: artifact_id, + alias: "allocation_spec".into(), + name: "Protocol specification".into(), + description: String::new(), + owner: a, + relation: ArtifactRelation::References, + uri: "https://example.org/protocol".into(), + revision: None, + fragment: None, + }, + ); + valid(&p); + let mut session = Session::new(p.clone()); + let duplicate = Relation { + id: Id::new_v4(), + alias: "duplicate_allocation".into(), + name: "Duplicate inherited item".into(), + description: String::new(), + body: RelationBody::new(RelationKind::InterfaceItemAllocation, derived, item), + }; + assert!(session.execute(Command::CreateRelation(duplicate)).is_err()); + let impact = references::deletion_impact(&p, a); + assert!(impact.contains(&artifact_id)); + assert!(!impact.contains(&item)); + assert!(!impact.contains(&b)); + session.execute(Command::Delete(a)).unwrap(); + assert_eq!( + interface_items(session.project(), derived), + vec![other_item] + ); + assert_eq!(session.project().relations[&b], p.relations[&b]); + assert!(session.project().elements.contains_key(&item)); + session.undo(); + assert_eq!(session.project(), &p); + let new_item = Id::new_v4(); + session + .execute(Command::Reidentify(item, new_item)) + .unwrap(); + assert_eq!(session.project().relations[&a].target(), new_item); + assert_eq!(session.project().relations[&b].target(), new_item); +} + +#[test] +fn specialization_preserves_fields_rejects_cycles_and_prunes_removed_inherited_paths() { + let Payload { + mut p, + item, + record, + leaf, + origin, + call, + reply, + .. + } = payload(); + let specialized = make_record(&mut p, "Refined coordinate", Some(record)); + if let Some(SharedBody::DataField { type_ref, .. }) = + p.elements.get_mut(&origin).unwrap().shared_mut() + { + *type_ref = TypeRef::Named(specialized); + } + valid(&p); + assert_eq!(effective_fields(&p, specialized)[0].id, leaf); + assert_eq!(p.elements[&leaf].parent, Some(record)); + let mut session = Session::new(p.clone()); + let unbase = edited(&p, specialized, |s| { + if let SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } = s + { + *supertype = None; + } + }); + let mut explicit = p.clone(); + if let Command::EditElement(id, e) = &unbase { + explicit.elements.insert(*id, e.clone()); + } + assert!( + session + .execute(Command::Replace(explicit)) + .unwrap_err() + .0 + .contains("message_value_field") + ); + assert_eq!(session.project(), &p); + session.execute(unbase).unwrap(); + assert!(values(session.project(), call).is_empty()); + assert_eq!(values(session.project(), reply).len(), 1); + session.undo(); + assert_eq!(session.project(), &p); + let cycle = edited(&p, record, |s| { + if let SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } = s + { + *supertype = Some(specialized); + } + }); + assert!( + session + .execute(cycle) + .unwrap_err() + .0 + .contains("data_inheritance_cycle") + ); + let shadow = field( + &mut p, + specialized, + "latitude", + TypeRef::Real, + ParameterDirection::Unspecified, + ); + assert!(code(&p, "inherited_field_shadow", Severity::Error)); + p.elements.remove(&shadow); + let derived_item = add(&mut p, Kind::ExchangeItem, "Precise move", None); + body( + &mut p, + derived_item, + SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Operation, + supertype: Some(item), + }, + ); + assert!(oa_core::item_satisfies(&p, derived_item, item)); + assert!(!oa_core::item_satisfies(&p, item, derived_item)); + let result = field( + &mut p, + item, + "result", + TypeRef::Boolean, + ParameterDirection::Return, + ); + field( + &mut p, + derived_item, + "second result", + TypeRef::Boolean, + ParameterDirection::Return, + ); + assert!(code(&p, "multiple_return_parameters", Severity::Error)); + assert_eq!(p.elements[&result].parent, Some(item)); +} + +#[test] +fn removing_inheritance_prunes_legacy_annotations_only_when_every_reachable_path_disappears() { + let Payload { + mut p, + record, + origin, + destination, + call, + reply, + .. + } = payload(); + let derived = make_record(&mut p, "Inherited arguments", Some(record)); + for id in [origin, destination] { + if let Some(SharedBody::DataField { type_ref, .. }) = + p.elements.get_mut(&id).unwrap().shared_mut() + { + *type_ref = TypeRef::Named(derived); + } + } + for id in [call, reply] { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&id).unwrap().shared_mut() + { + values[0].path.clear(); + } + } + valid(&p); + assert!(code(&p, "message_value_ambiguous_path", Severity::Warning)); + let remove = edited(&p, derived, |s| { + if let SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } = s + { + *supertype = None; + } + }); + let mut session = Session::new(p.clone()); + session.execute(remove).unwrap(); + assert!(values(session.project(), call).is_empty()); + assert!(values(session.project(), reply).is_empty()); + assert!(session.project().elements.contains_key(&record)); + session.undo(); + assert_eq!(session.project(), &p); +} + +#[test] +fn recursive_value_types_warn_while_optional_reference_cycles_and_associations_are_distinct() { + let mut p = Project::empty(); + let a = make_record(&mut p, "Node", None); + let b = make_record(&mut p, "Envelope", None); + let next = field( + &mut p, + a, + "next", + TypeRef::Named(a), + ParameterDirection::Unspecified, + ); + field( + &mut p, + b, + "root", + TypeRef::Named(a), + ParameterDirection::Unspecified, + ); + valid(&p); + assert!(code(&p, "record_no_finite_value", Severity::Warning)); + if let Some(SharedBody::DataField { minimum, .. }) = + p.elements.get_mut(&next).unwrap().shared_mut() + { + *minimum = 0; + } + assert!(!code(&p, "record_no_finite_value", Severity::Warning)); + if let Some(SharedBody::DataField { + minimum, storage, .. + }) = p.elements.get_mut(&next).unwrap().shared_mut() + { + *minimum = 1; + *storage = FieldStorage::Reference; + } + assert!(!code(&p, "record_no_finite_value", Severity::Warning)); + let association = p.relate(RelationKind::DataAssociation, "Parent to children", a, a); + if let RelationBody::DataAssociation { + source_role, + target_role, + source_navigable, + target_navigable, + .. + } = &mut p.relations.get_mut(&association).unwrap().body + { + *source_role = "parent".into(); + *target_role = "children".into(); + *source_navigable = true; + *target_navigable = true; + } + valid(&p); + let mut duplicate = p.relations[&association].clone(); + duplicate.id = Id::new_v4(); + duplicate.alias = "second_role_contract".into(); + p.relations.insert(duplicate.id, duplicate); + valid(&p); + assert!(code(&p, "duplicate_association_roles", Severity::Warning)); + let mut session = Session::new(p.clone()); + let mut invalid = p.relations[&association].clone(); + if let RelationBody::DataAssociation { target_role, .. } = &mut invalid.body { + *target_role = "parent".into(); + } + assert!( + session + .execute(Command::EditRelation(association, invalid)) + .is_err() + ); + session.execute(Command::Delete(association)).unwrap(); + assert!(session.project().elements.contains_key(&a)); + assert!(session.project().elements.contains_key(&next)); +} + +#[test] +fn a_self_association_is_visible_with_neutral_connector_and_real_role_contracts() { + let mut p = Project::empty(); + let record = make_record(&mut p, "Tree node", None); + let relation = p.relate( + RelationKind::DataAssociation, + "Parent-child reference", + record, + record, + ); + if let RelationBody::DataAssociation { + source_role, + target_role, + source_maximum, + target_navigable, + .. + } = &mut p.relations.get_mut(&relation).unwrap().body + { + *source_role = "parent".into(); + *target_role = "children".into(); + *source_maximum = Some(1); + *target_navigable = true; + } + let diagram = p.ensure_diagram(Layer::Oa); + p.place(diagram, record, Position { x: 100., y: 100. }); + let item = add( + &mut p, + Kind::ExchangeItem, + "Nested allocation payload", + None, + ); + let interface = add(&mut p, Kind::Interface, "Nested allocation owner", None); + let allocation = p.relate( + RelationKind::InterfaceItemAllocation, + "Nested contract row", + interface, + item, + ); + p.place(diagram, item, Position { x: 600., y: 100. }); + p.place(diagram, interface, Position { x: 600., y: 300. }); + let view = oa_core::projection::project_view(&p, diagram); + assert!(!view.edges.iter().any(|e| e.id == allocation)); + let projected = view.edges.iter().find(|e| e.id == relation).unwrap(); + assert_eq!(projected.source, projected.target); + assert!(!projected.oriented); + let routed = oa_core::routing::route_diagram(&p, &p.diagrams[&diagram], &view); + let edge = &routed.edges[&relation]; + assert!(edge.points.len() > 3); + assert_ne!(edge.points.first(), edge.points.last()); + assert_eq!(edge.label.text, "Parent-child reference"); + assert_eq!( + association_end_label(&p.relations[&relation], true).unwrap(), + "parent [0..1]" + ); + assert_eq!( + association_end_label(&p.relations[&relation], false).unwrap(), + "children [0..*]" + ); + let ends = oa_core::routing::association_end_geometry(&p.relations[&relation], edge); + assert_eq!(ends.len(), 2); + assert!(ends[0].source_end); + assert!(!ends[0].navigable); + assert!(ends[1].navigable); + assert_eq!(ends[0].tip, *edge.points.first().unwrap()); + assert_eq!(ends[1].tip, *edge.points.last().unwrap()); + assert_ne!(ends[0].label.center, ends[1].label.center); + for end in &ends { + let b = end.label.bounds; + assert!( + b.position.x >= routed.bounds.position.x && b.position.y >= routed.bounds.position.y + ); + assert!( + b.position.x + b.size.width + <= routed.bounds.position.x + routed.bounds.size.width + 0.01 + ); + assert!( + b.position.y + b.size.height + <= routed.bounds.position.y + routed.bounds.size.height + 0.01 + ); + } + let mut changed = p.relations[&relation].clone(); + if let RelationBody::DataAssociation { + source_navigable, + target_navigable, + .. + } = &mut changed.body + { + *source_navigable = true; + *target_navigable = false; + } + let changed_ends = oa_core::routing::association_end_geometry(&changed, edge); + assert!(changed_ends[0].navigable); + assert!(!changed_ends[1].navigable); + assert_eq!(changed_ends[0].tip, ends[0].tip); + assert_eq!(changed_ends[1].tip, ends[1].tip); + for points in [ + vec![Position { x: 0., y: 0. }, Position { x: 12., y: 0. }], + vec![ + Position { x: 0., y: 0. }, + Position { x: 18., y: 0. }, + Position { x: 18., y: 5. }, + Position { x: 0., y: 5. }, + ], + ] { + let mut short = edge.clone(); + short.points = points; + let labels = oa_core::routing::association_end_geometry(&changed, &short); + let a = labels[0].label.bounds; + let b = labels[1].label.bounds; + assert!( + a.position.x + a.size.width <= b.position.x + || b.position.x + b.size.width <= a.position.x + || a.position.y + a.size.height <= b.position.y + || b.position.y + b.size.height <= a.position.y, + "End labels overlap on a short or self route: {labels:?}" + ); + } +} + +#[test] +fn empty_record_collections_are_supported_but_record_literals_and_reference_defaults_are_not() { + let mut p = Project::empty(); + let record = make_record(&mut p, "Collection member", None); + let item = add(&mut p, Kind::ExchangeItem, "Collection", None); + let collection = field( + &mut p, + item, + "members", + TypeRef::Named(record), + ParameterDirection::Unspecified, + ); + if let Some(SharedBody::DataField { + minimum, + maximum, + default, + .. + }) = p.elements.get_mut(&collection).unwrap().shared_mut() + { + *minimum = 0; + *maximum = None; + *default = Some(vec![]); + } + valid(&p); + let mut session = Session::new(p.clone()); + let reference = edited(&p, collection, |s| { + if let SharedBody::DataField { storage, .. } = s { + *storage = FieldStorage::Reference; + } + }); + assert!( + session + .execute(reference) + .unwrap_err() + .0 + .contains("reference_field_contract") + ); + let literal = edited(&p, collection, |s| { + if let SharedBody::DataField { default, .. } = s { + *default = Some(vec![DataValue::String("{}".into())]); + } + }); + assert!(session.execute(literal).is_err()); + assert_eq!(session.project(), &p); +} + +#[test] +fn exponential_payload_expansion_is_reported_and_recursive_finite_paths_remain_valid() { + let mut p = Project::empty(); + let item = add(&mut p, Kind::ExchangeItem, "Bounded payload", None); + let mut owner = item; + for depth in 0..15 { + let next = make_record(&mut p, &format!("Level {depth}"), None); + field( + &mut p, + owner, + "left", + TypeRef::Named(next), + ParameterDirection::Unspecified, + ); + field( + &mut p, + owner, + "right", + TypeRef::Named(next), + ParameterDirection::Unspecified, + ); + owner = next; + } + let a = add(&mut p, Kind::Activity, "A", None); + let b = add(&mut p, Kind::Activity, "B", None); + let exchange = p.relate(RelationKind::Interaction, "Expanded exchange", a, b); + if let RelationBody::Interaction { items, .. } = + &mut p.relations.get_mut(&exchange).unwrap().body + { + items.push(item); + } + assert!(try_carried_field_paths(&p, &p.relations[&exchange]).is_err()); + assert!(code(&p, "data_path_expansion_limit", Severity::Error)); + let mut p = Project::empty(); + let item = add(&mut p, Kind::ExchangeItem, "Recursive payload", None); + let record = make_record(&mut p, "Node", None); + let root = field( + &mut p, + item, + "root", + TypeRef::Named(record), + ParameterDirection::Unspecified, + ); + let next = field( + &mut p, + record, + "next", + TypeRef::Named(record), + ParameterDirection::Unspecified, + ); + if let Some(SharedBody::DataField { minimum, .. }) = + p.elements.get_mut(&next).unwrap().shared_mut() + { + *minimum = 0; + } + let a = add(&mut p, Kind::Activity, "A", None); + let b = add(&mut p, Kind::Activity, "B", None); + let exchange = p.relate(RelationKind::Interaction, "Recursive exchange", a, b); + if let RelationBody::Interaction { items, .. } = + &mut p.relations.get_mut(&exchange).unwrap().body + { + items.push(item); + } + valid(&p); + assert!(try_carried_field_paths(&p, &p.relations[&exchange]).is_ok()); + assert!(carried_value_path(&p, &p.relations[&exchange], &[root, next, next], next).is_some()); + assert_eq!( + resolve_legacy_field_path(&p, &p.relations[&exchange], next).unwrap(), + LegacyFieldPathResolution::Ambiguous + ); + let scenario = add(&mut p, Kind::Scenario, "Recursive annotation", None); + message( + &mut p, + scenario, + exchange, + MessageKind::Asynchronous, + None, + "Legacy recursive value", + vec![MessageValue { + path: vec![], + field: next, + expression: "descriptive annotation".into(), + }], + ); + valid(&p); + assert!(code(&p, "message_value_ambiguous_path", Severity::Warning)); +} + +#[test] +fn initial_transition_copies_qualified_paths_inheritance_allocations_and_associations() { + let Payload { + mut p, + item, + record, + origin, + leaf, + call, + .. + } = payload(); + let base = make_record(&mut p, "Reference base", None); + if let Some(SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + }) = p.elements.get_mut(&record).unwrap().shared_mut() + { + *supertype = Some(base); + } + let interface = add(&mut p, Kind::Interface, "Movement service", None); + let allocation = p.relate( + RelationKind::InterfaceItemAllocation, + "Service operation", + interface, + item, + ); + let association = p.relate( + RelationKind::DataAssociation, + "Coordinate relation", + record, + base, + ); + let scenario = p.elements[&call].parent.unwrap(); + let mut request = + TransitionRequest::new(TransitionDirection::OaToSa, vec![scenario, interface]); + request.scope = DependencyScope::Required; + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let candidate = apply_transition(&p, &plan).unwrap(); + valid(&candidate); + let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target; + assert_eq!( + values(&candidate, mapped(call))[0].path, + vec![mapped(origin)] + ); + assert_eq!(values(&candidate, mapped(call))[0].field, mapped(leaf)); + assert_eq!( + candidate.relations[&mapped(allocation)].source(), + mapped(interface) + ); + assert_eq!( + candidate.relations[&mapped(association)].target(), + mapped(base) + ); + assert_eq!( + supertype(&candidate.elements[&mapped(record)]), + Some(mapped(base)) + ); + assert_eq!(p.elements[&leaf].parent, Some(record)); +} + +#[test] +fn required_dictionary_closure_does_not_expand_through_incident_associations() { + let mut p = Project::empty(); + let a = make_record(&mut p, "A", None); + let b = make_record(&mut p, "B", None); + let c = make_record(&mut p, "C", None); + let ab = p.relate(RelationKind::DataAssociation, "A to B", a, b); + let bc = p.relate(RelationKind::DataAssociation, "B to C", b, c); + for (sources, expected, omitted) in [ + (vec![a], vec![a], vec![b, c, ab, bc]), + (vec![a, b], vec![a, b, ab], vec![c, bc]), + (vec![ab], vec![a, b, ab], vec![c, bc]), + ] { + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, sources); + request.scope = DependencyScope::Required; + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let mapped: std::collections::BTreeSet<_> = + plan.changes.iter().map(|change| change.source).collect(); + assert_eq!(mapped, expected.into_iter().collect()); + for id in omitted { + assert!(!mapped.contains(&id)); + } + valid(&apply_transition(&p, &plan).unwrap()); + } +} + +#[test] +#[ignore = "explicit inheritance scaling probe; run with --ignored --nocapture"] +fn inheritance_chain_validation_scaling_probe() { + for count in [250, 500, 1000] { + let mut p = Project::empty(); + let mut base = None; + for index in 0..count { + base = Some(make_record(&mut p, &format!("Record {index}"), base)); + } + let started = std::time::Instant::now(); + valid(&p); + eprintln!( + "R6 inheritance validation {count} records: {:.3} ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + } +} diff --git a/crates/oa-core/tests/release_ports.rs b/crates/oa-core/tests/release_ports.rs index 30194ef6..c2bee3f1 100644 --- a/crates/oa-core/tests/release_ports.rs +++ b/crates/oa-core/tests/release_ports.rs @@ -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(); 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] }; + p.relate( + RelationKind::InterfaceItemAllocation, + "Interface payload", + interface, + item, + ); let requirement = p.relate( RelationKind::InterfaceRequirement, "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 (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.relate( + RelationKind::InterfaceItemAllocation, + "Interface payload", + interface, + 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, @@ -659,11 +661,12 @@ 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.relate( + RelationKind::InterfaceItemAllocation, + "Interface payload", + interface, + item, + ); *p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { diff --git a/crates/oa-core/tests/release_scenarios.rs b/crates/oa-core/tests/release_scenarios.rs index 5e45cdf9..516c076a 100644 --- a/crates/oa-core/tests/release_scenarios.rs +++ b/crates/oa-core/tests/release_scenarios.rs @@ -94,7 +94,9 @@ fn fixture(layer: Layer) -> Fixture { .add_element(Kind::DataType, layer, "Request record", None) .unwrap(); *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 .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() { values.push(MessageValue { + path: vec![payload], field, expression: "requested_level".into(), }); diff --git a/crates/oa-core/tests/workflows.rs b/crates/oa-core/tests/workflows.rs index 7a57dbd6..a6759939 100644 --- a/crates/oa-core/tests/workflows.rs +++ b/crates/oa-core/tests/workflows.rs @@ -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 item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None); let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None); - *p.elements - .get_mut(&interface) - .unwrap() - .shared_mut() - .unwrap() = SharedBody::Interface { items: vec![item] }; + p.relate( + RelationKind::InterfaceItemAllocation, + "Interface payload", + interface, + item, + ); let port = |p: &mut Project, name: &str, parent, direction| { let id = Id::new_v4(); 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, maximum: None, unit: "items".into(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, }; let function = add(&mut p, Kind::Function, Layer::Sa, "Function", 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" ); if id != p.id { - let deleted = execute_batch(&p, &[Command::Delete(id)]) - .unwrap_or_else(|err| panic!("Delete {id}: {err}")); + let deleted = if p + .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); } } @@ -890,13 +926,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() { assert!(p.elements.values().any(|e| matches!( e.shared(), Some(SharedBody::ExchangeItem { - kind: ExchangeItemKind::Material + kind: ExchangeItemKind::Material, + .. }) ))); assert!(p.elements.values().any(|e| matches!( e.shared(), Some(SharedBody::ExchangeItem { - kind: ExchangeItemKind::Energy + kind: ExchangeItemKind::Energy, + .. }) ))); }