use oa_core::{ references, transitions::*, validation::{self, Severity}, *, }; fn kinds(layer: Layer) -> (Kind, Kind) { match layer { Layer::Oa => (Kind::Process, Kind::Activity), Layer::Sa => (Kind::FunctionalChain, Kind::Function), } } fn occurrence(p: &Project, owner: Id, name: &str, body: OccurrenceBody) -> Element { let mut element = p .make_element( Kind::Occurrence, p.elements[&owner].layer(), name, Some(owner), ) .unwrap(); *element.shared_mut().unwrap() = SharedBody::Occurrence { body, precondition: String::new(), postcondition: String::new(), }; element } fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { let element = occurrence(p, owner, name, body); let id = element.id; p.elements.insert(id, element); p.elements .get_mut(&owner) .unwrap() .steps_mut() .unwrap() .push(id); id } fn action(p: &mut Project, owner: Id, name: &str) -> Id { let layer = p.elements[&owner].layer(); let work = p.add_element(kinds(layer).1, layer, name, None).unwrap(); let id = add_occurrence( p, owner, &format!("Perform {name}"), OccurrenceBody::Action { action: Some(work), lifeline: None, }, ); p.elements.get_mut(&id).unwrap().description = format!("Contribution within {}", p.elements[&owner].name); id } fn link(p: &mut Project, source: Id, target: Id, guard: &str) -> Id { let id = p.relate( RelationKind::SequenceLink, "Next contribution", source, target, ); if let RelationBody::SequenceLink { guard: value, .. } = &mut p.relations.get_mut(&id).unwrap().body { *value = guard.into(); } id } fn branch_fixture(layer: Layer) -> (Project, Id, Id) { let mut p = Project::empty(); let main = p .add_element(kinds(layer).0, layer, "Respond to distress", None) .unwrap(); let reusable = p .add_element(kinds(layer).0, layer, "Prepare resources", None) .unwrap(); action(&mut p, reusable, "Mobilize responder"); let start = action(&mut p, main, "Assess distress"); let fork = add_occurrence( &mut p, main, "Prepare in parallel", OccurrenceBody::Control { kind: ControlKind::Fork, }, ); let locate = action(&mut p, main, "Locate vessel"); let reuse = add_occurrence( &mut p, main, "Use preparations", OccurrenceBody::SequenceReference { sequence: Some(reusable), }, ); let join = add_occurrence( &mut p, main, "Both preparations ready", OccurrenceBody::Control { kind: ControlKind::Join, }, ); let choice = add_occurrence( &mut p, main, "Select response", OccurrenceBody::Control { kind: ControlKind::Choice, }, ); let direct = action(&mut p, main, "Dispatch rescue"); let fallback = action(&mut p, main, "Request assistance"); let merge = add_occurrence( &mut p, main, "Response selected", OccurrenceBody::Control { kind: ControlKind::Merge, }, ); let end = action(&mut p, main, "Confirm response"); for (source, target, guard) in [ (start, fork, ""), (fork, locate, ""), (fork, reuse, ""), (locate, join, ""), (reuse, join, ""), (join, choice, ""), (choice, direct, "available_resources > 0"), (choice, fallback, "else"), (direct, merge, ""), (fallback, merge, ""), (merge, end, ""), ] { link(&mut p, source, target, guard); } (p, main, reusable) } fn errors(p: &Project) -> Vec { validation::validate(p) .into_iter() .filter(|i| i.severity == Severity::Error) .collect() } fn coherent_path(p: &Project) { // These contextual-path fixtures deliberately leave the global system boundary // and performer allocation open. let unexpected: Vec<_> = validation::validate(p) .into_iter() .filter(|issue| { issue.severity != Severity::Warning || !matches!(issue.code.as_str(), "unallocated_leaf" | "missing_system") }) .collect(); assert!(unexpected.is_empty(), "{unexpected:?}"); } #[test] fn branching_reusable_processes_transition_as_contextual_topology_in_both_directions() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, main, reusable) = branch_fixture(direction.source_layer()); coherent_path(&p); let mut request = TransitionRequest::new(direction, vec![main]); request.scope = DependencyScope::Required; let plan = plan_transition(&p, &request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); let mapped = |id| { plan.changes .iter() .find(|change| change.source == id) .unwrap() .target }; let candidate = apply_transition(&p, &plan).unwrap(); assert_eq!( candidate.elements[&mapped(main)].kind(), kinds(direction.target_layer()).0 ); assert_eq!( candidate.elements[&mapped(reusable)].kind(), kinds(direction.target_layer()).0 ); for element in p.elements.values() { let counterpart = &candidate.elements[&mapped(element.id)]; assert_eq!(counterpart.description, element.description); let expected: Vec<_> = references::element_references(element) .into_iter() .map(mapped) .collect(); assert_eq!(references::element_references(counterpart), expected); if element.kind() == Kind::Occurrence { let trace = candidate .traces .values() .find(|trace| trace.oa == element.id || trace.sa == element.id) .unwrap(); assert_eq!(trace.kind, TraceKind::Sequence); } } for relation in p.relations.values() { let counterpart = &candidate.relations[&mapped(relation.id)]; let mut expected = relation.body.clone(); expected.set_endpoints(mapped(relation.source()), mapped(relation.target())); assert_eq!(counterpart.body, expected); } assert!( candidate .relations .values() .all(|r| r.kind() == RelationKind::SequenceLink), "contextual precedence never fabricates global exchanges" ); let mut session = Session::new(p.clone()); session.execute_many(&plan.commands).unwrap(); assert_eq!(session.project(), &candidate); session.undo(); assert_eq!(session.project(), &p); session.redo(); assert_eq!(session.project(), &candidate); let mut reverse = TransitionRequest::new( if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa }, vec![mapped(main)], ); reverse.scope = DependencyScope::Required; let repeat = plan_transition(&candidate, &reverse).unwrap(); assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts); assert!(repeat.commands.is_empty()); } } #[test] fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() { for layer in [Layer::Oa, Layer::Sa] { let mut p = Project::empty(); let owner = p .add_element(kinds(layer).0, layer, "Context", None) .unwrap(); let a = action(&mut p, owner, "A"); let b = action(&mut p, owner, "B"); let wrong = action(&mut p, owner, "Wrong"); let work = |p: &Project, id| match p.elements[&id].shared().unwrap() { SharedBody::Occurrence { body: OccurrenceBody::Action { action: Some(work), .. }, .. } => *work, _ => unreachable!(), }; let global = p.relate( if layer == Layer::Oa { RelationKind::Interaction } else { RelationKind::FunctionalExchange }, "A sends to B", work(&p, a), work(&p, b), ); let exchange = add_occurrence( &mut p, owner, "Contextual message", OccurrenceBody::Exchange { operation: None, exchange: Some(global), sender: None, receiver: None, message_kind: MessageKind::Asynchronous, reply_to: None, values: vec![], }, ); let other = p .add_element(kinds(layer).0, layer, "Other context", None) .unwrap(); let foreign = action(&mut p, other, "Foreign"); for (source, target) in [(a, exchange), (exchange, b)] { assert!(validation::can_connect( &p, RelationKind::SequenceLink, source, target )); } for (source, target) in [(wrong, exchange), (exchange, wrong), (a, foreign), (a, a)] { assert!(!validation::can_connect( &p, RelationKind::SequenceLink, source, target )); assert!( execute_batch( &p, &[Command::Connect( RelationKind::SequenceLink, "Invalid".into(), source, target )] ) .is_err() ); } let original_exchange = p.relations[&global].clone(); let accepted = execute_batch( &p, &[ Command::Connect(RelationKind::SequenceLink, "Before".into(), a, exchange), Command::Connect(RelationKind::SequenceLink, "After".into(), exchange, b), ], ) .unwrap(); assert_eq!(accepted.relations[&global], original_exchange); let scenario = p .add_element(Kind::Scenario, layer, "Timeline", None) .unwrap(); let invalid = occurrence( &p, scenario, "Wrong control context", OccurrenceBody::Control { kind: ControlKind::Fork, }, ); assert!( execute_batch(&p, &[Command::CreateElement(invalid)]) .unwrap_err() .0 .contains("sequence_contribution_owner") ); } } #[test] fn reuse_rejects_recursive_or_cross_layer_targets_and_deleted_targets_become_drafts() { let mut p = Project::empty(); let a = p.add_element(Kind::Process, Layer::Oa, "A", None).unwrap(); let b = p.add_element(Kind::Process, Layer::Oa, "B", None).unwrap(); let c = p.add_element(Kind::Process, Layer::Oa, "C", None).unwrap(); let use_b = add_occurrence( &mut p, a, "Reuse B", OccurrenceBody::SequenceReference { sequence: Some(b) }, ); add_occurrence( &mut p, b, "Reuse C", OccurrenceBody::SequenceReference { sequence: Some(c) }, ); let foreign = p .add_element(Kind::FunctionalChain, Layer::Sa, "SA sequence", None) .unwrap(); let mut session = Session::new(p.clone()); for target in [a, c, foreign] { let invalid = occurrence( session.project(), c, "Invalid reuse", OccurrenceBody::SequenceReference { sequence: Some(target), }, ); assert!(session.execute(Command::CreateElement(invalid)).is_err()); assert_eq!(session.project(), &p); } let new_b = Id::new_v4(); session.execute(Command::Reidentify(b, new_b)).unwrap(); assert!(references::element_references(&session.project().elements[&use_b]).contains(&new_b)); let reidentified = session.project().clone(); session.execute(Command::Delete(new_b)).unwrap(); assert!(session.project().elements.contains_key(&use_b)); assert!(matches!( session.project().elements[&use_b].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: None }, .. }) )); assert!(session.project().elements.contains_key(&c)); assert!(errors(session.project()).is_empty()); assert!( validation::validate(session.project()) .iter() .any(|issue| issue.element == Some(use_b) && issue.code == "incomplete_reference") ); session.undo(); assert_eq!(session.project(), &reidentified); session.undo(); assert_eq!(session.project(), &p); } #[test] fn loops_are_allowed_while_missing_guards_and_disconnected_paths_are_diagnosed() { let mut p = Project::empty(); let owner = p .add_element(Kind::Process, Layer::Oa, "Retry until ready", None) .unwrap(); let start = action(&mut p, owner, "Start"); let gate = add_occurrence( &mut p, owner, "Ready?", OccurrenceBody::Control { kind: ControlKind::Loop, }, ); let repeat = action(&mut p, owner, "Retry"); let end = action(&mut p, owner, "Finish"); link(&mut p, start, gate, ""); link(&mut p, gate, repeat, "!ready"); link(&mut p, repeat, gate, ""); let exit = link(&mut p, gate, end, "ready"); coherent_path(&p); if let RelationBody::SequenceLink { guard, .. } = &mut p.relations.get_mut(&exit).unwrap().body { guard.clear(); } assert!(errors(&p).is_empty()); assert!( validation::validate(&p) .iter() .any(|i| i.element == Some(exit) && i.code == "missing_sequence_guard") ); let island = action(&mut p, owner, "Disconnected work"); assert!( validation::validate(&p) .iter() .any(|i| i.element == Some(island) && i.code == "isolated_contribution") ); p.relations.remove(&exit); assert!( validation::validate(&p) .iter() .any(|i| i.element == Some(gate) && i.code == "control_cardinality") ); assert!( validation::validate(&p) .iter() .any(|i| i.element == Some(gate) && i.code == "nonterminating_contribution") ); } #[test] fn topology_identity_edits_and_deletions_preserve_remaining_paths_and_undo() { let (p, main, _) = branch_fixture(Layer::Sa); for id in p.elements.keys().chain(p.relations.keys()).copied() { let mut session = Session::new(p.clone()); let replacement = Id::new_v4(); session .execute(Command::Reidentify(id, replacement)) .unwrap(); let reidentified = session.project().clone(); assert!( !reidentified .elements .values() .any(|e| references::element_references(e).contains(&id)) ); assert!( !reidentified .relations .values() .any(|r| references::relation_references(r).contains(&id)) ); session.execute(Command::Delete(replacement)).unwrap(); assert!(errors(session.project()).is_empty()); session.undo(); assert_eq!(session.project(), &reidentified); session.undo(); assert_eq!(session.project(), &p); } // List order is presentation order: changing it leaves explicit links untouched. let mut session = Session::new(p.clone()); session.execute(Command::MoveStep(main, 0, 4)).unwrap(); assert_eq!(session.project().relations, p.relations); coherent_path(session.project()); } #[test] fn legacy_linear_exchange_only_sequences_keep_their_existing_meaning() { let mut p = Project::empty(); let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap(); let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap(); let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap(); let owner = p .add_element(Kind::Process, Layer::Oa, "Legacy order", None) .unwrap(); let ab = p.relate(RelationKind::Interaction, "AB", a, b); let ca = p.relate(RelationKind::Interaction, "CA", c, a); let mut session = Session::new(p); session.execute(Command::AddStep(owner, ab)).unwrap(); session.execute(Command::AddStep(owner, ca)).unwrap(); let issues = validation::validate(session.project()); assert!(issues.iter().any(|i| i.code == "sequence_gap")); assert!( !issues .iter() .any(|i| i.code == "isolated_contribution" || i.code == "sequence_starts") ); assert!( session .project() .relations .values() .all(|r| r.kind() != RelationKind::SequenceLink) ); }