639 lines
22 KiB
Rust
639 lines
22 KiB
Rust
//! 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,
|
|
// Both directions must preserve authored endpoint preferences,
|
|
// including dormant preferences on the SA semantic-port edge.
|
|
source_attachment: Some(EdgeAttachment {
|
|
side: PortAnchorSide::Bottom,
|
|
offset: 0.2,
|
|
}),
|
|
target_attachment: Some(EdgeAttachment {
|
|
side: PortAnchorSide::Top,
|
|
offset: 0.8,
|
|
}),
|
|
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);
|
|
}
|