576 lines
21 KiB
Rust
576 lines
21 KiB
Rust
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::<Command>(&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<Id> {
|
|
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);
|
|
}
|
|
}
|
|
}
|