Integrate typed ports and schema 3 migration
# Conflicts: # crates/oa-core/src/projection.rs
This commit is contained in:
@@ -252,6 +252,63 @@ project mission "Harbor safety — 港 🌊" {
|
||||
.unwrap()
|
||||
.push(id);
|
||||
}
|
||||
let owner = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "safety_system")
|
||||
.unwrap()
|
||||
.id;
|
||||
let function = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "detect")
|
||||
.unwrap()
|
||||
.id;
|
||||
let component_port = Id::new_v4();
|
||||
let function_port = Id::new_v4();
|
||||
for (id, alias, parent, contract) in [
|
||||
(
|
||||
component_port,
|
||||
"typed_component_port",
|
||||
owner,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
),
|
||||
(
|
||||
function_port,
|
||||
"typed_function_output",
|
||||
function,
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
),
|
||||
] {
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
parent: Some(parent),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract,
|
||||
legacy: None,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Typed port allocation",
|
||||
component_port,
|
||||
function_port,
|
||||
);
|
||||
p
|
||||
}
|
||||
|
||||
@@ -404,7 +461,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 3; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -708,6 +765,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 3);
|
||||
retained["schema_version"] = 2.into();
|
||||
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
||||
for layer in ["Oa", "Sa"] {
|
||||
let Some(shared) = element["body"]
|
||||
@@ -716,6 +775,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(port) = shared.get_mut("Port") {
|
||||
assert_eq!(port["contract"], "Legacy");
|
||||
*port = port["legacy"].clone();
|
||||
}
|
||||
if let Some(scenario) = shared
|
||||
.get_mut("Scenario")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -735,6 +798,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
||||
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
|
||||
assert_eq!(boundary["oriented"], true);
|
||||
boundary.as_object_mut().unwrap().remove("oriented");
|
||||
}
|
||||
if let Some(transition) = relation["body"]
|
||||
.get_mut("StateTransition")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -828,7 +895,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 2);
|
||||
assert_eq!(from_text.schema_version, 3);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1027,7 +1094,9 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;") {
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
{
|
||||
read(&code);
|
||||
examples += 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
use oa_core::{
|
||||
language::{self, ParseLimits},
|
||||
persistence::{self, FileError},
|
||||
*,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const ORIGINAL: &[u8] = include_bytes!("fixtures/v2-coastal-rescue.json");
|
||||
fn original() -> Value {
|
||||
serde_json::from_slice(ORIGINAL).unwrap()
|
||||
}
|
||||
fn exchange_id(old: &Value) -> String {
|
||||
old["relations"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(_, r)| r["body"].get("FunctionalExchange").is_some())
|
||||
.unwrap()
|
||||
.0
|
||||
.clone()
|
||||
}
|
||||
fn without_endpoints() -> Value {
|
||||
let mut old = original();
|
||||
let id = exchange_id(&old);
|
||||
old["relations"][&id]["body"]["FunctionalExchange"]["source_port"] = Value::Null;
|
||||
old["relations"][&id]["body"]["FunctionalExchange"]["target_port"] = Value::Null;
|
||||
old
|
||||
}
|
||||
fn decode(old: &Value) -> persistence::DecodeResult {
|
||||
persistence::decode_with_report(&serde_json::to_vec(old).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
let old = original();
|
||||
let decoded = persistence::decode_with_report(ORIGINAL).unwrap();
|
||||
let from_source =
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(decoded.project, from_source.project);
|
||||
assert_eq!(decoded.migration, from_source.migration);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 3));
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
assert_eq!(
|
||||
decoded.project.identities().len(),
|
||||
old["elements"].as_object().unwrap().len()
|
||||
+ old["relations"].as_object().unwrap().len()
|
||||
+ old["diagrams"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(|d| 1 + d["nodes"].as_object().unwrap().len())
|
||||
.sum::<usize>()
|
||||
+ old["traces"].as_object().unwrap().len()
|
||||
+ old["artifact_links"].as_object().unwrap().len()
|
||||
+ 1
|
||||
);
|
||||
let current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for field in [
|
||||
"id",
|
||||
"alias",
|
||||
"name",
|
||||
"description",
|
||||
"diagrams",
|
||||
"traces",
|
||||
"artifact_links",
|
||||
] {
|
||||
assert_eq!(current[field], old[field], "preserve {field}");
|
||||
}
|
||||
for (id, before) in old["elements"].as_object().unwrap() {
|
||||
let after = ¤t["elements"][id];
|
||||
for field in ["id", "alias", "name", "description", "parent"] {
|
||||
assert_eq!(after[field], before[field], "{id}.{field}");
|
||||
}
|
||||
for layer in ["Oa", "Sa"] {
|
||||
if let Some(port) = before["body"][layer]["Shared"].get("Port") {
|
||||
assert_eq!(
|
||||
after["body"][layer]["Shared"]["Port"],
|
||||
json!({"contract":"Legacy", "legacy":port})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, before) in old["relations"].as_object().unwrap() {
|
||||
let after = ¤t["relations"][id];
|
||||
for field in ["id", "alias", "name", "description"] {
|
||||
assert_eq!(after[field], before[field]);
|
||||
}
|
||||
if before["body"].get("StateTransition").is_none() {
|
||||
let mut expected = before.clone();
|
||||
if let Some(body) = expected["body"].get_mut("BoundaryExchange") {
|
||||
body["oriented"] = true.into();
|
||||
}
|
||||
assert_eq!(*after, expected, "relation {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
let old = without_endpoints();
|
||||
let first = decode(&old);
|
||||
let second = decode(&old);
|
||||
assert_eq!(first.project, second.project);
|
||||
assert_eq!(first.migration, second.migration);
|
||||
let report = first.migration.as_ref().unwrap();
|
||||
assert_eq!(report.generated_endpoints.len(), 2);
|
||||
let fe: Id = exchange_id(&old).parse().unwrap();
|
||||
let items = first.project.relations[&fe].body.items();
|
||||
for endpoint in &report.generated_endpoints {
|
||||
assert_eq!(
|
||||
endpoint.port,
|
||||
Id::new_v5(
|
||||
&first.project.id,
|
||||
format!("v2-fe-port:{fe}:{}", endpoint.side).as_bytes()
|
||||
)
|
||||
);
|
||||
let port = &first.project.elements[&endpoint.port];
|
||||
assert_eq!(port.parent, Some(endpoint.owner));
|
||||
assert_eq!(port.alias, endpoint.alias);
|
||||
match port.shared().unwrap() {
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
legacy: None,
|
||||
} => {
|
||||
assert_eq!(endpoint.side, "source");
|
||||
assert_eq!(outgoing_items, items);
|
||||
}
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
legacy: None,
|
||||
} => {
|
||||
assert_eq!(endpoint.side, "target");
|
||||
assert_eq!(incoming_items, items);
|
||||
}
|
||||
other => panic!("unexpected migrated endpoint {other:?}"),
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 3;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
assert_eq!(canonical, language::format(&reopened.project));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("migrated.json");
|
||||
persistence::save(&path, &first.project).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let loaded = persistence::load_with_report(&path).unwrap();
|
||||
assert!(loaded.migration.is_none());
|
||||
persistence::save(&path, &loaded.project).unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_endpoint_collision_in_any_object_category_is_located_and_rejected() {
|
||||
let mut old = without_endpoints();
|
||||
let fe = exchange_id(&old);
|
||||
let project: Id = old["id"].as_str().unwrap().parse().unwrap();
|
||||
let collision = Id::new_v5(&project, format!("v2-fe-port:{fe}:source").as_bytes());
|
||||
old["artifact_links"].as_object_mut().unwrap().insert(collision.to_string(), json!({"id":collision,"alias":"collision_artifact","name":"Keep this identity","description":"Never overwrite me","owner":project,"relation":"References","uri":"docs/evidence.md","revision":null,"fragment":null}));
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let error = persistence::decode(&bytes).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("identity_collision")
|
||||
&& error.contains(&fe)
|
||||
&& error.contains("source_port")
|
||||
&& error.contains(&collision.to_string()),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(serde_json::to_vec(&old).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_alias_collision_is_deterministically_suffixed() {
|
||||
let mut old = without_endpoints();
|
||||
let first = decode(&old);
|
||||
let generated = &first.migration.unwrap().generated_endpoints[0];
|
||||
let original_port = old["elements"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.find(|e| e["body"]["Sa"]["Shared"].get("Port").is_some())
|
||||
.unwrap();
|
||||
original_port["alias"] = generated.alias.clone().into();
|
||||
let decoded = decode(&old);
|
||||
let endpoint = &decoded.migration.unwrap().generated_endpoints[0];
|
||||
assert_eq!(endpoint.port, generated.port);
|
||||
assert_ne!(endpoint.alias, generated.alias);
|
||||
assert!(endpoint.alias.starts_with(&generated.alias));
|
||||
assert_eq!(decoded.project, decode(&old).project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
let old = without_endpoints();
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let graph =
|
||||
old["elements"].as_object().unwrap().len() + old["relations"].as_object().unwrap().len();
|
||||
let migrated = decode(&old).project;
|
||||
let records = migrated.identities().len();
|
||||
let reference_count = migrated
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum::<usize>()
|
||||
+ decode(&old)
|
||||
.migration
|
||||
.unwrap()
|
||||
.generated_endpoints
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
migrated.elements[&endpoint.port]
|
||||
.shared()
|
||||
.map_or(0, |body| match body {
|
||||
SharedBody::Port { contract, .. } => contract.items().len() + 2,
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
.sum::<usize>();
|
||||
for limits in [
|
||||
ParseLimits {
|
||||
max_graph_objects: graph + 1,
|
||||
..ParseLimits::UNLIMITED
|
||||
},
|
||||
ParseLimits {
|
||||
max_records: records - 1,
|
||||
..ParseLimits::UNLIMITED
|
||||
},
|
||||
ParseLimits {
|
||||
max_step_references: reference_count - 1,
|
||||
..ParseLimits::UNLIMITED
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
persistence::decode_with_report_limited(&bytes, limits),
|
||||
Err(FileError::Limit(_))
|
||||
));
|
||||
}
|
||||
let accepted = ParseLimits {
|
||||
max_graph_objects: graph + 2,
|
||||
max_records: records,
|
||||
max_step_references: reference_count,
|
||||
..ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(persistence::decode_with_report_limited(&bytes, accepted).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
let mut old = original();
|
||||
let fe = exchange_id(&old);
|
||||
let port = old["relations"][&fe]["body"]["FunctionalExchange"]["source_port"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
old["elements"][&port]["body"]["Sa"]["Shared"]["Port"]["direction"] = "In".into();
|
||||
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 3);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_v2_does_not_rewrite_original_bytes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("original.json");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let loaded = persistence::load_with_report(&path).unwrap();
|
||||
assert!(loaded.migration.is_some());
|
||||
assert_eq!(std::fs::read(path).unwrap(), ORIGINAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_oa_inout_ports_attachments_and_traces_remain_unclassified() {
|
||||
let source = r##"arcadia 2;
|
||||
project old_ports "Existing engineering" id "10000000-0000-0000-0000-000000000001" {
|
||||
oa_entity observer "Observer" {}
|
||||
oa_actor operator_one "Operator" {}
|
||||
oa_port old_oa "OA channel" { parent @observer; direction InOut; }
|
||||
oa_port peer_oa "OA peer" { parent @operator_one; direction InOut; }
|
||||
boundary_exchange old_oa_link "Operational communication" { source @old_oa; target @peer_oa; }
|
||||
oa_activity observe "Observe" {}
|
||||
oa_activity react "React" {}
|
||||
interaction operational_message "Operational message" { source @observe; target @react; }
|
||||
sa_function send "Send" {}
|
||||
sa_function respond "Respond" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_interface "Original association" { items [@payload]; }
|
||||
sa_port directed "Original directed port" { parent @send; direction Out; interface @old_interface; description "Retain — описание"; }
|
||||
sa_port bidirectional "Original InOut port" id "10000000-0000-0000-0000-000000000002" { parent @respond; direction InOut; interface @old_interface; }
|
||||
functional_exchange request_message "Request" { source @send; target @respond; source_port @directed; target_port @bidirectional; items [@payload]; }
|
||||
functional_exchange reply_message "Reply" id "10000000-0000-0000-0000-000000000003" { source @respond; target @send; source_port @bidirectional; items [@payload]; }
|
||||
interface_provision legacy_role "Preserved role" { source @bidirectional; target @old_interface; }
|
||||
sa_constraint port_constraint "Original requirement" { constrained [@bidirectional]; expression "review exact meaning"; }
|
||||
artifact attachment "Attached rationale" { owner @bidirectional; uri "docs/original.md"; revision "v2"; fragment "#port"; }
|
||||
trace original_trace "Original correspondence" { oa @old_oa; sa @bidirectional; kind Item; }
|
||||
diagram sa_view "Existing placement" { layer Sa; }
|
||||
node original_node "" { diagram @sa_view; element @bidirectional; position { x -0; y 23.5; }; size { width 44; height 22; }; }
|
||||
}
|
||||
"##;
|
||||
// Node declarations intentionally have no display-name string.
|
||||
let source = source.replace("node original_node \"\"", "node original_node");
|
||||
let decoded = language::parse_with_report(&source).unwrap();
|
||||
let p = decoded.project;
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert_eq!(report.generated_endpoints.len(), 1);
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
let original: Id = "10000000-0000-0000-0000-000000000002".parse().unwrap();
|
||||
assert!(matches!(
|
||||
p.elements[&original].shared(),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: Some(_)
|
||||
})
|
||||
})
|
||||
));
|
||||
assert!(
|
||||
p.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == Layer::Oa && e.kind() == Kind::Port)
|
||||
.all(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
..
|
||||
})
|
||||
))
|
||||
);
|
||||
assert_eq!(p.artifact_links.values().next().unwrap().owner, original);
|
||||
assert_eq!(p.traces.values().next().unwrap().sa, original);
|
||||
let node = p
|
||||
.diagrams
|
||||
.values()
|
||||
.flat_map(|d| d.nodes.values())
|
||||
.next()
|
||||
.unwrap();
|
||||
assert_eq!(node.element, original);
|
||||
assert_eq!(node.position.x.to_bits(), (-0.0f32).to_bits());
|
||||
assert_eq!(node.position.y, 23.5);
|
||||
assert!(
|
||||
p.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::InterfaceProvision && r.source() == original)
|
||||
);
|
||||
assert_eq!(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::PortAllocation)
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(p, language::parse(&language::format(&p)).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project typed_ports "Typed ports" {
|
||||
sa_system producer "Producer" {}
|
||||
sa_actor consumer "Consumer" {}
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
allocation sends "Produces" { source @producer; target @send; }
|
||||
allocation receives "Consumes" { source @consumer; target @receive; }
|
||||
sa_exchange_item data "Data" {}
|
||||
sa_interface service "Service" { items [@data]; }
|
||||
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
|
||||
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
|
||||
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||
sa_port client "Client port" { parent @consumer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||
interface_provision provided "Provides service" { source @provider; target @service; }
|
||||
interface_requirement required "Requires service" { source @client; target @service; }
|
||||
functional_exchange message "Message" { source @send; target @receive; source_port @output; target_port @input; items [@data]; }
|
||||
boundary_exchange connection "Connection" { source @provider; target @client; oriented false; items [@data]; }
|
||||
port_allocation output_allocation "Output allocation" { source @provider; target @output; }
|
||||
port_allocation input_allocation "Input allocation" { source @client; target @input; }
|
||||
exchange_allocation carried "Carried message" { source @connection; target @message; }
|
||||
sa_port preserved "Preserved generic port" { parent @send; contract Legacy; legacy { direction InOut; interface @service; }; }
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
assert!(decoded.migration.is_none());
|
||||
let canonical = language::format(&decoded.project);
|
||||
for field in [
|
||||
"incoming_items [@data]",
|
||||
"outgoing_items [@data]",
|
||||
"interface @service",
|
||||
"source_port @output",
|
||||
"target_port @input",
|
||||
"oriented false",
|
||||
"port_allocation",
|
||||
] {
|
||||
assert!(canonical.contains(field), "{field}\n{canonical}");
|
||||
}
|
||||
assert_eq!(language::parse(&canonical).unwrap(), decoded.project);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&decoded.project).unwrap()).unwrap(),
|
||||
decoded.project
|
||||
);
|
||||
let preserved = decoded
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "preserved")
|
||||
.unwrap();
|
||||
let service = decoded
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.alias == "service")
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(references::element_references(preserved).contains(&service));
|
||||
assert!(
|
||||
language::lint(&canonical)
|
||||
.iter()
|
||||
.any(|d| d.object_id == Some(preserved.id)
|
||||
&& d.severity == validation::Severity::Warning)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
for (remove, expected) in [
|
||||
("source_port @output; ", "source_port"),
|
||||
("target_port @input; ", "target_port"),
|
||||
(
|
||||
"contract { FunctionOutput { outgoing_items [@data]; }; };",
|
||||
"contract",
|
||||
),
|
||||
] {
|
||||
let candidate = SOURCE.replace(remove, "");
|
||||
let errors = language::parse(&candidate).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|d| d.message.contains(expected)),
|
||||
"{errors:?}"
|
||||
);
|
||||
assert!(errors.iter().all(|d| d.line > 0 && d.column > 0));
|
||||
}
|
||||
assert!(language::parse(&SOURCE.replace("source_port @output", "source_port none")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
let mixed = SOURCE.replace(
|
||||
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
|
||||
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
|
||||
);
|
||||
assert!(language::parse(&mixed).is_err());
|
||||
let typo = SOURCE.replace("interface @service;", "interface @missing;");
|
||||
let error = language::parse(&typo).unwrap_err();
|
||||
assert!(error.iter().any(|d| d.message.contains("@missing")));
|
||||
let item_typo = SOURCE.replace("incoming_items [@data]", "incoming_items [@missing]");
|
||||
assert!(
|
||||
language::parse(&item_typo)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("@missing"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
use oa_core::{projection::*, transitions::*, validation, *};
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
fn request(p: &Project, source: Id, target: Id, items: Vec<Id>) -> FunctionalExchangeRequest {
|
||||
let id = Id::new_v4();
|
||||
FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: p.fresh_alias("message", "exchange", id),
|
||||
name: "Message".into(),
|
||||
description: "Engineering rationale".into(),
|
||||
source,
|
||||
target,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
items,
|
||||
}
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Function, Layer::Sa, "Send");
|
||||
let b = add(&mut p, Kind::Function, Layer::Sa, "Receive");
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Information");
|
||||
let request = request(&p, a, b, vec![item]);
|
||||
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
|
||||
(p, a, b, item, request.id)
|
||||
}
|
||||
fn endpoints(p: &Project, id: Id) -> (Id, Id) {
|
||||
match p.relations[&id].body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => (source_port, target_port),
|
||||
_ => panic!("not a functional exchange"),
|
||||
}
|
||||
}
|
||||
fn allocate(p: &mut Project, a: Id, b: Id) -> (Id, Id) {
|
||||
let first = add(p, Kind::System, Layer::Sa, "System");
|
||||
let second = add(p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
p.relate(RelationKind::Allocation, "Send allocation", first, a);
|
||||
p.relate(RelationKind::Allocation, "Receive allocation", second, b);
|
||||
(first, second)
|
||||
}
|
||||
fn component_request(p: &Project, fe: Id, kind: ComponentPortKind) -> ComponentExchangeRequest {
|
||||
let id = Id::new_v4();
|
||||
ComponentExchangeRequest {
|
||||
functional_exchange: fe,
|
||||
boundary: ComponentExchangeChoice::Create {
|
||||
id,
|
||||
alias: p.fresh_alias("boundary", "exchange", id),
|
||||
name: "Boundary".into(),
|
||||
description: "Physical realization".into(),
|
||||
port_kind: kind,
|
||||
oriented: kind == ComponentPortKind::Flow,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn boundary_id(request: &ComponentExchangeRequest) -> Id {
|
||||
match request.boundary {
|
||||
ComponentExchangeChoice::Create { id, .. } | ComponentExchangeChoice::Reuse(id) => id,
|
||||
}
|
||||
}
|
||||
fn code(p: &Project, expected: &str) -> bool {
|
||||
validation::validate(p)
|
||||
.iter()
|
||||
.any(|issue| issue.code == expected && issue.severity == validation::Severity::Error)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_creation_reuse_and_item_sync_are_atomic_and_identity_safe() {
|
||||
let (p, a, b, item, fe) = fixture();
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert!(
|
||||
matches!(port_contract(&p.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[item])
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(&p.elements[&input]), Some(PortContract::FunctionInput { incoming_items }) if incoming_items == &[item])
|
||||
);
|
||||
let next = request(&p, a, b, vec![item]);
|
||||
let commands = plan_functional_exchange(&p, &next).unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute(Command::Batch(commands)).unwrap();
|
||||
assert_eq!(endpoints(session.project(), next.id), (output, input));
|
||||
assert_eq!(session.revision(), 1);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
let mut wrong = request(&p, a, b, vec![item]);
|
||||
wrong.source_port = PortChoice::Reuse(input);
|
||||
assert!(plan_functional_exchange(&p, &wrong).is_err());
|
||||
assert!(plan_function_port_items(&p, output, vec![]).is_err());
|
||||
assert_eq!(session.project(), &p);
|
||||
assert!(ElementBody::new(Kind::Port, Layer::Sa).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_reuse_creates_a_fresh_port_and_collisions_never_overwrite() {
|
||||
let (mut p, a, b, item, _) = fixture();
|
||||
let other = Id::new_v4();
|
||||
p.elements.insert(
|
||||
other,
|
||||
make_port(
|
||||
&p,
|
||||
other,
|
||||
a,
|
||||
"Alternative output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let req = request(&p, a, b, vec![item]);
|
||||
let expected = Id::new_v5(&req.id, b"function-source-port");
|
||||
let commands = plan_functional_exchange(&p, &req).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&commands).unwrap(),
|
||||
serde_json::to_value(plan_functional_exchange(&p, &req).unwrap()).unwrap()
|
||||
);
|
||||
let result = execute_batch(&p, &commands).unwrap();
|
||||
assert_eq!(endpoints(&result, req.id).0, expected);
|
||||
let occupied = p.elements[&other].clone();
|
||||
let mut blocker = occupied.clone();
|
||||
blocker.id = expected;
|
||||
blocker.alias = "block_generated_identity".into();
|
||||
p.elements.insert(expected, blocker);
|
||||
assert!(
|
||||
plan_functional_exchange(&p, &req)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("port_id_collision")
|
||||
);
|
||||
assert_eq!(p.elements[&other], occupied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_generation_requires_real_allocations_and_reuses_existing_facts() {
|
||||
let (mut p, a, b, _, fe) = fixture();
|
||||
let initial = component_request(&p, fe, ComponentPortKind::Flow);
|
||||
assert!(
|
||||
plan_component_exchange(&p, &initial)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("port_allocation_owner")
|
||||
);
|
||||
allocate(&mut p, a, b);
|
||||
let req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||
let boundary = boundary_id(&req);
|
||||
let result = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::PortAllocation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::ExchangeAllocation)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let again = ComponentExchangeRequest {
|
||||
functional_exchange: fe,
|
||||
boundary: ComponentExchangeChoice::Reuse(boundary),
|
||||
};
|
||||
assert!(plan_component_exchange(&result, &again).unwrap().is_empty());
|
||||
let mut wrong = result.clone();
|
||||
let source = wrong.relations[&boundary].source();
|
||||
if let Some(SharedBody::Port { contract, .. }) =
|
||||
wrong.elements.get_mut(&source).unwrap().shared_mut()
|
||||
{
|
||||
*contract = PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
};
|
||||
}
|
||||
assert!(code(&wrong, "boundary_port"));
|
||||
assert!(code(&wrong, "port_allocation_contract"));
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::Batch(plan_component_exchange(&p, &req).unwrap()))
|
||||
.unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_rejected() {
|
||||
let (mut p, a, b, item, fe) = fixture();
|
||||
allocate(&mut p, a, b);
|
||||
let req = component_request(&p, fe, ComponentPortKind::Standard);
|
||||
let boundary = boundary_id(&req);
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Caller requires service",
|
||||
source,
|
||||
interface,
|
||||
);
|
||||
let provider = p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Callee provides service",
|
||||
target,
|
||||
interface,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
assert!(
|
||||
!validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unsatisfied_interface_requirement")
|
||||
);
|
||||
assert!(matches!(
|
||||
p.relations[&boundary].body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let without_provider = execute_batch(&p, &[Command::Delete(provider)]).unwrap();
|
||||
assert!(
|
||||
validation::validate(&without_provider).iter().any(
|
||||
|i| i.element == Some(requirement) && i.code == "unsatisfied_interface_requirement"
|
||||
)
|
||||
);
|
||||
if let Some(SharedBody::Port { contract, .. }) =
|
||||
p.elements.get_mut(&source).unwrap().shared_mut()
|
||||
{
|
||||
*contract = PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
};
|
||||
}
|
||||
assert!(code(&p, "mixed_component_ports"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_and_trace() {
|
||||
let (mut p, a, b, item, forward) = fixture();
|
||||
let (old, _) = endpoints(&p, forward);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
|
||||
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: Some(interface),
|
||||
}),
|
||||
};
|
||||
let reverse_req = request(&p, b, a, vec![item]);
|
||||
p = execute_batch(&p, &plan_functional_exchange(&p, &reverse_req).unwrap()).unwrap();
|
||||
if let RelationBody::FunctionalExchange { target_port, .. } =
|
||||
&mut p.relations.get_mut(&reverse_req.id).unwrap().body
|
||||
{
|
||||
*target_port = old;
|
||||
}
|
||||
let artifact = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "rationale".into(),
|
||||
name: "Evidence".into(),
|
||||
description: "Attached original evidence".into(),
|
||||
owner: old,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "docs/evidence.md".into(),
|
||||
revision: Some("v2".into()),
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let constraint = add(&mut p, Kind::Constraint, Layer::Sa, "Port requirement");
|
||||
*p.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "original meaning".into(),
|
||||
constrained: vec![old],
|
||||
};
|
||||
let oa_work = add(&mut p, Kind::Activity, Layer::Oa, "Original work");
|
||||
let oa_port = Id::new_v4();
|
||||
let mut legacy_oa = p.elements[&old].clone();
|
||||
legacy_oa.id = oa_port;
|
||||
legacy_oa.alias = "original_oa_port".into();
|
||||
legacy_oa.parent = Some(oa_work);
|
||||
legacy_oa.body = ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
p.elements.insert(oa_port, legacy_oa);
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "port_trace".into(),
|
||||
name: "Original correspondence".into(),
|
||||
description: String::new(),
|
||||
oa: oa_port,
|
||||
sa: old,
|
||||
kind: TraceKind::Item,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let req = LegacyWorkPortSplitRequest {
|
||||
port: old,
|
||||
primary: FunctionPortSide::Input,
|
||||
incoming_items: vec![item],
|
||||
outgoing_items: vec![item],
|
||||
};
|
||||
let commands = plan_legacy_work_port_split(&p, &req).unwrap();
|
||||
let mut session = Session::new(p.clone());
|
||||
session.execute(Command::Batch(commands)).unwrap();
|
||||
let result = session.project();
|
||||
let opposite = Id::new_v5(&old, b"split-opposite-port");
|
||||
assert_eq!(endpoints(result, forward).0, opposite);
|
||||
assert_eq!(endpoints(result, reverse_req.id).1, old);
|
||||
assert_eq!(
|
||||
result.elements[&old].description,
|
||||
p.elements[&old].description
|
||||
);
|
||||
assert!(
|
||||
matches!(result.elements[&old].shared(), Some(SharedBody::Port { contract: PortContract::FunctionInput { .. }, legacy: Some(LegacyPortContract { direction: PortDirection::InOut, interface: Some(id) }) }) if *id == interface)
|
||||
);
|
||||
assert_eq!(
|
||||
result.artifact_links[&artifact],
|
||||
p.artifact_links[&artifact]
|
||||
);
|
||||
assert_eq!(result.traces[&trace], p.traces[&trace]);
|
||||
assert_eq!(result.elements[&constraint], p.elements[&constraint]);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
assert!(
|
||||
plan_legacy_port_resolution(
|
||||
&p,
|
||||
&LegacyPortResolution {
|
||||
port: oa_port,
|
||||
contract: PortContract::FunctionInput {
|
||||
incoming_items: vec![]
|
||||
}
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_preserves_independent_ports_but_removes_required_dependents() {
|
||||
let (mut p, a, b, _, fe) = fixture();
|
||||
allocate(&mut p, a, b);
|
||||
let req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
let allocation = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::PortAllocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let removed_allocation = execute_batch(&p, &[Command::Delete(allocation)]).unwrap();
|
||||
assert!(removed_allocation.elements.contains_key(&output));
|
||||
assert!(
|
||||
removed_allocation
|
||||
.relations
|
||||
.values()
|
||||
.all(|r| r.kind() != RelationKind::ExchangeAllocation)
|
||||
);
|
||||
let removed_fe = execute_batch(&p, &[Command::Delete(fe)]).unwrap();
|
||||
assert!(removed_fe.elements.contains_key(&output) && removed_fe.elements.contains_key(&input));
|
||||
let removed_port = execute_batch(&p, &[Command::Delete(output)]).unwrap();
|
||||
assert!(!removed_port.relations.contains_key(&fe));
|
||||
assert!(removed_port.elements.contains_key(&input));
|
||||
let new = Id::new_v4();
|
||||
let changed = execute_batch(&p, &[Command::Reidentify(output, new)]).unwrap();
|
||||
assert_eq!(endpoints(&changed, fe).0, new);
|
||||
assert!(
|
||||
changed
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::PortAllocation && r.target() == new)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity() {
|
||||
let (mut p, _, _, item, fe) = fixture();
|
||||
let output = endpoints(&p, fe).0;
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: Some(interface),
|
||||
}),
|
||||
};
|
||||
let role = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Original unreviewed role",
|
||||
output,
|
||||
interface,
|
||||
);
|
||||
let req = LegacyPortResolution {
|
||||
port: output,
|
||||
contract: PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
};
|
||||
let result = execute_batch(&p, &plan_legacy_port_resolution(&p, &req).unwrap()).unwrap();
|
||||
assert_eq!(result.relations[&role], p.relations[&role]);
|
||||
assert_eq!(result.elements[&output].alias, p.elements[&output].alias);
|
||||
assert!(
|
||||
validation::validate(&result)
|
||||
.iter()
|
||||
.any(|issue| issue.element == Some(role) && issue.code == "legacy_interface_role")
|
||||
);
|
||||
let replacement = Id::new_v4();
|
||||
let remapped = execute_batch(&result, &[Command::Reidentify(interface, replacement)]).unwrap();
|
||||
assert!(
|
||||
matches!(remapped.elements[&output].shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { interface: Some(id), .. }), .. }) if *id == replacement)
|
||||
);
|
||||
let removed = execute_batch(&remapped, &[Command::Delete(replacement)]).unwrap();
|
||||
assert!(removed.elements.contains_key(&item));
|
||||
assert!(!removed.relations.contains_key(&role));
|
||||
assert!(
|
||||
matches!(removed.elements[&output].shared(), Some(SharedBody::Port { contract: PortContract::FunctionOutput { outgoing_items }, legacy: Some(LegacyPortContract { interface: None, .. }) }) if outgoing_items == &[item])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
|
||||
let mut oa = Project::empty();
|
||||
let a = add(&mut oa, Kind::Activity, Layer::Oa, "Send");
|
||||
let b = add(&mut oa, Kind::Activity, Layer::Oa, "Receive");
|
||||
let interaction = oa.relate(RelationKind::Interaction, "Operational exchange", a, b);
|
||||
let mut req = TransitionRequest::new(TransitionDirection::OaToSa, vec![interaction]);
|
||||
req.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&oa, &req).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let sa = apply_transition(&oa, &plan).unwrap();
|
||||
assert_eq!(
|
||||
sa.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Sa)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
sa.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let (mut p, a, b, _, fe) = fixture();
|
||||
allocate(&mut p, a, b);
|
||||
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||
let boundary = boundary_id(&component_req);
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
|
||||
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
|
||||
reverse.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&p, &reverse).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
|
||||
let result = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(
|
||||
result
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Interaction)
|
||||
);
|
||||
for e in p.elements.values() {
|
||||
assert_eq!(result.elements[&e.id], *e);
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
assert_eq!(result.relations[&r.id], *r);
|
||||
}
|
||||
// A required FE dependency is portless in OA even while an SA endpoint
|
||||
// retains unresolved legacy semantics. No incidental legacy copy is made.
|
||||
let (mut legacy, _, _, _, legacy_fe) = fixture();
|
||||
let legacy_port = endpoints(&legacy, legacy_fe).0;
|
||||
*legacy
|
||||
.elements
|
||||
.get_mut(&legacy_port)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
};
|
||||
let mut req = TransitionRequest::new(TransitionDirection::SaToOa, vec![legacy_fe]);
|
||||
req.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&legacy, &req).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = apply_transition(&legacy, &plan).unwrap();
|
||||
assert!(
|
||||
!result
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_identity() {
|
||||
let (mut p, a, b, _, fe) = fixture();
|
||||
let (system, actor) = allocate(&mut p, a, b);
|
||||
let req = component_request(&p, fe, ComponentPortKind::Standard);
|
||||
let boundary = boundary_id(&req);
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
for (index, id) in [system, actor, a, b].into_iter().enumerate() {
|
||||
p.place(
|
||||
diagram,
|
||||
id,
|
||||
Position {
|
||||
x: 50. + index as f32 * 280.,
|
||||
y: 100.,
|
||||
},
|
||||
);
|
||||
}
|
||||
let view = project_view(&p, diagram);
|
||||
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||
assert!(!edge.oriented);
|
||||
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||
assert!(
|
||||
view.ports
|
||||
.iter()
|
||||
.filter(|port| [
|
||||
p.relations[&boundary].source(),
|
||||
p.relations[&boundary].target()
|
||||
]
|
||||
.contains(&port.element))
|
||||
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||
);
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == output)
|
||||
.unwrap()
|
||||
.side,
|
||||
PortAnchorSide::Right
|
||||
);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == input)
|
||||
.unwrap()
|
||||
.side,
|
||||
PortAnchorSide::Left
|
||||
);
|
||||
assert!(
|
||||
p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.all(|node| p.elements[&node.element].kind() != Kind::Port)
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,26 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
| RelationBody::FunctionalExchange { items, .. } => items.push(item),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&exchange].body
|
||||
{
|
||||
for port in [source_port, target_port] {
|
||||
match p.elements.get_mut(&port).unwrap().shared_mut() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
}) => incoming_items.push(item),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
}) => outgoing_items.push(item),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
let call = add_occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
@@ -1010,9 +1030,27 @@ fn role_lifelines_require_explicit_work_assignment_not_just_a_shared_realizing_e
|
||||
validation::semantic_representation_compatible(&p, target, work),
|
||||
Some(false)
|
||||
);
|
||||
let port = p
|
||||
.add_element(Kind::Port, Layer::Oa, "Controller port", Some(entity))
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
p.elements.insert(
|
||||
port,
|
||||
Element {
|
||||
id: port,
|
||||
alias: "legacy_controller_port".into(),
|
||||
name: "Controller port".into(),
|
||||
description: String::new(),
|
||||
parent: Some(entity),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Oa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
validation::semantic_representation_compatible(&p, entity, port),
|
||||
Some(true)
|
||||
|
||||
@@ -334,11 +334,29 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
direction,
|
||||
interface: Some(interface),
|
||||
let id = Id::new_v4();
|
||||
let contract = if p.elements[&parent].kind() == Kind::Function {
|
||||
if direction == PortDirection::Out {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
}
|
||||
} else {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![item],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: if direction == PortDirection::Out {
|
||||
ComponentPortOrientation::Out
|
||||
} else {
|
||||
ComponentPortOrientation::In
|
||||
},
|
||||
}
|
||||
};
|
||||
p.elements
|
||||
.insert(id, make_port(p, id, parent, name, contract).unwrap());
|
||||
id
|
||||
};
|
||||
let out = port(&mut p, "System output", system, PortDirection::Out);
|
||||
@@ -355,10 +373,14 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: f1,
|
||||
target: f2,
|
||||
source_port: Some(fp1),
|
||||
target_port: Some(fp2),
|
||||
source_port: fp1,
|
||||
target_port: fp2,
|
||||
items: vec![item],
|
||||
};
|
||||
p.relate(RelationKind::Allocation, "Send allocation", system, f1);
|
||||
p.relate(RelationKind::Allocation, "Receive allocation", actor, f2);
|
||||
p.relate(RelationKind::PortAllocation, "Output allocation", out, fp1);
|
||||
p.relate(RelationKind::PortAllocation, "Input allocation", input, fp2);
|
||||
p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Carried on",
|
||||
@@ -381,7 +403,7 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
if let RelationBody::FunctionalExchange { source_port, .. } =
|
||||
&mut p.relations.get_mut(&exchange).unwrap().body
|
||||
{
|
||||
*source_port = Some(input);
|
||||
*source_port = input;
|
||||
}
|
||||
rejected(&p, "functional_port");
|
||||
}
|
||||
@@ -721,15 +743,36 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
|
||||
let port = Id::new_v4();
|
||||
p.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&p,
|
||||
port,
|
||||
function,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port, items, ..
|
||||
} = &mut p.relations.get_mut(&rel).unwrap().body
|
||||
{
|
||||
*source_port = Some(port);
|
||||
*source_port = port;
|
||||
items.push(item);
|
||||
}
|
||||
if let RelationBody::FunctionalExchange { target_port, .. } = p.relations[&rel].body
|
||||
&& let Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
}) = p.elements.get_mut(&target_port).unwrap().shared_mut()
|
||||
{
|
||||
incoming_items.push(item);
|
||||
}
|
||||
let dtype_new = Id::new_v4();
|
||||
let port_new = Id::new_v4();
|
||||
let result = execute_batch(
|
||||
@@ -744,7 +787,7 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
|
||||
);
|
||||
assert!(
|
||||
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
|
||||
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:id,..}if *id==port_new)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user