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

819 lines
30 KiB
Rust

//! Contextual controls for the closed port model. Draft-only preservation never
//! changes the persisted contract or substitutes for candidate validation.
use super::*;
fn constructor(value: &Value) -> Option<&str> {
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
}
pub(super) fn switch_contract(
current: &Value,
proposed: Value,
saved: &mut BTreeMap<String, Value>,
) -> Value {
let (Some(previous), Some(next)) = (constructor(current), constructor(&proposed)) else {
return proposed;
};
if previous == next {
return proposed;
}
saved.insert(previous.into(), current.clone());
if let Some(existing) = saved.get(next) {
return existing.clone();
}
// Direction changes retain the item declaration; switching constructors
// must not silently replace an authored payload with an empty template.
let items = current
.pointer("/FunctionInput/incoming_items")
.or_else(|| current.pointer("/FunctionOutput/outgoing_items"));
match (next, items) {
("FunctionInput", Some(items)) => json!({"FunctionInput":{"incoming_items":items}}),
("FunctionOutput", Some(items)) => json!({"FunctionOutput":{"outgoing_items":items}}),
_ => proposed,
}
}
pub(super) fn variants(
draft: &Draft,
project: &Project,
path: &str,
_value: &Value,
) -> Option<Vec<(String, Value)>> {
if path.ends_with("/Port/contract") {
let current = draft.context_value();
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
}
let owner = current
.get("parent")
.and_then(Value::as_str)
.and_then(|id| id.parse::<Id>().ok())
.and_then(|id| project.elements.get(&id));
let mut choices = vec![];
if owner.is_none_or(|owner| owner.kind() == Kind::Function) {
choices.push((
"Function input".into(),
json!({"FunctionInput":{"incoming_items":[]}}),
));
choices.push((
"Function output".into(),
json!({"FunctionOutput":{"outgoing_items":[]}}),
));
}
if owner.is_none_or(|owner| matches!(owner.kind(), Kind::System | Kind::SystemActor)) {
choices.push((
"Component port".into(),
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
));
}
return Some(choices);
}
if path.ends_with("/Port/contract/Component/kind") {
return Some(
["Standard", "Flow"]
.into_iter()
.map(|kind| (kind.into(), json!(kind)))
.collect(),
);
}
if path.ends_with("/Port/contract/Component/orientation") {
let current = draft.context_value();
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
let orientations: &[&str] =
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
&["Unset"]
} else {
&["Unset", "In", "Out", "InOut"]
};
return Some(
orientations
.iter()
.map(|orientation| {
let label = if *orientation == "Unset" && orientations.len() == 1 {
"Not applicable to Standard".into()
} else {
friendly(orientation)
};
(label, json!(orientation))
})
.collect(),
);
}
None
}
pub(super) fn legacy_lines(value: &Value, project: &Project) -> Vec<String> {
let direction = value
.get("direction")
.and_then(Value::as_str)
.map(friendly)
.unwrap_or_else(|| "Unspecified".into());
let interface = value
.get("interface")
.and_then(Value::as_str)
.and_then(|id| id.parse::<Id>().ok());
let association = interface
.map(|id| {
project
.elements
.get(&id)
.map(|e| format!("{} · @{}", e.name, e.alias))
.unwrap_or_else(|| format!("Missing interface · {id}"))
})
.unwrap_or_else(|| "No interface was associated".into());
vec![format!("Original direction: {direction}"), format!("Original interface: {association}"), "Retained from the generic model; this association does not declare a provided or required interface.".into()]
}
fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
let interfaces: Vec<_> = project
.relations
.values()
.filter(|role| {
matches!(
role.kind(),
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
) && role.source() == port
})
.map(Relation::target)
.collect();
if interfaces.is_empty() {
return true;
}
let declared: Vec<_> = interfaces
.iter()
.flat_map(|id| data::interface_items(project, *id))
.collect();
items_satisfy(project, items, &declared)
}
fn boundary_pair(project: &Project, relation: &Relation, source: Id, target: Id) -> bool {
let RelationBody::BoundaryExchange {
oriented, items, ..
} = &relation.body
else {
return false;
};
let (Some(a), Some(b)) = (project.elements.get(&source), project.elements.get(&target)) else {
return false;
};
if source == target
|| a.layer() != b.layer()
|| a.parent == b.parent
|| [a, b].iter().any(|port| {
!port
.parent
.and_then(|id| project.elements.get(&id))
.is_some_and(|owner| owner.kind().is_performer())
})
{
return false;
}
let (Some(ac), Some(bc)) = (port_contract(a), port_contract(b)) else {
return false;
};
let direction = |e: &Element, contract: &PortContract, outgoing| match contract {
PortContract::Component { .. } => !*oriented || component_allows(contract, outgoing),
PortContract::Legacy => {
matches!(e.shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { direction, .. }), .. }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
}
_ => false,
};
if !direction(a, ac, true) || !direction(b, bc, false) {
return false;
}
if let (PortContract::Component { kind: ak, .. }, PortContract::Component { kind: bk, .. }) =
(ac, bc)
{
if ak != bk {
return false;
}
if !*oriented
&& *ak == ComponentPortKind::Flow
&& !((component_allows(ac, true) && component_allows(bc, false))
|| (component_allows(ac, false) && component_allows(bc, true)))
{
return false;
}
}
component_items_fit(project, source, items) && component_items_fit(project, target, items)
}
pub(super) fn relation_endpoints(
project: &Project,
relation: &Relation,
field: &str,
original: Id,
) -> Option<Vec<Id>> {
if !matches!(
relation.kind(),
RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::PortAllocation
| RelationKind::InterfaceProvision
| RelationKind::InterfaceRequirement
) {
return None;
}
let source_field = field == "source";
let other = if source_field {
relation.target()
} else {
relation.source()
};
if relation.kind() == RelationKind::FunctionalExchange {
return Some(
project
.elements
.values()
.filter(|e| e.kind() == Kind::Function && e.layer() == Layer::Sa && e.id != other)
.map(|e| e.id)
.collect(),
);
}
let eligible = |e: &Element, source: bool| match relation.kind() {
RelationKind::BoundaryExchange => {
e.layer() == Layer::Sa
&& matches!(port_contract(e), Some(PortContract::Component { .. }))
}
RelationKind::PortAllocation => {
e.layer() == Layer::Sa
&& if source {
matches!(port_contract(e), Some(PortContract::Component { .. }))
} else {
port_contract(e).is_some_and(PortContract::is_function)
}
}
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement => {
if source {
validation::can_create_interface_role(project, e.id)
} else {
e.kind() == Kind::Interface
}
}
_ => false,
};
let mut context = project.clone();
context.relations.remove(&original);
let permits = |candidate: Id, peer: Id| {
let (source, target) = if source_field {
(candidate, peer)
} else {
(peer, candidate)
};
if relation.kind() == RelationKind::BoundaryExchange {
boundary_pair(project, relation, source, target)
} else {
validation::can_connect(&context, relation.kind(), source, target)
}
};
Some(
project
.elements
.values()
.filter(|e| eligible(e, source_field))
.filter(|e| {
if project.elements.contains_key(&other) {
permits(e.id, other)
} else {
project
.elements
.values()
.filter(|peer| eligible(peer, !source_field))
.any(|peer| permits(e.id, peer.id))
}
})
.map(|e| e.id)
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
const CONTRACT: &str = "/body/Sa/Shared/Port/contract";
fn add(project: &mut Project, kind: Kind, name: &str) -> Id {
project.add_element(kind, Layer::Sa, name, None).unwrap()
}
fn port(project: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
let id = Id::new_v4();
let element = make_port(project, id, owner, name, contract).unwrap();
project.elements.insert(id, element);
id
}
fn component(
project: &mut Project,
owner: Id,
kind: ComponentPortKind,
orientation: ComponentPortOrientation,
) -> Id {
port(
project,
owner,
"Boundary port",
PortContract::Component { kind, orientation },
)
}
fn function_port(project: &mut Project, owner: Id, output: bool, items: Vec<Id>) -> Id {
port(
project,
owner,
"Function port",
if output {
PortContract::FunctionOutput {
outgoing_items: items,
}
} else {
PortContract::FunctionInput {
incoming_items: items,
}
},
)
}
fn legacy(project: &mut Project, owner: Id, interface: Option<Id>) -> Id {
let id = port(project, owner, "Preserved port", PortContract::Legacy);
let layer = project.elements[&id].layer();
project.elements.get_mut(&id).unwrap().body = ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface,
}),
},
);
id
}
fn choice_values(draft: &Draft, project: &Project, path: &str) -> Vec<Value> {
variants(draft, project, path, draft.value.pointer(path).unwrap())
.unwrap()
.into_iter()
.map(|(_, value)| value)
.collect()
}
#[test]
fn switching_function_direction_preserves_invalid_items_and_each_authored_payload() {
let mut project = Project::empty();
let function = add(&mut project, Kind::Function, "Send");
let item = add(&mut project, Kind::ExchangeItem, "Payload");
let output = function_port(&mut project, function, true, vec![item]);
let mut draft = Draft::from_project(&project, output).unwrap();
let output_item = format!("{CONTRACT}/FunctionOutput/outgoing_items/0");
let input_items = format!("{CONTRACT}/FunctionInput/incoming_items");
draft.input(&output_item, "unfinished reference".into());
draft
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(
draft.materialize().unwrap().pointer(&input_items).unwrap(),
&json!(["unfinished reference"])
);
assert!(draft.command().is_err());
draft.shape(&input_items, json!([item])).unwrap();
draft
.shape(CONTRACT, json!({"FunctionOutput":{"outgoing_items":[]}}))
.unwrap();
assert_eq!(draft.inputs[&output_item].raw, "unfinished reference");
draft
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(
draft.materialize().unwrap().pointer(&input_items).unwrap(),
&json!([item])
);
assert_eq!(
execute_batch(&project, &[draft.command().unwrap()])
.unwrap()
.elements[&output]
.shared(),
Some(&SharedBody::Port {
contract: PortContract::FunctionInput {
incoming_items: vec![item]
},
legacy: None,
})
);
assert!(draft.dirty());
assert!(
port_contract(&project.elements[&output])
.unwrap()
.is_function()
);
}
#[test]
fn failed_shape_keeps_incomplete_text_and_saved_contract_payloads() {
let mut project = Project::empty();
let function = add(&mut project, Kind::Function, "Send");
let output = function_port(&mut project, function, true, vec![]);
let mut draft = Draft::from_project(&project, output).unwrap();
draft.set_exact(true).unwrap();
draft.input("/description", "\"unfinished escape\\".into());
let value = draft.value.clone();
assert!(
draft
.shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}}))
.is_err()
);
assert_eq!(draft.value, value);
assert_eq!(draft.inputs["/description"].raw, "\"unfinished escape\\");
assert!(draft.port_contract_drafts.is_empty());
}
#[test]
fn contract_choices_follow_owner_and_never_offer_legacy_or_oa_typed_ports() {
let mut project = Project::empty();
let function = add(&mut project, Kind::Function, "Send");
let system = add(&mut project, Kind::System, "System");
let output = function_port(&mut project, function, true, vec![]);
let boundary = component(
&mut project,
system,
ComponentPortKind::Standard,
ComponentPortOrientation::Unset,
);
let activity = project
.add_element(Kind::Activity, Layer::Oa, "Operate", None)
.unwrap();
let retained = legacy(&mut project, activity, None);
let function_draft = Draft::from_project(&project, output).unwrap();
let component_draft = Draft::from_project(&project, boundary).unwrap();
let choices = choice_values(&function_draft, &project, CONTRACT);
assert_eq!(
choices,
vec![
json!({"FunctionInput":{"incoming_items":[]}}),
json!({"FunctionOutput":{"outgoing_items":[]}})
]
);
assert_eq!(
choice_values(&component_draft, &project, CONTRACT),
vec![json!({"Component":{"kind":"Standard","orientation":"Unset"}})]
);
assert!(
choice_values(
&Draft::from_project(&project, retained).unwrap(),
&project,
"/body/Oa/Shared/Port/contract"
)
.is_empty()
);
assert_eq!(
reference_ids(&function_draft, &project, "/parent"),
vec![function]
);
assert_eq!(
reference_ids(&component_draft, &project, "/parent"),
vec![system]
);
}
#[test]
fn standard_switch_retains_incompatible_orientation_until_explicit_repair() {
let mut project = Project::empty();
let system = add(&mut project, Kind::System, "System");
let boundary = component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::Out,
);
let mut draft = Draft::from_project(&project, boundary).unwrap();
let kind = format!("{CONTRACT}/Component/kind");
let orientation = format!("{CONTRACT}/Component/orientation");
assert!(choice_values(&draft, &project, &orientation).contains(&json!("InOut")));
draft.shape(&kind, json!("Standard")).unwrap();
assert_eq!(
draft.materialize().unwrap().pointer(&orientation),
Some(&json!("Out"))
);
assert_eq!(
choice_values(&draft, &project, &orientation),
vec![json!("Unset")]
);
let mut session = Session::new(project.clone());
assert!(session.execute(draft.command().unwrap()).is_err());
assert_eq!(session.project(), &project);
assert!(draft.dirty());
draft.shape(&orientation, json!("Unset")).unwrap();
session.execute(draft.command().unwrap()).unwrap();
assert_eq!(
port_contract(&session.project().elements[&boundary]),
Some(&PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
})
);
session.undo();
assert_eq!(session.project(), &project);
}
#[test]
fn functional_endpoints_require_owner_direction_and_items_but_retain_bad_current_choice() {
let mut project = Project::empty();
let source = add(&mut project, Kind::Function, "Send");
let target = add(&mut project, Kind::Function, "Receive");
let alternative = add(&mut project, Kind::Function, "Alternative sender");
let item = add(&mut project, Kind::ExchangeItem, "Message");
let output = function_port(&mut project, source, true, vec![item]);
let input = function_port(&mut project, target, false, vec![item]);
let wrong_direction = function_port(&mut project, source, false, vec![item]);
function_port(&mut project, source, true, vec![]);
function_port(&mut project, target, true, vec![item]);
let replacement = function_port(&mut project, alternative, true, vec![item]);
let retained = legacy(&mut project, source, None);
let exchange = project.relate(RelationKind::FunctionalExchange, "Message", source, target);
project.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source,
target,
source_port: output,
target_port: input,
items: vec![item],
};
let mut draft = Draft::from_project(&project, exchange).unwrap();
let source_path = "/body/FunctionalExchange/source_port";
assert_eq!(reference_ids(&draft, &project, source_path), vec![output]);
assert_eq!(
reference_ids(&draft, &project, "/body/FunctionalExchange/target_port"),
vec![input]
);
assert!(
!reference_choices(&draft, &project, source_path, output)
.iter()
.any(|choice| choice.id.is_nil())
);
for invalid in [wrong_direction, retained] {
let choices = reference_choices(&draft, &project, source_path, invalid);
assert!(choices.iter().any(|choice| choice.id == invalid
&& choice.label.starts_with("Current choice needs review")));
}
let owners = reference_ids(&draft, &project, "/body/FunctionalExchange/source");
assert!(owners.contains(&source) && owners.contains(&alternative));
assert!(!owners.contains(&target));
draft
.shape("/body/FunctionalExchange/source", json!(alternative))
.unwrap();
assert_eq!(
reference_ids(&draft, &project, source_path),
vec![replacement]
);
assert_eq!(
draft.materialize().unwrap().pointer(source_path),
Some(&json!(output))
);
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
draft.shape(source_path, json!(replacement)).unwrap();
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
}
#[test]
fn port_allocation_choices_use_actual_owner_direction_and_ignore_edited_relation() {
let mut project = Project::empty();
let system = add(&mut project, Kind::System, "System");
let actor = add(&mut project, Kind::SystemActor, "Partner");
let function = add(&mut project, Kind::Function, "Send");
let unallocated = add(&mut project, Kind::Function, "Unallocated");
project.relate(RelationKind::Allocation, "Performs", system, function);
assert_eq!(actual_performer(&project, unallocated), None);
let output = function_port(&mut project, function, true, vec![]);
function_port(&mut project, function, false, vec![]);
function_port(&mut project, unallocated, true, vec![]);
let first = component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::Out,
);
let second = component(
&mut project,
system,
ComponentPortKind::Standard,
ComponentPortOrientation::Unset,
);
component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::In,
);
component(
&mut project,
actor,
ComponentPortKind::Flow,
ComponentPortOrientation::Out,
);
legacy(&mut project, system, None);
let allocation = project.relate(
RelationKind::PortAllocation,
"Allocated port",
first,
output,
);
let draft = Draft::from_project(&project, allocation).unwrap();
let sources = reference_ids(&draft, &project, "/body/PortAllocation/source");
assert_eq!(
sources.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([first, second])
);
assert_eq!(
reference_ids(&draft, &project, "/body/PortAllocation/target"),
vec![output]
);
project.relate(
RelationKind::PortAllocation,
"Other allocated port",
second,
output,
);
assert_eq!(
reference_ids(&draft, &project, "/body/PortAllocation/source"),
vec![first]
);
}
#[test]
fn boundary_choices_honor_draft_direction_same_kind_and_direct_interface_items() {
let mut project = Project::empty();
let system = add(&mut project, Kind::System, "System");
let actor = add(&mut project, Kind::SystemActor, "Partner");
let input = component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::In,
);
let output = component(
&mut project,
actor,
ComponentPortKind::Flow,
ComponentPortOrientation::Out,
);
component(
&mut project,
actor,
ComponentPortKind::Flow,
ComponentPortOrientation::In,
);
component(
&mut project,
actor,
ComponentPortKind::Standard,
ComponentPortOrientation::Unset,
);
legacy(&mut project, actor, None);
let item = add(&mut project, Kind::ExchangeItem, "Message");
let interface = add(&mut project, Kind::Interface, "Empty contract");
// A participant declaration must not become the port's declaration.
project.relate(
RelationKind::InterfaceRequirement,
"Participant requires",
actor,
interface,
);
let exchange = project.relate(RelationKind::BoundaryExchange, "Boundary", input, output);
project.relations.get_mut(&exchange).unwrap().body = RelationBody::BoundaryExchange {
source: input,
target: output,
oriented: false,
items: vec![item],
};
let mut draft = Draft::from_project(&project, exchange).unwrap();
let target = "/body/BoundaryExchange/target";
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
draft
.shape("/body/BoundaryExchange/oriented", json!(true))
.unwrap();
assert!(reference_ids(&draft, &project, target).is_empty());
assert_eq!(
draft.materialize().unwrap().pointer(target),
Some(&json!(output))
);
draft
.shape("/body/BoundaryExchange/oriented", json!(false))
.unwrap();
project.relate(
RelationKind::InterfaceProvision,
"Port provides",
output,
interface,
);
assert!(reference_ids(&draft, &project, target).is_empty());
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
}
#[test]
fn interface_roles_offer_participants_and_component_ports_independent_of_orientation() {
let mut project = Project::empty();
let system = add(&mut project, Kind::System, "System");
let function = add(&mut project, Kind::Function, "Send");
let function_port = function_port(&mut project, function, true, vec![]);
let interface = add(&mut project, Kind::Interface, "Service");
let input = component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::In,
);
let output = component(
&mut project,
system,
ComponentPortKind::Flow,
ComponentPortOrientation::Out,
);
let preserved = legacy(&mut project, system, Some(interface));
for kind in [
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
] {
let role = project.relate(kind, "Role", input, interface);
let draft = Draft::from_project(&project, role).unwrap();
let path = format!("/body/{kind:?}/source");
let choices = reference_ids(&draft, &project, &path);
assert_eq!(
choices.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([system, input, output])
);
for invalid in [function_port, preserved] {
assert!(
reference_choices(&draft, &project, &path, invalid)
.iter()
.any(|choice| choice.id == invalid
&& choice.label.starts_with("Current choice needs review"))
);
}
assert_eq!(
reference_ids(&draft, &project, &format!("/body/{kind:?}/target")),
vec![interface]
);
}
let draft = Draft::from_project(&project, preserved).unwrap();
let lines = legacy_lines(
draft.value.pointer("/body/Sa/Shared/Port/legacy").unwrap(),
&project,
);
assert!(lines[0].contains("Input / output"));
assert!(lines[1].contains("Service"));
assert!(lines[2].contains("does not declare a provided or required interface"));
assert_eq!(
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
project
);
}
#[test]
fn all_demo_records_roundtrip_through_graphical_and_escaped_string_drafts() {
let project = Project::demo();
let mut kinds = BTreeSet::new();
for (id, _) in project.identities() {
let mut draft = Draft::from_project(&project, id).unwrap();
kinds.insert(
format!("{:?}", draft.kind)
.split('(')
.next()
.unwrap()
.to_owned(),
);
assert!(!draft.dirty());
assert_eq!(
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
project,
"Normal draft {id}"
);
draft.set_exact(true).unwrap();
assert!(!draft.dirty());
assert_eq!(
execute_batch(&project, &[draft.command().unwrap()]).unwrap(),
project,
"Escaped draft {id}"
);
draft.set_exact(false).unwrap();
assert!(!draft.dirty());
}
assert_eq!(
kinds,
BTreeSet::from(
[
"Project", "Element", "Relation", "Diagram", "Node", "Artifact", "Trace"
]
.map(str::to_owned)
)
);
}
}