use oa_core::{ projection, references, transitions::*, validation::{self, Severity}, *, }; fn kinds(layer: Layer) -> (Kind, Kind, Kind) { match layer { Layer::Oa => (Kind::Actor, Kind::OperationalMission, Kind::Capability), Layer::Sa => (Kind::SystemActor, Kind::Mission, Kind::SystemCapability), } } fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id { p.add_element(kind, layer, name, None).unwrap() } fn transition(p: &Project, request: &TransitionRequest) -> (Project, TransitionPlan) { let plan = plan_transition(p, request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); (apply_transition(p, &plan).unwrap(), plan) } #[test] fn capability_and_mission_links_enforce_direction_family_and_layer() { for layer in [Layer::Oa, Layer::Sa] { let mut p = Project::empty(); let (participant_kind, mission_kind, capability_kind) = kinds(layer); let participant = add(&mut p, participant_kind, layer, "Mission participant"); let mission = add(&mut p, mission_kind, layer, "Protect lives"); let capability = add(&mut p, capability_kind, layer, "Rescue"); let other = add(&mut p, capability_kind, layer, "Locate distress"); let opposite = if layer == Layer::Oa { Layer::Sa } else { Layer::Oa }; let cross_layer = add(&mut p, kinds(opposite).2, opposite, "Remote capability"); for (kind, source, target) in [ (RelationKind::MissionInvolvement, participant, mission), (RelationKind::MissionCapability, mission, capability), (RelationKind::CapabilityInclude, capability, other), (RelationKind::CapabilityExtend, capability, other), (RelationKind::CapabilityGeneralization, capability, other), ] { assert!(validation::can_connect(&p, kind, source, target)); let created = execute_batch(&p, &[Command::Connect(kind, "Link".into(), source, target)]) .unwrap(); let relation = created.relations.values().next().unwrap(); assert_eq!(relation.body.endpoints(), (source, target)); for invalid_target in [source, cross_layer] { assert!(!validation::can_connect(&p, kind, source, invalid_target)); assert!( execute_batch( &p, &[Command::Connect( kind, "Invalid".into(), source, invalid_target )] ) .is_err() ); } } for (kind, source, target) in [ (RelationKind::MissionInvolvement, mission, participant), (RelationKind::MissionInvolvement, capability, mission), (RelationKind::MissionCapability, capability, mission), (RelationKind::CapabilityInclude, mission, capability), (RelationKind::CapabilityExtend, participant, capability), (RelationKind::CapabilityGeneralization, mission, mission), ] { assert!(!validation::can_connect(&p, kind, source, target)); } } } #[test] fn inclusion_and_specialization_reject_transitive_cycles_atomically() { for layer in [Layer::Oa, Layer::Sa] { for (kind, code) in [ (RelationKind::CapabilityInclude, "capability_include_cycle"), ( RelationKind::CapabilityGeneralization, "capability_generalization_cycle", ), ] { let mut p = Project::empty(); let ids: Vec<_> = ["A", "B", "C"] .into_iter() .map(|name| add(&mut p, kinds(layer).2, layer, name)) .collect(); p.relate(kind, "A to B", ids[0], ids[1]); p.relate(kind, "B to C", ids[1], ids[2]); let mut session = Session::new(p.clone()); session .execute(Command::Rename(ids[0], "Undoable draft".into())) .unwrap(); session.undo(); let revision = session.revision(); assert!(!validation::can_connect(&p, kind, ids[2], ids[0])); let error = session .execute_many(&[ Command::Rename(ids[0], "Must roll back".into()), Command::Connect(kind, "Would close cycle".into(), ids[2], ids[0]), ]) .unwrap_err(); assert!(error.0.contains(code), "{error}"); assert_eq!(session.project(), &p); assert_eq!(session.revision(), revision); assert!(session.can_redo()); // Even an imported or edited complete graph is checked, not just the connector UI. let closing = p.relate(kind, "Cycle", ids[2], ids[0]); assert!( validation::validate(&p) .iter() .any(|issue| issue.code == code && issue.element == Some(closing)) ); } } } #[test] fn conditional_extension_remains_editable_and_condition_is_validated() { let mut p = Project::empty(); let base = add(&mut p, Kind::Capability, Layer::Oa, "Rescue"); let extending = add(&mut p, Kind::Capability, Layer::Oa, "Night rescue"); let mut session = Session::new(p); let link = session .execute(Command::Connect( RelationKind::CapabilityExtend, "Night assistance".into(), extending, base, )) .unwrap() .unwrap(); assert!( validation::validate(session.project()) .iter() .any(|issue| issue.element == Some(link) && issue.code == "missing_extension_condition" && issue.severity == Severity::Warning) ); let mut edited = session.project().relations[&link].clone(); if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body { *condition = "daylight == false && visibility < 500".into(); } session .execute(Command::EditRelation(link, edited.clone())) .unwrap(); assert!( !validation::validate(session.project()) .iter() .any(|issue| issue.code == "missing_extension_condition") ); let accepted = session.project().clone(); if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body { *condition = "invalid\u{0000}condition".into(); } assert!( session .execute(Command::EditRelation(link, edited)) .is_err() ); assert_eq!(session.project(), &accepted); session.undo(); assert!( validation::validate(session.project()) .iter() .any(|issue| issue.code == "missing_extension_condition") ); session.redo(); assert_eq!(session.project(), &accepted); } #[test] fn operational_missions_have_typed_bidirectional_counterparts_and_legacy_choices() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let mut p = Project::empty(); let source = add( &mut p, kinds(direction.source_layer()).1, direction.source_layer(), "Protect lives", ); let request = TransitionRequest::new(direction, vec![source]); let (candidate, plan) = transition(&p, &request); let target = plan.changes[0].target; assert_eq!( candidate.elements[&target].kind(), kinds(direction.target_layer()).1 ); let trace = candidate.traces.values().next().unwrap(); assert_eq!(trace.kind, TraceKind::Mission); assert_eq!( candidate.elements[&trace.oa].kind(), Kind::OperationalMission ); assert_eq!(candidate.elements[&trace.sa].kind(), Kind::Mission); assert!(validation::trace_permits(&candidate, trace)); assert!( plan_transition(&candidate, &request) .unwrap() .commands .is_empty() ); let mut session = Session::new(p.clone()); session.execute_many(&plan.commands).unwrap(); session.undo(); assert_eq!(session.project(), &p); session.redo(); assert_eq!(session.project(), &candidate); } assert_eq!( counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa), vec![Kind::OperationalMission, Kind::Capability] ); assert_eq!( counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa), vec![Kind::SystemCapability, Kind::Mission] ); // A previously intentional capability↔mission relation remains a manual trace and is reused. let mut p = Project::empty(); let capability = add(&mut p, Kind::Capability, Layer::Oa, "User-level goal"); let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![capability]); request.mappings.push(TransitionMapping { source: capability, target: None, target_kind: Some(Kind::Mission), }); let (candidate, plan) = transition(&p, &request); let mission = plan.changes[0].target; assert_eq!( candidate.traces.values().next().unwrap().kind, TraceKind::Manual ); let reverse = plan_transition( &candidate, &TransitionRequest::new(TransitionDirection::SaToOa, vec![mission]), ) .unwrap(); assert!(reverse.conflicts.is_empty()); assert_eq!(reverse.changes[0].target, capability); assert!(reverse.commands.is_empty()); } fn mission_map(layer: Layer) -> (Project, Vec) { let mut p = Project::empty(); let (actor_kind, mission_kind, capability_kind) = kinds(layer); let actor = add(&mut p, actor_kind, layer, "Rescue coordinator"); let mission = add(&mut p, mission_kind, layer, "Protect lives"); let rescue = add(&mut p, capability_kind, layer, "Rescue"); let locate = add(&mut p, capability_kind, layer, "Locate distress"); let night = add(&mut p, capability_kind, layer, "Night rescue"); let mut relations = vec![ p.relate( RelationKind::MissionInvolvement, "Participates", actor, mission, ), p.relate( RelationKind::MissionCapability, "Delivered by", mission, rescue, ), p.relate( RelationKind::CapabilityInclude, "Always locate", rescue, locate, ), p.relate( RelationKind::CapabilityGeneralization, "Specializes rescue", night, rescue, ), ]; let extension = p.relate( RelationKind::CapabilityExtend, "Night assistance", night, rescue, ); if let RelationBody::CapabilityExtend { condition, .. } = &mut p.relations.get_mut(&extension).unwrap().body { *condition = "light_level < 10".into(); } relations.push(extension); (p, relations) } #[test] fn mission_map_transition_preserves_all_relationship_endpoints_and_conditions_both_ways() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, relations) = mission_map(direction.source_layer()); let mut request = TransitionRequest::new(direction, relations.clone()); request.scope = DependencyScope::Required; let actor = p .elements .values() .find(|e| e.kind().is_performer()) .unwrap() .id; request.mappings.push(TransitionMapping { source: actor, target: None, target_kind: Some(kinds(direction.target_layer()).0), }); let (candidate, plan) = transition(&p, &request); let mapped = |source| { plan.changes .iter() .find(|change| change.source == source) .unwrap() .target }; for id in &relations { let original = &p.relations[id]; let counterpart = &candidate.relations[&mapped(*id)]; assert_eq!(counterpart.kind(), original.kind()); assert_eq!( counterpart.body.endpoints(), (mapped(original.source()), mapped(original.target())) ); if let RelationBody::CapabilityExtend { condition, .. } = &counterpart.body { assert_eq!(condition, "light_level < 10"); } assert_eq!( references::relation_references(counterpart), vec![counterpart.source(), counterpart.target()] ); } assert!( candidate.elements.values().all(|e| e.parent.is_none()), "capability relations do not create containment" ); assert!( candidate .traces .values() .any(|t| t.kind == TraceKind::Mission) ); let mut reverse = TransitionRequest::new( if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa }, relations.iter().map(|id| mapped(*id)).collect(), ); reverse.scope = DependencyScope::Required; let repeated = plan_transition(&candidate, &reverse).unwrap(); assert!(repeated.conflicts.is_empty(), "{:?}", repeated.conflicts); assert!(repeated.commands.is_empty()); } } #[test] fn mission_reidentification_and_deletion_preserve_reference_integrity_and_undo() { let (mut p, relations) = mission_map(Layer::Oa); let mission = p .elements .values() .find(|e| e.kind() == Kind::OperationalMission) .unwrap() .id; let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Mission demonstration"); if let Some(SharedBody::Scenario { context, .. }) = p.elements.get_mut(&scenario).unwrap().shared_mut() { *context = Some(mission); } let diagram = p.ensure_diagram(Layer::Oa); let node = p.place(diagram, mission, Position { x: 10., y: 20. }); let evidence = Id::new_v4(); p.artifact_links.insert( evidence, ArtifactLink { id: evidence, alias: "mission_evidence".into(), name: "Mission evidence".into(), description: String::new(), owner: mission, relation: ArtifactRelation::Verifies, uri: "git:reviews/mission.md".into(), revision: Some("review-1".into()), fragment: None, }, ); let (p, plan) = transition( &p, &TransitionRequest::new(TransitionDirection::OaToSa, vec![mission]), ); let sa = plan.changes[0].target; let mut session = Session::new(p.clone()); let renamed_id = Id::new_v4(); session .execute(Command::Reidentify(mission, renamed_id)) .unwrap(); let updated = session.project(); assert_eq!(updated.relations[&relations[0]].target(), renamed_id); assert_eq!(updated.relations[&relations[1]].source(), renamed_id); assert!(references::element_references(&updated.elements[&scenario]).contains(&renamed_id)); assert_eq!(updated.diagrams[&diagram].nodes[&node].element, renamed_id); assert_eq!(updated.artifact_links[&evidence].owner, renamed_id); assert_eq!(updated.traces.values().next().unwrap().oa, renamed_id); let reidentified = updated.clone(); session.execute(Command::Delete(renamed_id)).unwrap(); let deleted = session.project(); assert!( deleted.elements.contains_key(&sa), "deletion never cascades to the opposite-layer counterpart" ); assert!(deleted.elements.contains_key(&scenario)); assert!(references::element_references(&deleted.elements[&scenario]).is_empty()); assert!(!deleted.relations.contains_key(&relations[0])); assert!(!deleted.relations.contains_key(&relations[1])); assert!(deleted.relations.contains_key(&relations[2])); assert!(deleted.traces.is_empty()); assert!(!deleted.artifact_links.contains_key(&evidence)); assert!(!deleted.diagrams[&diagram].nodes.contains_key(&node)); session.undo(); assert_eq!(session.project(), &reidentified); session.undo(); assert_eq!(session.project(), &p); // The same identity visitor covers incoming/outgoing include, extend, // generalization, and mission-capability endpoints. let capability = p.relations[&relations[1]].target(); let updated_capability = Id::new_v4(); session .execute(Command::Reidentify(capability, updated_capability)) .unwrap(); for original in p.relations.values() { let (source, target) = original.body.endpoints(); let map = |id| { if id == capability { updated_capability } else { id } }; assert_eq!( session.project().relations[&original.id].body.endpoints(), (map(source), map(target)) ); } let updated = session.project().clone(); session .execute(Command::Delete(updated_capability)) .unwrap(); assert!(session.project().relations.values().all(|relation| { !references::relation_references(relation).contains(&updated_capability) })); assert!(session.project().elements.contains_key(&mission)); assert!(session.project().relations.contains_key(&relations[0])); session.undo(); assert_eq!(session.project(), &updated); } #[test] fn capability_semantics_remain_edges_independent_of_visual_containment() { let mut p = Project::empty(); let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group"); let child = p .add_element( Kind::Capability, Layer::Oa, "Specific capability", Some(parent), ) .unwrap(); let include = p.relate( RelationKind::CapabilityInclude, "Includes group behavior", child, parent, ); let generalization = p.relate( RelationKind::CapabilityGeneralization, "Separate specialization", parent, child, ); validate_candidate(&p).unwrap(); let diagram = p.ensure_diagram(Layer::Oa); p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition; let parent_node = p.place(diagram, parent, Position { x: 0., y: 0. }); let child_node = p.place(diagram, child, Position { x: 25., y: 75. }); let view = projection::project_view(&p, diagram); assert_eq!( view.nodes .iter() .find(|n| n.id == child_node) .unwrap() .visual_parent, Some(parent_node) ); for (id, source, target) in [ (include, child_node, parent_node), (generalization, parent_node, child_node), ] { let edge = view.edges.iter().find(|edge| edge.id == id).unwrap(); assert_eq!((edge.source, edge.target), (source, target)); } }