use oa_core::{ persistence::{self, FileError}, transitions::*, *, }; #[test] fn unowned_state_machine_is_valid_incomplete_work_in_both_layers() { for layer in [Layer::Oa, Layer::Sa] { let p = Project::empty(); assert!(validation::can_parent(&p, Kind::StateMachine, layer, None)); let machine = p .make_element(Kind::StateMachine, layer, "Draft machine", None) .unwrap(); let id = machine.id; let created = execute_batch(&p, &[Command::CreateElement(machine)]).unwrap(); let issues = validation::validate(&created); assert!( issues .iter() .all(|i| i.severity != validation::Severity::Error) ); assert!(issues.iter().any(|i| i.element == Some(id) && i.code == "machine_owner" && i.severity == validation::Severity::Warning)); assert!(!validation::can_parent(&created, Kind::Region, layer, None)); } } fn placement_fixture() -> (Project, Id, Id, Id) { let mut p = Project::empty(); let element = p .add_element(Kind::Activity, Layer::Oa, "Placed work", None) .unwrap(); let source = p.ensure_diagram(Layer::Oa); p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Decomposition; let target = p.ensure_diagram(Layer::Oa); let node = p.place(source, element, Position { x: 123., y: -42. }); let placement = p .diagrams .get_mut(&source) .unwrap() .nodes .get_mut(&node) .unwrap(); placement.collapsed = true; placement.size = Size { width: 350., height: 135., }; let artifact = Id::new_v4(); p.artifact_links.insert( artifact, ArtifactLink { id: artifact, alias: "placement_evidence".into(), name: "Placement evidence".into(), description: "Retained with the same placement".into(), owner: node, relation: ArtifactRelation::References, uri: "git:designs/layout#view".into(), revision: Some("layout-v2".into()), fragment: None, }, ); (p, source, target, node) } #[test] fn relocating_a_placement_preserves_identity_artifacts_and_history() { let (p, source, target, node) = placement_fixture(); let original = p.diagrams[&source].nodes[&node].clone(); let command = Command::RelocateNode { node, diagram: target, }; let json = serde_json::to_string(&command).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), command); let mut session = Session::new(p.clone()); assert_eq!(session.execute(command.clone()).unwrap(), Some(node)); assert!( !session.project().diagrams[&source] .nodes .contains_key(&node) ); assert_eq!(session.project().diagrams[&target].nodes[&node], original); assert_eq!(session.project().artifact_links, p.artifact_links); assert_eq!(session.project().elements, p.elements); let revision = session.revision(); let relocated = session.project().clone(); session.execute(command).unwrap(); assert_eq!(session.revision(), revision); session.undo(); assert_eq!(session.project(), &p); session.redo(); assert_eq!(session.project(), &relocated); } #[test] fn invalid_placement_relocations_preserve_model_artifacts_and_redo() { let (mut p, source, target, node) = placement_fixture(); let element = p.diagrams[&source].nodes[&node].element; p.place(target, element, Position { x: 0., y: 0. }); let incompatible = p.ensure_diagram(Layer::Sa); let mut session = Session::new(p.clone()); session .execute(Command::Rename(element, "Pending redo".into())) .unwrap(); session.undo(); let revision = session.revision(); for diagram in [target, incompatible, Id::new_v4()] { assert!( session .execute(Command::RelocateNode { node, diagram }) .is_err() ); assert_eq!(session.project(), &p); assert_eq!(session.revision(), revision); assert!(session.can_redo()); } assert!( execute_batch( &p, &[Command::RelocateNode { node: Id::new_v4(), diagram: source }] ) .is_err() ); let mut traceability = p.diagrams[&incompatible].clone(); traceability.projection = ProjectionKind::Traceability; let accepted = execute_batch( &p, &[ Command::EditDiagram(incompatible, traceability), Command::RelocateNode { node, diagram: incompatible, }, ], ) .unwrap(); assert_eq!( accepted.diagrams[&incompatible].nodes[&node], p.diagrams[&source].nodes[&node] ); assert_eq!(accepted.artifact_links, p.artifact_links); } fn reviewed_divergence(direction: TransitionDirection) -> (Project, Id, Id) { let mut p = Project::empty(); let oa = p .add_element(Kind::Activity, Layer::Oa, "Operational assessment", None) .unwrap(); let sa = p .add_element(Kind::Function, Layer::Sa, "System classification", None) .unwrap(); p.elements.get_mut(&oa).unwrap().description = "Operational intent".into(); p.elements.get_mut(&sa).unwrap().description = "Technical responsibility".into(); let (source, target) = match direction { TransitionDirection::OaToSa => (oa, sa), TransitionDirection::SaToOa => (sa, oa), }; let mut request = TransitionRequest::new(direction, vec![source]); request.mappings.push(TransitionMapping { source, target: Some(target), target_kind: None, }); for field in [TransitionField::Name, TransitionField::Description] { request.resolutions.push(FieldResolution { source, field, decision: FieldDecision::KeepTarget, }); } let plan = plan_transition(&p, &request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); let reviewed = apply_transition(&p, &plan).unwrap(); assert_eq!(reviewed.properties(source), p.properties(source)); assert_eq!(reviewed.properties(target), p.properties(target)); (reviewed, source, target) } fn reverse(direction: TransitionDirection) -> TransitionDirection { match direction { TransitionDirection::OaToSa => TransitionDirection::SaToOa, TransitionDirection::SaToOa => TransitionDirection::OaToSa, } } #[test] fn keeping_divergent_values_is_idempotent_in_both_directions() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, source, target) = reviewed_divergence(direction); for (direction, source) in [(direction, source), (reverse(direction), target)] { let plan = plan_transition(&p, &TransitionRequest::new(direction, vec![source])).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); assert!(plan.commands.is_empty(), "{:?}", plan.commands); assert!( plan.changes .iter() .all(|c| c.action == TransitionAction::Reuse && c.fields.is_empty()) ); assert_eq!(apply_transition(&p, &plan).unwrap(), p); } } } #[test] fn later_source_changes_propagate_without_overwriting_other_reviewed_fields() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, source, target) = reviewed_divergence(direction); let target_description = p.elements[&target].description.clone(); let edited = execute_batch(&p, &[Command::Rename(source, "New source decision".into())]).unwrap(); let request = TransitionRequest::new(direction, vec![source]); let plan = plan_transition(&edited, &request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); assert_eq!(plan.changes[0].fields.len(), 1); assert_eq!(plan.changes[0].fields[0].field, TransitionField::Name); let applied = apply_transition(&edited, &plan).unwrap(); assert_eq!(applied.elements[&target].name, "New source decision"); assert_eq!(applied.elements[&target].description, target_description); let repeat = plan_transition(&applied, &request).unwrap(); assert!(repeat.conflicts.is_empty()); assert!(repeat.commands.is_empty()); } } #[test] fn destination_only_changes_remain_available_for_reverse_propagation() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, source, target) = reviewed_divergence(direction); let edited = execute_batch( &p, &[Command::Rename(target, "Destination local change".into())], ) .unwrap(); let forward = plan_transition(&edited, &TransitionRequest::new(direction, vec![source])).unwrap(); assert!(forward.conflicts.is_empty(), "{:?}", forward.conflicts); assert!( forward.commands.is_empty(), "A skipped field must retain its previous baseline" ); assert_eq!(apply_transition(&edited, &forward).unwrap(), edited); let back = plan_transition( &edited, &TransitionRequest::new(reverse(direction), vec![target]), ) .unwrap(); assert!(back.conflicts.is_empty(), "{:?}", back.conflicts); let applied = apply_transition(&edited, &back).unwrap(); assert_eq!(applied.elements[&source].name, "Destination local change"); } } #[test] fn divergent_changes_on_both_sides_still_require_review() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, source, target) = reviewed_divergence(direction); let changed = execute_batch( &p, &[ Command::Rename(source, "Changed source".into()), Command::Rename(target, "Changed target".into()), ], ) .unwrap(); let request = TransitionRequest::new(direction, vec![source]); let plan = plan_transition(&changed, &request).unwrap(); assert!( plan.conflicts .iter() .any(|c| c.field == Some(TransitionField::Name)) ); assert!(apply_transition(&changed, &plan).is_err()); assert_eq!(changed.elements[&target].name, "Changed target"); } } #[test] fn an_explicit_merged_destination_stays_reviewed_on_repeated_preview() { for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let (p, source, target) = reviewed_divergence(direction); let mut request = TransitionRequest::new(direction, vec![source]); request.resolutions.push(FieldResolution { source, field: TransitionField::Name, decision: FieldDecision::Value("Reviewed merged name".into()), }); let plan = plan_transition(&p, &request).unwrap(); assert!(plan.conflicts.is_empty()); let applied = apply_transition(&p, &plan).unwrap(); assert_eq!(applied.elements[&target].name, "Reviewed merged name"); let repeated = plan_transition(&applied, &TransitionRequest::new(direction, vec![source])).unwrap(); assert!(repeated.conflicts.is_empty()); assert!(repeated.commands.is_empty()); } } #[test] fn same_parent_reparent_preserves_sequence_order_revision_and_redo() { 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 exchange = p.relate(RelationKind::Interaction, "Message", a, b); let sequence = p .add_element(Kind::Process, Layer::Oa, "Procedure", None) .unwrap(); let mut session = Session::new(p); for _ in 0..3 { session .execute(Command::AddStep(sequence, exchange)) .unwrap(); } let first = session.project().elements[&sequence].steps().unwrap()[0]; session .execute(Command::Rename(sequence, "Temporary name".into())) .unwrap(); session.undo(); let before = session.project().clone(); let revision = session.revision(); session .execute(Command::Reparent { element: first, parent: Some(sequence), }) .unwrap(); assert_eq!(session.project(), &before); assert_eq!(session.revision(), revision); assert!(session.can_redo()); } fn fragment_session(layer: Layer) -> (Session, Id) { let mut p = Project::empty(); let scenario = p .add_element(Kind::Scenario, layer, "Scenario", None) .unwrap(); let mut fragment = p .make_element(Kind::Occurrence, layer, "Alternative", Some(scenario)) .unwrap(); *fragment.shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Fragment { operator: FragmentOperator::Alternative, branches: vec![], }, precondition: String::new(), postcondition: String::new(), }; let id = fragment.id; let mut session = Session::new(p); session.execute(Command::CreateElement(fragment)).unwrap(); (session, id) } fn branches(p: &Project, fragment: Id) -> &Vec { match p.elements[&fragment].shared().unwrap() { SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. } => branches, _ => panic!("Expected fragment"), } } #[test] fn same_parent_reparent_preserves_fragment_branch_order_and_history() { for layer in [Layer::Oa, Layer::Sa] { let (mut session, fragment) = fragment_session(layer); for name in ["First branch", "Second branch", "Third branch"] { session .execute(Command::AddElement { kind: Kind::Occurrence, layer, name: name.into(), parent: Some(fragment), }) .unwrap(); } let first = branches(session.project(), fragment)[0]; session .execute(Command::Rename(fragment, "Temporary name".into())) .unwrap(); session.undo(); let before = session.project().clone(); let revision = session.revision(); session .execute(Command::Reparent { element: first, parent: Some(fragment), }) .unwrap(); assert_eq!(session.project(), &before); assert_eq!(session.revision(), revision); assert!(session.can_redo()); } } #[test] fn factory_and_add_commands_share_fragment_branch_defaults() { for layer in [Layer::Oa, Layer::Sa] { let (mut session, fragment) = fragment_session(layer); let child = session .project() .make_element(Kind::Occurrence, layer, "Factory branch", Some(fragment)) .unwrap(); let factory_id = child.id; assert!(matches!( child.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) )); session.execute(Command::CreateElement(child)).unwrap(); let added_id = session .execute(Command::AddElement { kind: Kind::Occurrence, layer, name: "Command branch".into(), parent: Some(fragment), }) .unwrap() .unwrap(); assert_eq!( session.project().elements[&factory_id].body, session.project().elements[&added_id].body ); assert_eq!( branches(session.project(), fragment), &vec![factory_id, added_id] ); let step = session .project() .make_element(Kind::Occurrence, layer, "Branch message", Some(factory_id)) .unwrap(); assert!(matches!( step.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { .. }, .. }) )); session.execute(Command::CreateElement(step)).unwrap(); } } #[test] fn checked_save_returns_exact_bytes_and_accepts_unchanged_external_content() { let dir = tempfile::tempdir().unwrap(); for name in ["project.arcadia", "project.oa.json"] { let path = dir.path().join(name); let mut p = Project::empty(); let previous = persistence::save_checked(&path, &p, None).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), previous); p.description = "A checked update with Unicode 海岸 and a newline\n".into(); let written = persistence::save_checked(&path, &p, Some(&previous)).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), written); assert_eq!(persistence::decode(&written).unwrap(), p); let same = persistence::save_checked(&path, &p, Some(&written)).unwrap(); assert_eq!(same, written); assert_eq!(std::fs::read(&path).unwrap(), written); } } #[test] fn checked_save_rejects_external_changes_and_preserves_their_exact_bytes() { let dir = tempfile::tempdir().unwrap(); for name in ["project.arcadia", "project.oa.json"] { let path = dir.path().join(name); let mut p = Project::empty(); let previous = persistence::save_checked(&path, &p, None).unwrap(); let external = b"External editor content, including an unfinished draft\r\n"; std::fs::write(&path, external).unwrap(); p.name = "Local unsaved changes".into(); assert!(matches!( persistence::save_checked(&path, &p, Some(&previous)), Err(FileError::Conflict) )); assert_eq!(std::fs::read(&path).unwrap(), external); } } #[test] fn checked_save_rejects_external_deletion_without_recreating_the_file() { let dir = tempfile::tempdir().unwrap(); for name in ["project.arcadia", "project.oa.json"] { let path = dir.path().join(name); let p = Project::empty(); let previous = persistence::save_checked(&path, &p, None).unwrap(); std::fs::remove_file(&path).unwrap(); assert!(matches!( persistence::save_checked(&path, &p, Some(&previous)), Err(FileError::Conflict) )); assert!(!path.exists()); } assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn capability_mission_transition_remains_an_explicit_choice_in_both_directions() { assert_eq!( counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa), vec![Kind::SystemCapability, Kind::Mission] ); assert_eq!( counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa), vec![Kind::OperationalMission, Kind::Capability] ); for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { let mut p = Project::empty(); let (kind, layer) = match direction { TransitionDirection::OaToSa => (Kind::Capability, Layer::Oa), TransitionDirection::SaToOa => (Kind::Mission, Layer::Sa), }; let source = p.add_element(kind, layer, "Protect life", None).unwrap(); let mut request = TransitionRequest::new(direction, vec![source]); if direction == TransitionDirection::OaToSa { let default_plan = plan_transition(&p, &request).unwrap(); let default = apply_transition(&p, &default_plan).unwrap(); assert_eq!( default.elements[&default_plan.changes[0].target].kind(), Kind::SystemCapability ); request.mappings.push(TransitionMapping { source, target: None, target_kind: Some(Kind::Mission), }); } else { let default_plan = plan_transition(&p, &request).unwrap(); let default = apply_transition(&p, &default_plan).unwrap(); assert_eq!( default.elements[&default_plan.changes[0].target].kind(), Kind::OperationalMission ); request.mappings.push(TransitionMapping { source, target: None, target_kind: Some(Kind::Capability), }); } let plan = plan_transition(&p, &request).unwrap(); assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); let target = plan.changes[0].target; let applied = apply_transition(&p, &plan).unwrap(); assert_eq!( applied.elements[&target].kind(), if direction == TransitionDirection::OaToSa { Kind::Mission } else { Kind::Capability } ); assert_eq!(applied.traces.len(), 1); assert_eq!( applied.traces.values().next().unwrap().kind, TraceKind::Manual ); for (direction, source) in [(direction, source), (reverse(direction), target)] { let repeat = plan_transition(&applied, &TransitionRequest::new(direction, vec![source])) .unwrap(); assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts); assert!(repeat.commands.is_empty()); assert_eq!(apply_transition(&applied, &repeat).unwrap(), applied); } } }