Make ordinary counterpart selection searchable and verify transition lifecycle
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
//! Existing counterpart eligibility is a layer/category/kind decision, not
|
||||
//! validation or acceptance of an entire transition plan. The catalog fixtures
|
||||
//! below intentionally contain partial records (unowned states, etc.). The
|
||||
//! final regression separately starts from a structurally valid real project.
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let body = if kind == Kind::Port {
|
||||
// Legacy ports can exist in preserved OA models. This does not offer
|
||||
// new typed SA implementation ports for creation in portless OA.
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: None,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ElementBody::new(kind, layer).unwrap()
|
||||
};
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("candidate_{}", id.simple()),
|
||||
name: "Identical candidate label".into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
|
||||
// Contract: language.rst §5 trace families and the explicit capability /
|
||||
// mission alternative. State constructors remain eligible for explicit
|
||||
// review across the state family; selecting a candidate does not accept a
|
||||
// constructor change or make invalid ownership legal.
|
||||
let mut allowed: BTreeSet<(Kind, Kind)> = [
|
||||
(Kind::Activity, Kind::Function),
|
||||
(Kind::Entity, Kind::System),
|
||||
(Kind::Entity, Kind::SystemActor),
|
||||
(Kind::Actor, Kind::System),
|
||||
(Kind::Actor, Kind::SystemActor),
|
||||
(Kind::Capability, Kind::SystemCapability),
|
||||
(Kind::OperationalMission, Kind::Mission),
|
||||
(Kind::Capability, Kind::Mission),
|
||||
(Kind::Process, Kind::FunctionalChain),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
for kind in [
|
||||
Kind::ExchangeItem,
|
||||
Kind::ExchangeCategory,
|
||||
Kind::DataType,
|
||||
Kind::DataField,
|
||||
Kind::Interface,
|
||||
Kind::Port,
|
||||
Kind::Scenario,
|
||||
Kind::Lifeline,
|
||||
Kind::Occurrence,
|
||||
Kind::StateMachine,
|
||||
Kind::Region,
|
||||
Kind::Event,
|
||||
Kind::Constraint,
|
||||
] {
|
||||
allowed.insert((kind, kind));
|
||||
}
|
||||
let vertices = [
|
||||
Kind::State,
|
||||
Kind::Mode,
|
||||
Kind::Initial,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::Junction,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
];
|
||||
for oa in vertices {
|
||||
for sa in vertices {
|
||||
allowed.insert((oa, sa));
|
||||
}
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Oa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
|
||||
.collect();
|
||||
let sa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Sa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
|
||||
.collect();
|
||||
let original = p.clone();
|
||||
for (&oa_kind, &oa_id) in &oa {
|
||||
for (&sa_kind, &sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
p, original,
|
||||
"Eligibility must not establish correspondence or edit records"
|
||||
);
|
||||
}
|
||||
|
||||
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
// Only the semantic layer and relation category participate in candidate
|
||||
// eligibility. Endpoint compatibility belongs to full plan validation.
|
||||
p.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: format!("relation_{}", id.simple()),
|
||||
name: "Identical relation label".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(kind, anchor, anchor),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
|
||||
// Work and boundary exchanges are different families. The boundary /
|
||||
// boundary pair permits preserved legacy OA boundary records; ordinary
|
||||
// portless OA communication means map to SA boundary exchanges.
|
||||
let mut allowed = vec![
|
||||
(RelationKind::Interaction, RelationKind::FunctionalExchange),
|
||||
(
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
(
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
];
|
||||
let shared = [
|
||||
RelationKind::Allocation,
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
RelationKind::DataAssociation,
|
||||
RelationKind::ExchangeAllocation,
|
||||
RelationKind::CapabilityInvolvement,
|
||||
RelationKind::CapabilityInclude,
|
||||
RelationKind::CapabilityExtend,
|
||||
RelationKind::CapabilityGeneralization,
|
||||
RelationKind::MissionInvolvement,
|
||||
RelationKind::MissionCapability,
|
||||
RelationKind::SequenceLink,
|
||||
RelationKind::ContextualLink,
|
||||
RelationKind::ChainInvolvement,
|
||||
RelationKind::InterfaceProvision,
|
||||
RelationKind::InterfaceRequirement,
|
||||
RelationKind::StateTransition,
|
||||
RelationKind::Availability,
|
||||
RelationKind::StateAction,
|
||||
];
|
||||
for kind in shared {
|
||||
allowed.push((kind, kind));
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::Interaction,
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::RoleRealization,
|
||||
RelationKind::RoleAssignment,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
|
||||
.collect();
|
||||
let sa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::FunctionalExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::PortAllocation,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
|
||||
.collect();
|
||||
for &(oa_kind, oa_id) in &oa {
|
||||
for &(sa_kind, sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
|
||||
let mut p = Project::empty();
|
||||
let oa = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
|
||||
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
|
||||
for (direction, source, target, source_relation, target_relation) in [
|
||||
(
|
||||
TransitionDirection::OaToSa,
|
||||
oa,
|
||||
sa,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
),
|
||||
(
|
||||
TransitionDirection::SaToOa,
|
||||
sa,
|
||||
oa,
|
||||
sa_exchange,
|
||||
oa_exchange,
|
||||
),
|
||||
] {
|
||||
assert!(counterpart_compatible(&p, source, target, direction));
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source_relation,
|
||||
target_relation,
|
||||
direction
|
||||
));
|
||||
for (a, b) in [
|
||||
(target, source),
|
||||
(source, source),
|
||||
(target, target),
|
||||
(target_relation, source_relation),
|
||||
(source_relation, source_relation),
|
||||
(source, target_relation),
|
||||
(source_relation, target),
|
||||
] {
|
||||
assert!(!counterpart_compatible(&p, a, b, direction));
|
||||
}
|
||||
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
absent_or_nonsemantic,
|
||||
direction
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
absent_or_nonsemantic,
|
||||
target,
|
||||
direction
|
||||
));
|
||||
}
|
||||
}
|
||||
p.elements.remove(&sa);
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa,
|
||||
sa,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
sa,
|
||||
oa,
|
||||
TransitionDirection::SaToOa
|
||||
));
|
||||
// The retained relation header cannot borrow a layer from a deleted owner.
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
|
||||
let p = Project::demo();
|
||||
let original = p.clone();
|
||||
assert!(validation::validate(&p).is_empty());
|
||||
let source = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let port = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
let function = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Function)
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
function,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
port,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(port),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(port)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(&p, &plan).is_err());
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
|
||||
//! OA is portless; the SA half contains typed ports. Deletion expectations below
|
||||
//! are fixture-owned identity lists, never results of the deletion implementation.
|
||||
use oa_core::{
|
||||
language, persistence,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Side {
|
||||
root: Id,
|
||||
elements: Vec<Id>,
|
||||
exchange: Id,
|
||||
diagram: Id,
|
||||
nodes: Vec<Id>,
|
||||
artifacts: Vec<Id>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
source: Side,
|
||||
target: Side,
|
||||
trace: Id,
|
||||
trace_artifact: Id,
|
||||
}
|
||||
|
||||
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias(label, "evidence", id),
|
||||
name: label.into(),
|
||||
description: "Independently authored rationale".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "git:engineering/design.md".into(),
|
||||
revision: Some("review-17".into()),
|
||||
fragment: Some("retained-decision".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn side(p: &mut Project, layer: Layer) -> Side {
|
||||
let (kind, prefix, relation) = match layer {
|
||||
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
|
||||
Layer::Sa => (
|
||||
Kind::Function,
|
||||
"Technical",
|
||||
RelationKind::FunctionalExchange,
|
||||
),
|
||||
};
|
||||
let root = p
|
||||
.add_element(kind, layer, &format!("{prefix} design"), None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&root).unwrap().description =
|
||||
format!("{prefix} independently reviewed intent");
|
||||
let send = p
|
||||
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
|
||||
.unwrap();
|
||||
let receive = p
|
||||
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
|
||||
.unwrap();
|
||||
p.elements.get_mut(&send).unwrap().description =
|
||||
"Preserve the detailed sending responsibility".into();
|
||||
p.elements.get_mut(&receive).unwrap().description =
|
||||
"Preserve the detailed receiving responsibility".into();
|
||||
// A shared, separately owned definition survives deletion of its users.
|
||||
let item = p
|
||||
.add_element(
|
||||
Kind::ExchangeItem,
|
||||
layer,
|
||||
&format!("{prefix} information"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
p.add_element(
|
||||
Kind::DataField,
|
||||
layer,
|
||||
&format!("{prefix} payload"),
|
||||
Some(item),
|
||||
)
|
||||
.unwrap();
|
||||
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
|
||||
let mut elements = vec![root, send, receive];
|
||||
match &mut p.relations.get_mut(&exchange).unwrap().body {
|
||||
RelationBody::Interaction { items, .. } => *items = vec![item],
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
*items = vec![item];
|
||||
let (output, input) = (*source_port, *target_port);
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionInput {
|
||||
incoming_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
elements.extend([output, input]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
p.relations.get_mut(&exchange).unwrap().description =
|
||||
"Retain direction, item contract and relation identity".into();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let nodes = [root, send, receive]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, element)| {
|
||||
p.place(
|
||||
diagram,
|
||||
element,
|
||||
Position {
|
||||
x: 43.25 + index as f32 * 271.,
|
||||
y: 97.5 + index as f32 * 83.,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let root_node = p
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nodes[0])
|
||||
.unwrap();
|
||||
root_node.size = Size {
|
||||
width: 611.,
|
||||
height: 319.,
|
||||
};
|
||||
root_node.collapsed = true;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
waypoints: vec![Position {
|
||||
x: 337.5,
|
||||
y: 423.25,
|
||||
}],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 11., y: -17. },
|
||||
}),
|
||||
});
|
||||
let artifacts = [root, send, exchange, nodes[0]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
|
||||
.collect();
|
||||
artifact(p, item, &format!("{prefix} shared item evidence"));
|
||||
Side {
|
||||
root,
|
||||
elements,
|
||||
exchange,
|
||||
diagram,
|
||||
nodes,
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted(p: &Project) {
|
||||
let issues = validation::validate(p);
|
||||
// This fixture deliberately isolates decomposition and correspondence from
|
||||
// performer allocation and process/chain/scenario inclusion. Each surviving
|
||||
// leaf (including a newly transitioned root) must have exactly those two
|
||||
// incomplete-work warnings; each surviving local exchange lacks a behavior
|
||||
// context. Check exact record IDs and fields, including duplicate warnings.
|
||||
// The system of interest and item contracts are complete fixture facts.
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.all(|issue| issue.severity == Severity::Warning),
|
||||
"{issues:?}"
|
||||
);
|
||||
let mut expected = Vec::new();
|
||||
for element in p.elements.values().filter(|element| {
|
||||
matches!(element.kind(), Kind::Activity | Kind::Function)
|
||||
&& !p
|
||||
.elements
|
||||
.values()
|
||||
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
|
||||
}) {
|
||||
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
|
||||
expected.push(("work_without_behavior_context", Some(element.id), None));
|
||||
}
|
||||
for relation in p.relations.values().filter(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
)
|
||||
}) {
|
||||
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
|
||||
}
|
||||
let mut actual = issues
|
||||
.iter()
|
||||
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort();
|
||||
actual.sort();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Unexpected fixture diagnostics: {issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn round_trip(p: &Project) -> String {
|
||||
accepted(p);
|
||||
let json = serde_json::to_vec_pretty(p).unwrap();
|
||||
assert_eq!(persistence::decode(&json).unwrap(), *p);
|
||||
let source = language::format(p);
|
||||
let compiled = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(compiled.decoded.project, *p);
|
||||
assert_eq!(language::format(&compiled.decoded.project), source);
|
||||
source
|
||||
}
|
||||
|
||||
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
fn fixture(direction: TransitionDirection) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
project.name = "Endpoint lifecycle conservation".into();
|
||||
project
|
||||
.add_element(Kind::System, Layer::Sa, "System of interest", None)
|
||||
.unwrap();
|
||||
let oa = side(&mut project, Layer::Oa);
|
||||
let sa = side(&mut project, Layer::Sa);
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (oa, sa),
|
||||
TransitionDirection::SaToOa => (sa, oa),
|
||||
};
|
||||
accepted(&project);
|
||||
let before = project.clone();
|
||||
let mut request = explicit(direction, source.root, target.root);
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: source.root,
|
||||
field,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
}
|
||||
let plan = plan_transition(&project, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
project = apply_transition(&project, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
|
||||
let mut without_trace = project.clone();
|
||||
without_trace.traces.remove(&trace);
|
||||
assert_eq!(
|
||||
without_trace, before,
|
||||
"Initial review must only add correspondence"
|
||||
);
|
||||
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
|
||||
round_trip(&project);
|
||||
Fixture {
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
trace,
|
||||
trace_artifact,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_only_new_trace(
|
||||
before: &Project,
|
||||
after: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Id {
|
||||
let added = after
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| !before.traces.contains_key(&trace.id))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(after.traces.len(), before.traces.len() + 1);
|
||||
let trace = added[0];
|
||||
let (oa, sa) = match direction {
|
||||
TransitionDirection::OaToSa => (source, target),
|
||||
TransitionDirection::SaToOa => (target, source),
|
||||
};
|
||||
assert_eq!(
|
||||
(trace.oa, trace.sa, trace.kind),
|
||||
(oa, sa, TraceKind::Refinement)
|
||||
);
|
||||
assert_eq!(
|
||||
trace.baseline,
|
||||
Some(TraceBaseline {
|
||||
oa: after.properties(oa).unwrap(),
|
||||
sa: after.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
|
||||
trace.id
|
||||
}
|
||||
|
||||
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
|
||||
let mut expected = fixture.project.clone();
|
||||
for id in &side.elements {
|
||||
assert!(expected.elements.remove(id).is_some());
|
||||
}
|
||||
assert!(expected.relations.remove(&side.exchange).is_some());
|
||||
assert!(expected.traces.remove(&fixture.trace).is_some());
|
||||
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
|
||||
assert!(expected.artifact_links.remove(id).is_some());
|
||||
}
|
||||
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
|
||||
for id in &side.nodes {
|
||||
assert!(diagram.nodes.remove(id).is_some());
|
||||
}
|
||||
// This fixture authored exactly one route, for its removed local exchange.
|
||||
assert_eq!(diagram.routes.len(), 1);
|
||||
assert_eq!(diagram.routes[0].edge, side.exchange);
|
||||
diagram.routes.clear();
|
||||
expected
|
||||
}
|
||||
|
||||
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
plan.changes[0].source,
|
||||
plan.changes[0].target,
|
||||
plan.changes[0].action
|
||||
),
|
||||
(source, target, TransitionAction::Reuse)
|
||||
);
|
||||
assert!(plan.changes[0].fields.is_empty());
|
||||
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
|
||||
let mut session = Session::new(p.clone());
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), p);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(!session.can_undo());
|
||||
}
|
||||
|
||||
fn source_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let original_source = round_trip(&fixture.project);
|
||||
let expected = expected_deletion(&fixture, &fixture.source);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.source.root))
|
||||
.unwrap();
|
||||
// Whole-project equality covers every retained target child, typed SA port,
|
||||
// exchange, placement, route, artifact, shared item and unchanged identity.
|
||||
assert_eq!(session.project(), &expected);
|
||||
let deleted_source = round_trip(session.project());
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
assert!(!session.can_undo(), "Deletion is one history transaction");
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(round_trip(session.project()), deleted_source);
|
||||
}
|
||||
|
||||
fn implicit_creation(
|
||||
p: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
forbidden_target: Id,
|
||||
) -> (Project, Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
let change = &plan.changes[0];
|
||||
assert_eq!(change.source, source);
|
||||
assert_eq!(change.action, TransitionAction::Create);
|
||||
let target = change.target;
|
||||
assert_ne!(target, forbidden_target);
|
||||
assert!(!p.contains(target));
|
||||
let after = apply_transition(p, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(p, &after, direction, source, target);
|
||||
let created = &after.elements[&target];
|
||||
assert_eq!(created.id, target, "Apply preserves the preview identity");
|
||||
assert_eq!(created.name, p.elements[&source].name);
|
||||
assert_eq!(created.description, p.elements[&source].description);
|
||||
assert_eq!(created.parent, None);
|
||||
assert_eq!(
|
||||
created.body,
|
||||
match direction {
|
||||
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
|
||||
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
|
||||
}
|
||||
);
|
||||
assert!(!created.alias.is_empty());
|
||||
assert!(
|
||||
!p.identities()
|
||||
.iter()
|
||||
.any(|(_, alias)| *alias == created.alias)
|
||||
);
|
||||
// Remove only the independently expected two additions. Any incidental
|
||||
// mutation, clone of target children or extra correspondence fails here.
|
||||
let mut retained = after.clone();
|
||||
retained.elements.remove(&target);
|
||||
retained.traces.remove(&trace);
|
||||
assert_eq!(retained, *p);
|
||||
round_trip(&after);
|
||||
// Replaying a creation plan after acceptance violates its snapshot, while
|
||||
// a fresh unchanged request reuses the accepted counterpart.
|
||||
assert!(
|
||||
apply_transition(&after, &plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
unchanged_retry(&after, direction, source, target);
|
||||
(after, target)
|
||||
}
|
||||
|
||||
fn target_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
|
||||
let old_plan = plan_transition(&fixture.project, &request).unwrap();
|
||||
assert!(old_plan.conflicts.is_empty());
|
||||
let expected = expected_deletion(&fixture, &fixture.target);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.target.root))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
round_trip(session.project());
|
||||
let revision = session.revision();
|
||||
assert!(
|
||||
apply_transition(session.project(), &old_plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
let explicit_plan = plan_transition(
|
||||
session.project(),
|
||||
&explicit(direction, fixture.source.root, fixture.target.root),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
explicit_plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(fixture.source.root)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(session.project(), &explicit_plan).is_err());
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(session.revision(), revision);
|
||||
let (created, _) = implicit_creation(
|
||||
session.project(),
|
||||
direction,
|
||||
fixture.source.root,
|
||||
fixture.target.root,
|
||||
);
|
||||
session.execute(Command::Replace(created.clone())).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &created);
|
||||
round_trip(session.project());
|
||||
}
|
||||
|
||||
fn source_recreation(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
|
||||
replacement.id = Id::new_v4();
|
||||
let source = replacement.id;
|
||||
// Same alias, name and prose, different UUID: name matching must never
|
||||
// silently relink the retained engineering design.
|
||||
assert_ne!(source, fixture.source.root);
|
||||
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
|
||||
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
|
||||
let recreated =
|
||||
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
|
||||
let mut expected = deleted.clone();
|
||||
expected.elements.insert(source, replacement);
|
||||
assert_eq!(recreated, expected);
|
||||
round_trip(&recreated);
|
||||
let (implicit, new_target) =
|
||||
implicit_creation(&recreated, direction, source, fixture.target.root);
|
||||
assert!(implicit.elements.contains_key(&fixture.target.root));
|
||||
assert_ne!(new_target, fixture.target.root);
|
||||
|
||||
// The explicit branch starts from exactly the same recreated project.
|
||||
let mut request = explicit(direction, source, fixture.target.root);
|
||||
let unreviewed = plan_transition(&recreated, &request).unwrap();
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
assert!(
|
||||
unreviewed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field == Some(field))
|
||||
);
|
||||
}
|
||||
assert!(apply_transition(&recreated, &unreviewed).is_err());
|
||||
request.resolutions = vec![
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::TakeSource,
|
||||
},
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Description,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
},
|
||||
];
|
||||
let plan = plan_transition(&recreated, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(plan.changes[0].target, plan.changes[0].action),
|
||||
(fixture.target.root, TransitionAction::Update)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changes[0].fields,
|
||||
vec![FieldChange {
|
||||
field: TransitionField::Name,
|
||||
before: recreated.elements[&fixture.target.root].name.clone(),
|
||||
after: recreated.elements[&source].name.clone(),
|
||||
}]
|
||||
);
|
||||
let relinked = apply_transition(&recreated, &plan).unwrap();
|
||||
let new_trace = assert_only_new_trace(
|
||||
&recreated,
|
||||
&relinked,
|
||||
direction,
|
||||
source,
|
||||
fixture.target.root,
|
||||
);
|
||||
assert_ne!(
|
||||
new_trace, fixture.trace,
|
||||
"Deleted correspondence is not silently resurrected"
|
||||
);
|
||||
let mut expected = recreated.clone();
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&fixture.target.root)
|
||||
.unwrap()
|
||||
.name = recreated.elements[&source].name.clone();
|
||||
let mut without_trace = relinked.clone();
|
||||
without_trace.traces.remove(&new_trace);
|
||||
assert_eq!(
|
||||
without_trace, expected,
|
||||
"Only the reviewed target name may change; rich target design survives"
|
||||
);
|
||||
round_trip(&relinked);
|
||||
unchanged_retry(&relinked, direction, source, fixture.target.root);
|
||||
let original_source = round_trip(&recreated);
|
||||
let mut session = Session::new(recreated.clone());
|
||||
session.execute(Command::Replace(relinked)).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &recreated);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
|
||||
source_recreation(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
|
||||
source_recreation(TransitionDirection::SaToOa);
|
||||
}
|
||||
Reference in New Issue
Block a user