Files
ARCADIA-Studio/crates/oa-core/tests/review_regressions.rs
T

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);
}
}
}