//! 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, exchange: Id, diagram: Id, nodes: Vec, artifacts: Vec, } 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::>(); 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::>(); 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::>(); 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); }