use oa_core::{ references, transitions::*, validation::{self, Severity}, *, }; fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option) -> Id { p.add_element(kind, layer, &kind.to_string(), parent) .unwrap() } fn vertex(p: &mut Project, region: Id, kind: Kind) -> Id { add(p, kind, p.elements[®ion].layer(), Some(region)) } fn region(p: &mut Project, owner: Id) -> Id { add(p, Kind::Region, p.elements[&owner].layer(), Some(owner)) } fn edge(p: &mut Project, source: Id, target: Id) -> Id { p.relate(RelationKind::StateTransition, "Transition", source, target) } fn set_kind(p: &mut Project, id: Id, value: TransitionKind) { if let RelationBody::StateTransition { transition_kind, .. } = &mut p.relations.get_mut(&id).unwrap().body { *transition_kind = value; } } fn set_guard(p: &mut Project, id: Id, value: &str) { if let RelationBody::StateTransition { guard, .. } = &mut p.relations.get_mut(&id).unwrap().body { *guard = value.into(); } } fn event(p: &mut Project, layer: Layer, body: EventBody) -> Id { let id = add(p, Kind::Event, layer, None); *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event { body }; id } fn diagnostics(p: &Project, code: &str) -> Vec { validation::validate(p) .into_iter() .filter(|i| i.code == code) .collect() } fn valid(p: &Project) { let errors: Vec<_> = validation::validate(p) .into_iter() .filter(|i| i.severity == Severity::Error) .collect(); assert!(errors.is_empty(), "{errors:?}"); } fn rejects(p: &Project, code: &str) { assert!( diagnostics(p, code) .iter() .any(|i| i.severity == Severity::Error), "Expected {code}: {:?}", validation::validate(p) ); } fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) { let mut p = Project::empty(); let owner = add( &mut p, if layer == Layer::Oa { Kind::Entity } else { Kind::System }, layer, None, ); let machine = add(&mut p, Kind::StateMachine, layer, Some(owner)); let main = region(&mut p, machine); let initial = vertex(&mut p, main, Kind::Initial); let ready = vertex(&mut p, main, Kind::State); edge(&mut p, initial, ready); (p, owner, machine, main, ready) } #[test] fn every_vertex_is_an_editable_draft_but_only_stable_states_own_behavior_or_regions() { for layer in [Layer::Oa, Layer::Sa] { for kind in Kind::ALL.into_iter().filter(|k| k.is_state()) { let (mut p, _, _, main, _) = fixture(layer); if kind == Kind::Initial { continue; } let id = vertex(&mut p, main, kind); valid(&p); assert!(!validation::can_parent(&p, kind, layer, None)); assert!(validation::can_parent(&p, kind, layer, Some(main))); assert_eq!( validation::can_parent(&p, Kind::Region, layer, Some(id)), kind.is_stable_state() ); let work = add( &mut p, if layer == Layer::Oa { Kind::Activity } else { Kind::Function }, layer, None, ); assert_eq!( RelationKind::StateAction.permits(kind, p.elements[&work].kind()), kind.is_stable_state() ); assert_eq!( RelationKind::Availability.permits(p.elements[&work].kind(), kind), kind.is_stable_state() ); if !kind.is_stable_state() { if let Some(SharedBody::State { entry, .. }) = p.elements.get_mut(&id).unwrap().shared_mut() { *entry = "Retained legacy annotation".into(); } assert_eq!(diagnostics(&p, "pseudostate_behavior").len(), 1); valid(&p); } } } } #[test] fn typed_events_trigger_alternatives_and_effects_validate_without_owner_restrictions() { let (mut p, _, _, main, ready) = fixture(Layer::Sa); let end = vertex(&mut p, main, Kind::Final); let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, None); let signal = event(&mut p, Layer::Sa, EventBody::Signal { items: vec![item] }); let time = event( &mut p, Layer::Sa, EventBody::Time { kind: TimeEventKind::After, expression: "30 s".into(), }, ); let change = event( &mut p, Layer::Sa, EventBody::Change { expression: "pressure > limit".into(), }, ); let work = add(&mut p, Kind::Function, Layer::Sa, None); let external = add(&mut p, Kind::SystemActor, Layer::Sa, None); p.relate( RelationKind::Allocation, "External function", external, work, ); let transition = edge(&mut p, ready, end); if let RelationBody::StateTransition { triggers, effects, trigger, effect, .. } = &mut p.relations.get_mut(&transition).unwrap().body { *triggers = vec![signal, time, change, work]; *effects = vec![work]; *trigger = "Any of these events".into(); *effect = "Record the reason".into(); } valid(&p); assert!(diagnostics(&p, "unresolved_trigger").is_empty()); let baseline = p.clone(); for replacement in [vec![signal, signal], vec![item], vec![Id::new_v4()]] { let mut invalid = baseline.clone(); if let RelationBody::StateTransition { triggers, .. } = &mut invalid.relations.get_mut(&transition).unwrap().body { *triggers = replacement; } assert!(Session::try_new(invalid).is_err()); } if let RelationBody::StateTransition { effects, .. } = &mut p.relations.get_mut(&transition).unwrap().body { *effects = vec![signal]; } rejects(&p, "invalid_reference_kind"); p = baseline.clone(); if let Some(SharedBody::Event { body }) = p.elements.get_mut(&signal).unwrap().shared_mut() { *body = EventBody::Signal { items: vec![work] }; } rejects(&p, "invalid_reference_kind"); let mut p = baseline; let foreign = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![] }); if let RelationBody::StateTransition { triggers, .. } = &mut p.relations.get_mut(&transition).unwrap().body { *triggers = vec![foreign]; } rejects(&p, "invalid_reference_kind"); for body in [ EventBody::Time { kind: TimeEventKind::At, expression: String::new(), }, EventBody::Change { expression: " ".into(), }, ] { let mut p = Project::empty(); event(&mut p, Layer::Oa, body); valid(&p); assert_eq!(diagnostics(&p, "missing_event_expression").len(), 1); } } #[test] fn event_reference_identity_cleanup_and_undo_preserve_triggering_intent() { let (mut p, _, _, main, ready) = fixture(Layer::Oa); let end = vertex(&mut p, main, Kind::Final); let item = add(&mut p, Kind::ExchangeItem, Layer::Oa, None); let signal = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![item] }); let work = add(&mut p, Kind::Activity, Layer::Oa, None); let transition = edge(&mut p, ready, end); if let RelationBody::StateTransition { triggers, effects, guard, effect, .. } = &mut p.relations.get_mut(&transition).unwrap().body { *triggers = vec![signal]; *effects = vec![work]; *guard = "accepted".into(); *effect = "Effect annotation survives".into(); } valid(&p); for id in [item, signal, work, transition, ready, main] { let mut session = Session::new(p.clone()); let replacement = Id::new_v4(); session .execute(Command::Reidentify(id, replacement)) .unwrap(); assert!( session .project() .elements .values() .all(|e| !references::element_references(e).contains(&id)) ); assert!( session .project() .relations .values() .all(|r| !references::relation_references(r).contains(&id)) ); let renamed = session.project().clone(); session.execute(Command::Delete(replacement)).unwrap(); valid(session.project()); if id == signal { let RelationBody::StateTransition { trigger, triggers, effects, guard, effect, .. } = &session.project().relations[&transition].body else { panic!() }; assert!(triggers.is_empty()); assert!(trigger.contains(&p.elements[&signal].name)); assert!(trigger.contains("replacement trigger")); assert_eq!(effects, &[work]); assert_eq!(guard, "accepted"); assert_eq!(effect, "Effect annotation survives"); assert_eq!( diagnostics(session.project(), "unresolved_trigger").len(), 1 ); } if id == transition { assert!(session.project().elements.contains_key(&signal)); } session.undo(); assert_eq!(session.project(), &renamed); session.undo(); assert_eq!(session.project(), &p); session.redo(); assert_eq!(session.project(), &renamed); } let mut session = Session::new(p); let mut changed = session.project().relations[&transition].clone(); if let RelationBody::StateTransition { trigger, .. } = &mut changed.body { *trigger = "Exact authored legacy trigger".into(); } session .execute(Command::EditRelation(transition, changed)) .unwrap(); session.execute(Command::Delete(signal)).unwrap(); assert!( matches!(&session.project().relations[&transition].body, RelationBody::StateTransition { trigger, .. } if trigger == "Exact authored legacy trigger") ); } #[test] fn transition_kinds_distinguish_internal_external_self_and_local_composite_routing() { let (mut p, _, _, main, composite) = fixture(Layer::Sa); let nested = region(&mut p, composite); let child = vertex(&mut p, nested, Kind::Mode); let other = vertex(&mut p, main, Kind::State); let local = edge(&mut p, composite, child); set_kind(&mut p, local, TransitionKind::Local); let internal = edge(&mut p, composite, composite); set_kind(&mut p, internal, TransitionKind::Internal); edge(&mut p, child, child); edge(&mut p, child, other); valid(&p); for (source, target, kind) in [ (child, other, TransitionKind::Internal), (composite, composite, TransitionKind::Local), (composite, other, TransitionKind::Local), (child, other, TransitionKind::Local), ] { let mut invalid = p.clone(); let id = edge(&mut invalid, source, target); set_kind(&mut invalid, id, kind); rejects(&invalid, "transition_kind"); } let machine = add(&mut p, Kind::StateMachine, Layer::Sa, None); let foreign = region(&mut p, machine); let foreign = vertex(&mut p, foreign, Kind::State); assert!(!validation::can_connect( &p, RelationKind::StateTransition, child, foreign )); edge(&mut p, child, foreign); rejects(&p, "transition_machine"); } #[test] fn initial_final_and_terminate_reject_forbidden_routes_and_cardinality_atomically() { let (p, _, _, main, ready) = fixture(Layer::Oa); let initial = p .children(main) .find(|e| e.kind() == Kind::Initial) .unwrap() .id; assert!(!validation::can_connect( &p, RelationKind::StateTransition, initial, ready )); let mut invalid = p.clone(); edge(&mut invalid, initial, ready); rejects(&invalid, "initial_transition_count"); for kind in [Kind::Final, Kind::Terminate] { let mut p = p.clone(); let terminal = vertex(&mut p, main, kind); edge(&mut p, ready, terminal); valid(&p); assert!(!validation::can_connect( &p, RelationKind::StateTransition, terminal, ready )); edge(&mut p, terminal, ready); rejects(&p, "pseudostate_direction"); } for kind in Kind::ALL .into_iter() .filter(|k| k.is_state() && !k.is_stable_state()) { let mut p = p.clone(); let v = if kind == Kind::Initial { initial } else { vertex(&mut p, main, kind) }; let transition = edge(&mut p, v, v); rejects(&p, "pseudostate_self"); let mut before = p.clone(); before.relations.remove(&transition); let mut session = Session::new(before.clone()); assert!(session.execute(Command::Replace(p)).is_err()); assert_eq!(session.project(), &before); } for (prose, guard) in [("timer", ""), ("", "condition")] { let mut p = p.clone(); let id = p .relations .values() .find(|r| r.source() == initial) .unwrap() .id; if let RelationBody::StateTransition { trigger, guard: actual, .. } = &mut p.relations.get_mut(&id).unwrap().body { *trigger = prose.into(); *actual = guard.into(); } rejects(&p, "initial_transition"); } } #[test] fn choices_and_junctions_report_unfinished_guards_but_reject_conflicting_defaults_or_triggers() { let (mut p, _, _, main, ready) = fixture(Layer::Sa); let choice = vertex(&mut p, main, Kind::Choice); let junction = vertex(&mut p, main, Kind::Junction); let fallback = vertex(&mut p, main, Kind::State); edge(&mut p, ready, choice); let yes = edge(&mut p, choice, junction); set_guard(&mut p, yes, "pressure <= limit"); let no = edge(&mut p, choice, fallback); set_guard(&mut p, no, "else"); edge(&mut p, fallback, junction); edge(&mut p, junction, ready); valid(&p); assert!(diagnostics(&p, "missing_choice_guard").is_empty()); let mut invalid = p.clone(); set_guard(&mut invalid, yes, "else"); rejects(&invalid, "choice_default"); let mut draft = p.clone(); set_guard(&mut draft, yes, ""); set_guard(&mut draft, no, "unknown"); valid(&draft); assert_eq!(diagnostics(&draft, "missing_choice_guard").len(), 1); assert_eq!(diagnostics(&draft, "missing_choice_default").len(), 1); assert!(!validation::can_connect( &p, RelationKind::StateTransition, fallback, choice )); let mut invalid = p.clone(); edge(&mut invalid, fallback, choice); rejects(&invalid, "pseudostate_cardinality"); for source in [choice, junction] { let mut invalid = p.clone(); let id = invalid .relations .values() .find(|r| r.source() == source) .unwrap() .id; if let RelationBody::StateTransition { trigger, .. } = &mut invalid.relations.get_mut(&id).unwrap().body { *trigger = "after 30s".into(); } rejects(&invalid, "pseudostate_trigger"); } edge(&mut p, junction, fallback); valid(&p); assert_eq!(diagnostics(&p, "junction_guard").len(), 1); } #[test] fn histories_remain_per_region_with_shallow_direct_and_deep_nested_fallbacks() { let (mut p, _, _, main, ready) = fixture(Layer::Sa); let nested = region(&mut p, ready); let nested_state = vertex(&mut p, nested, Kind::State); let shallow = vertex(&mut p, main, Kind::ShallowHistory); let deep = vertex(&mut p, main, Kind::DeepHistory); assert_eq!(diagnostics(&p, "incomplete_state_vertex").len(), 2); let fallback = edge(&mut p, shallow, ready); edge(&mut p, deep, nested_state); valid(&p); let mut invalid = p.clone(); invalid .relations .get_mut(&fallback) .unwrap() .body .set_endpoints(shallow, nested_state); rejects(&invalid, "history_scope"); let mut invalid = p.clone(); set_guard(&mut invalid, fallback, "resume"); rejects(&invalid, "pseudostate_segment"); let mut invalid = p.clone(); edge(&mut invalid, deep, ready); rejects(&invalid, "pseudostate_cardinality"); for (kind, code) in [ (Kind::ShallowHistory, "multiple_shallow_history"), (Kind::DeepHistory, "multiple_deep_history"), ] { let mut invalid = p.clone(); vertex(&mut invalid, main, kind); rejects(&invalid, code); let mut valid_other = p.clone(); vertex(&mut valid_other, nested, kind); valid(&valid_other); } let outside = add(&mut p, Kind::StateMachine, Layer::Sa, None); let outside_region = region(&mut p, outside); let foreign = vertex(&mut p, outside_region, Kind::State); edge(&mut p, deep, foreign); rejects(&p, "history_scope"); } #[test] fn fork_join_detect_concurrency_at_the_common_composite_not_deep_immediate_regions() { let (mut p, _, machine, main, ready) = fixture(Layer::Sa); let fork = vertex(&mut p, main, Kind::Fork); let join = vertex(&mut p, main, Kind::Join); let left = vertex(&mut p, main, Kind::State); let left_region = region(&mut p, left); let left_leaf = vertex(&mut p, left_region, Kind::State); let right_region = region(&mut p, machine); let right = vertex(&mut p, right_region, Kind::State); let right_region = region(&mut p, right); let right_leaf = vertex(&mut p, right_region, Kind::State); edge(&mut p, ready, fork); let arm = edge(&mut p, fork, left_leaf); edge(&mut p, fork, right_leaf); edge(&mut p, left_leaf, join); edge(&mut p, right_leaf, join); edge(&mut p, join, ready); valid(&p); let sibling_region = region(&mut p, left); let sibling_leaf = vertex(&mut p, sibling_region, Kind::State); assert!(!validation::can_connect( &p, RelationKind::StateTransition, fork, sibling_leaf )); let mut invalid = p.clone(); edge(&mut invalid, fork, sibling_leaf); rejects(&invalid, "orthogonal_regions"); let mut invalid = p.clone(); set_guard(&mut invalid, arm, "ready"); rejects(&invalid, "pseudostate_segment"); let mut invalid = p.clone(); edge(&mut invalid, fork, join); rejects(&invalid, "orthogonal_endpoint"); let mut invalid = p.clone(); edge(&mut invalid, ready, fork); rejects(&invalid, "pseudostate_cardinality"); let mut invalid = p.clone(); edge(&mut invalid, join, left); rejects(&invalid, "pseudostate_cardinality"); let mut invalid = p.clone(); let incoming = invalid .relations .values() .find(|r| r.target() == join) .unwrap() .id; if let RelationBody::StateTransition { trigger, .. } = &mut invalid.relations.get_mut(&incoming).unwrap().body { *trigger = "synchronize".into(); } rejects(&invalid, "pseudostate_segment"); } #[test] fn boundary_points_enforce_inside_outside_local_routing_and_one_route_per_region() { let (mut p, _, machine, main, composite) = fixture(Layer::Sa); let outside = vertex(&mut p, main, Kind::State); let nested = region(&mut p, composite); let child = vertex(&mut p, nested, Kind::State); let entry = vertex(&mut p, nested, Kind::EntryPoint); let exit = vertex(&mut p, nested, Kind::ExitPoint); edge(&mut p, outside, entry); assert!(validation::can_connect( &p, RelationKind::StateTransition, entry, child )); let route = edge(&mut p, entry, child); assert!(matches!( p.relations[&route].body, RelationBody::StateTransition { transition_kind: TransitionKind::Local, .. } )); edge(&mut p, child, exit); edge(&mut p, exit, outside); valid(&p); for (source, target) in [ (child, entry), (entry, outside), (outside, exit), (exit, child), ] { assert!(!validation::can_connect( &p, RelationKind::StateTransition, source, target )); let mut invalid = p.clone(); edge(&mut invalid, source, target); rejects(&invalid, "boundary_transition"); } let other = vertex(&mut p, nested, Kind::Mode); assert!(!validation::can_connect( &p, RelationKind::StateTransition, entry, other )); let mut invalid = p.clone(); edge(&mut invalid, entry, other); rejects(&invalid, "entry_point_cardinality"); let mut invalid = p.clone(); set_kind(&mut invalid, route, TransitionKind::External); rejects(&invalid, "transition_kind"); let concurrent = region(&mut p, composite); let concurrent_state = vertex(&mut p, concurrent, Kind::State); assert_eq!(diagnostics(&p, "entry_region_default").len(), 1); edge(&mut p, entry, concurrent_state); valid(&p); assert!(diagnostics(&p, "entry_region_default").is_empty()); let top_region = region(&mut p, machine); let top_exit = vertex(&mut p, top_region, Kind::ExitPoint); edge(&mut p, outside, top_exit); valid(&p); assert!( !diagnostics(&p, "incomplete_state_vertex") .iter() .any(|i| i.element == Some(top_exit)) ); } #[test] fn reachability_follows_nested_default_entry_and_reports_disconnected_vertices() { let (mut p, _, _, main, composite) = fixture(Layer::Oa); let nested = region(&mut p, composite); let initial = vertex(&mut p, nested, Kind::Initial); let child = vertex(&mut p, nested, Kind::State); edge(&mut p, initial, child); let isolated = vertex(&mut p, main, Kind::Final); let unreachable = diagnostics(&p, "unreachable_state"); assert_eq!(unreachable.len(), 1); assert_eq!(unreachable[0].element, Some(isolated)); edge(&mut p, child, isolated); valid(&p); assert!(diagnostics(&p, "unreachable_state").is_empty()); } #[test] fn machine_subtrees_and_selected_edges_transition_events_work_and_payloads_in_both_directions() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let layer = direction.source_layer(); let (mut p, owner, machine, main, ready) = fixture(layer); let end = vertex(&mut p, main, Kind::Terminate); let vertices: Vec<_> = Kind::ALL .into_iter() .filter(|kind| { kind.is_state() && !matches!(kind, Kind::Initial | Kind::State | Kind::Terminate) }) .map(|kind| vertex(&mut p, main, kind)) .collect(); let item = add(&mut p, Kind::ExchangeItem, layer, None); let signal = event(&mut p, layer, EventBody::Signal { items: vec![item] }); let work = add( &mut p, if layer == Layer::Oa { Kind::Activity } else { Kind::Function }, layer, None, ); p.relate(RelationKind::Allocation, "Performs", owner, work); let transition = edge(&mut p, ready, end); if let RelationBody::StateTransition { triggers, effects, guard, .. } = &mut p.relations.get_mut(&transition).unwrap().body { *triggers = vec![signal, work]; *effects = vec![work]; *guard = "accepted".into(); } let action = p.relate(RelationKind::StateAction, "On entry", ready, work); let availability = p.relate(RelationKind::Availability, "Available", work, ready); valid(&p); for (source, scope) in [ (machine, DependencyScope::Subtree), (transition, DependencyScope::Required), ] { let mut request = TransitionRequest::new(direction, vec![source]); request.scope = scope; request.mappings.push(TransitionMapping { source: owner, target: None, target_kind: Some(if layer == Layer::Oa { Kind::System } else { Kind::Entity }), }); let plan = plan_transition(&p, &request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); let candidate = execute_batch(&p, &plan.commands).unwrap(); valid(&candidate); let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target; assert!( matches!(&candidate.relations[&mapped(transition)].body, RelationBody::StateTransition { triggers, effects, guard, .. } if *triggers == vec![mapped(signal), mapped(work)] && *effects == vec![mapped(work)] && guard == "accepted") ); assert!( matches!(&candidate.elements[&mapped(signal)].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if *items == vec![mapped(item)]) ); if scope == DependencyScope::Subtree { assert!(candidate.relations.contains_key(&mapped(action))); for id in &vertices { assert_eq!( candidate.elements[&mapped(*id)].kind(), p.elements[id].kind() ); assert!( candidate .traces .values() .any(|t| t.kind == TraceKind::State && (t.oa == *id || t.sa == *id)) ); } } assert!(!plan.changes.iter().any(|c| c.source == availability)); assert!( candidate .traces .values() .any(|t| t.kind == TraceKind::State && (t.oa == signal || t.sa == signal)) ); 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); } } }