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

515 lines
19 KiB
Rust

use oa_core::{
projection, references,
transitions::*,
validation::{self, Severity},
*,
};
fn kinds(layer: Layer) -> (Kind, Kind, Kind) {
match layer {
Layer::Oa => (Kind::Actor, Kind::OperationalMission, Kind::Capability),
Layer::Sa => (Kind::SystemActor, Kind::Mission, Kind::SystemCapability),
}
}
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn transition(p: &Project, request: &TransitionRequest) -> (Project, TransitionPlan) {
let plan = plan_transition(p, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_transition(p, &plan).unwrap(), plan)
}
#[test]
fn capability_and_mission_links_enforce_direction_family_and_layer() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (participant_kind, mission_kind, capability_kind) = kinds(layer);
let participant = add(&mut p, participant_kind, layer, "Mission participant");
let mission = add(&mut p, mission_kind, layer, "Protect lives");
let capability = add(&mut p, capability_kind, layer, "Rescue");
let other = add(&mut p, capability_kind, layer, "Locate distress");
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let cross_layer = add(&mut p, kinds(opposite).2, opposite, "Remote capability");
for (kind, source, target) in [
(RelationKind::MissionInvolvement, participant, mission),
(RelationKind::MissionCapability, mission, capability),
(RelationKind::CapabilityInclude, capability, other),
(RelationKind::CapabilityExtend, capability, other),
(RelationKind::CapabilityGeneralization, capability, other),
] {
assert!(validation::can_connect(&p, kind, source, target));
let created =
execute_batch(&p, &[Command::Connect(kind, "Link".into(), source, target)])
.unwrap();
let relation = created.relations.values().next().unwrap();
assert_eq!(relation.body.endpoints(), (source, target));
for invalid_target in [source, cross_layer] {
assert!(!validation::can_connect(&p, kind, source, invalid_target));
assert!(
execute_batch(
&p,
&[Command::Connect(
kind,
"Invalid".into(),
source,
invalid_target
)]
)
.is_err()
);
}
}
for (kind, source, target) in [
(RelationKind::MissionInvolvement, mission, participant),
(RelationKind::MissionInvolvement, capability, mission),
(RelationKind::MissionCapability, capability, mission),
(RelationKind::CapabilityInclude, mission, capability),
(RelationKind::CapabilityExtend, participant, capability),
(RelationKind::CapabilityGeneralization, mission, mission),
] {
assert!(!validation::can_connect(&p, kind, source, target));
}
}
}
#[test]
fn inclusion_and_specialization_reject_transitive_cycles_atomically() {
for layer in [Layer::Oa, Layer::Sa] {
for (kind, code) in [
(RelationKind::CapabilityInclude, "capability_include_cycle"),
(
RelationKind::CapabilityGeneralization,
"capability_generalization_cycle",
),
] {
let mut p = Project::empty();
let ids: Vec<_> = ["A", "B", "C"]
.into_iter()
.map(|name| add(&mut p, kinds(layer).2, layer, name))
.collect();
p.relate(kind, "A to B", ids[0], ids[1]);
p.relate(kind, "B to C", ids[1], ids[2]);
let mut session = Session::new(p.clone());
session
.execute(Command::Rename(ids[0], "Undoable draft".into()))
.unwrap();
session.undo();
let revision = session.revision();
assert!(!validation::can_connect(&p, kind, ids[2], ids[0]));
let error = session
.execute_many(&[
Command::Rename(ids[0], "Must roll back".into()),
Command::Connect(kind, "Would close cycle".into(), ids[2], ids[0]),
])
.unwrap_err();
assert!(error.0.contains(code), "{error}");
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), revision);
assert!(session.can_redo());
// Even an imported or edited complete graph is checked, not just the connector UI.
let closing = p.relate(kind, "Cycle", ids[2], ids[0]);
assert!(
validation::validate(&p)
.iter()
.any(|issue| issue.code == code && issue.element == Some(closing))
);
}
}
}
#[test]
fn conditional_extension_remains_editable_and_condition_is_validated() {
let mut p = Project::empty();
let base = add(&mut p, Kind::Capability, Layer::Oa, "Rescue");
let extending = add(&mut p, Kind::Capability, Layer::Oa, "Night rescue");
let mut session = Session::new(p);
let link = session
.execute(Command::Connect(
RelationKind::CapabilityExtend,
"Night assistance".into(),
extending,
base,
))
.unwrap()
.unwrap();
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.element == Some(link)
&& issue.code == "missing_extension_condition"
&& issue.severity == Severity::Warning)
);
let mut edited = session.project().relations[&link].clone();
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
*condition = "daylight == false && visibility < 500".into();
}
session
.execute(Command::EditRelation(link, edited.clone()))
.unwrap();
assert!(
!validation::validate(session.project())
.iter()
.any(|issue| issue.code == "missing_extension_condition")
);
let accepted = session.project().clone();
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
*condition = "invalid\u{0000}condition".into();
}
assert!(
session
.execute(Command::EditRelation(link, edited))
.is_err()
);
assert_eq!(session.project(), &accepted);
session.undo();
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.code == "missing_extension_condition")
);
session.redo();
assert_eq!(session.project(), &accepted);
}
#[test]
fn operational_missions_have_typed_bidirectional_counterparts_and_legacy_choices() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let mut p = Project::empty();
let source = add(
&mut p,
kinds(direction.source_layer()).1,
direction.source_layer(),
"Protect lives",
);
let request = TransitionRequest::new(direction, vec![source]);
let (candidate, plan) = transition(&p, &request);
let target = plan.changes[0].target;
assert_eq!(
candidate.elements[&target].kind(),
kinds(direction.target_layer()).1
);
let trace = candidate.traces.values().next().unwrap();
assert_eq!(trace.kind, TraceKind::Mission);
assert_eq!(
candidate.elements[&trace.oa].kind(),
Kind::OperationalMission
);
assert_eq!(candidate.elements[&trace.sa].kind(), Kind::Mission);
assert!(validation::trace_permits(&candidate, trace));
assert!(
plan_transition(&candidate, &request)
.unwrap()
.commands
.is_empty()
);
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);
}
assert_eq!(
counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa),
vec![Kind::OperationalMission, Kind::Capability]
);
assert_eq!(
counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa),
vec![Kind::SystemCapability, Kind::Mission]
);
// A previously intentional capability↔mission relation remains a manual trace and is reused.
let mut p = Project::empty();
let capability = add(&mut p, Kind::Capability, Layer::Oa, "User-level goal");
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![capability]);
request.mappings.push(TransitionMapping {
source: capability,
target: None,
target_kind: Some(Kind::Mission),
});
let (candidate, plan) = transition(&p, &request);
let mission = plan.changes[0].target;
assert_eq!(
candidate.traces.values().next().unwrap().kind,
TraceKind::Manual
);
let reverse = plan_transition(
&candidate,
&TransitionRequest::new(TransitionDirection::SaToOa, vec![mission]),
)
.unwrap();
assert!(reverse.conflicts.is_empty());
assert_eq!(reverse.changes[0].target, capability);
assert!(reverse.commands.is_empty());
}
fn mission_map(layer: Layer) -> (Project, Vec<Id>) {
let mut p = Project::empty();
let (actor_kind, mission_kind, capability_kind) = kinds(layer);
let actor = add(&mut p, actor_kind, layer, "Rescue coordinator");
let mission = add(&mut p, mission_kind, layer, "Protect lives");
let rescue = add(&mut p, capability_kind, layer, "Rescue");
let locate = add(&mut p, capability_kind, layer, "Locate distress");
let night = add(&mut p, capability_kind, layer, "Night rescue");
let mut relations = vec![
p.relate(
RelationKind::MissionInvolvement,
"Participates",
actor,
mission,
),
p.relate(
RelationKind::MissionCapability,
"Delivered by",
mission,
rescue,
),
p.relate(
RelationKind::CapabilityInclude,
"Always locate",
rescue,
locate,
),
p.relate(
RelationKind::CapabilityGeneralization,
"Specializes rescue",
night,
rescue,
),
];
let extension = p.relate(
RelationKind::CapabilityExtend,
"Night assistance",
night,
rescue,
);
if let RelationBody::CapabilityExtend { condition, .. } =
&mut p.relations.get_mut(&extension).unwrap().body
{
*condition = "light_level < 10".into();
}
relations.push(extension);
(p, relations)
}
#[test]
fn mission_map_transition_preserves_all_relationship_endpoints_and_conditions_both_ways() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, relations) = mission_map(direction.source_layer());
let mut request = TransitionRequest::new(direction, relations.clone());
request.scope = DependencyScope::Required;
let actor = p
.elements
.values()
.find(|e| e.kind().is_performer())
.unwrap()
.id;
request.mappings.push(TransitionMapping {
source: actor,
target: None,
target_kind: Some(kinds(direction.target_layer()).0),
});
let (candidate, plan) = transition(&p, &request);
let mapped = |source| {
plan.changes
.iter()
.find(|change| change.source == source)
.unwrap()
.target
};
for id in &relations {
let original = &p.relations[id];
let counterpart = &candidate.relations[&mapped(*id)];
assert_eq!(counterpart.kind(), original.kind());
assert_eq!(
counterpart.body.endpoints(),
(mapped(original.source()), mapped(original.target()))
);
if let RelationBody::CapabilityExtend { condition, .. } = &counterpart.body {
assert_eq!(condition, "light_level < 10");
}
assert_eq!(
references::relation_references(counterpart),
vec![counterpart.source(), counterpart.target()]
);
}
assert!(
candidate.elements.values().all(|e| e.parent.is_none()),
"capability relations do not create containment"
);
assert!(
candidate
.traces
.values()
.any(|t| t.kind == TraceKind::Mission)
);
let mut reverse = TransitionRequest::new(
if direction == TransitionDirection::OaToSa {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
relations.iter().map(|id| mapped(*id)).collect(),
);
reverse.scope = DependencyScope::Required;
let repeated = plan_transition(&candidate, &reverse).unwrap();
assert!(repeated.conflicts.is_empty(), "{:?}", repeated.conflicts);
assert!(repeated.commands.is_empty());
}
}
#[test]
fn mission_reidentification_and_deletion_preserve_reference_integrity_and_undo() {
let (mut p, relations) = mission_map(Layer::Oa);
let mission = p
.elements
.values()
.find(|e| e.kind() == Kind::OperationalMission)
.unwrap()
.id;
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Mission demonstration");
if let Some(SharedBody::Scenario { context, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*context = Some(mission);
}
let diagram = p.ensure_diagram(Layer::Oa);
let node = p.place(diagram, mission, Position { x: 10., y: 20. });
let evidence = Id::new_v4();
p.artifact_links.insert(
evidence,
ArtifactLink {
id: evidence,
alias: "mission_evidence".into(),
name: "Mission evidence".into(),
description: String::new(),
owner: mission,
relation: ArtifactRelation::Verifies,
uri: "git:reviews/mission.md".into(),
revision: Some("review-1".into()),
fragment: None,
},
);
let (p, plan) = transition(
&p,
&TransitionRequest::new(TransitionDirection::OaToSa, vec![mission]),
);
let sa = plan.changes[0].target;
let mut session = Session::new(p.clone());
let renamed_id = Id::new_v4();
session
.execute(Command::Reidentify(mission, renamed_id))
.unwrap();
let updated = session.project();
assert_eq!(updated.relations[&relations[0]].target(), renamed_id);
assert_eq!(updated.relations[&relations[1]].source(), renamed_id);
assert!(references::element_references(&updated.elements[&scenario]).contains(&renamed_id));
assert_eq!(updated.diagrams[&diagram].nodes[&node].element, renamed_id);
assert_eq!(updated.artifact_links[&evidence].owner, renamed_id);
assert_eq!(updated.traces.values().next().unwrap().oa, renamed_id);
let reidentified = updated.clone();
session.execute(Command::Delete(renamed_id)).unwrap();
let deleted = session.project();
assert!(
deleted.elements.contains_key(&sa),
"deletion never cascades to the opposite-layer counterpart"
);
assert!(deleted.elements.contains_key(&scenario));
assert!(references::element_references(&deleted.elements[&scenario]).is_empty());
assert!(!deleted.relations.contains_key(&relations[0]));
assert!(!deleted.relations.contains_key(&relations[1]));
assert!(deleted.relations.contains_key(&relations[2]));
assert!(deleted.traces.is_empty());
assert!(!deleted.artifact_links.contains_key(&evidence));
assert!(!deleted.diagrams[&diagram].nodes.contains_key(&node));
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
// The same identity visitor covers incoming/outgoing include, extend,
// generalization, and mission-capability endpoints.
let capability = p.relations[&relations[1]].target();
let updated_capability = Id::new_v4();
session
.execute(Command::Reidentify(capability, updated_capability))
.unwrap();
for original in p.relations.values() {
let (source, target) = original.body.endpoints();
let map = |id| {
if id == capability {
updated_capability
} else {
id
}
};
assert_eq!(
session.project().relations[&original.id].body.endpoints(),
(map(source), map(target))
);
}
let updated = session.project().clone();
session
.execute(Command::Delete(updated_capability))
.unwrap();
assert!(session.project().relations.values().all(|relation| {
!references::relation_references(relation).contains(&updated_capability)
}));
assert!(session.project().elements.contains_key(&mission));
assert!(session.project().relations.contains_key(&relations[0]));
session.undo();
assert_eq!(session.project(), &updated);
}
#[test]
fn capability_semantics_remain_edges_independent_of_visual_containment() {
let mut p = Project::empty();
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
let child = p
.add_element(
Kind::Capability,
Layer::Oa,
"Specific capability",
Some(parent),
)
.unwrap();
let include = p.relate(
RelationKind::CapabilityInclude,
"Includes group behavior",
child,
parent,
);
let generalization = p.relate(
RelationKind::CapabilityGeneralization,
"Separate specialization",
parent,
child,
);
validate_candidate(&p).unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let parent_node = p.place(diagram, parent, Position { x: 0., y: 0. });
let child_node = p.place(diagram, child, Position { x: 25., y: 75. });
let view = projection::project_view(&p, diagram);
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child_node)
.unwrap()
.visual_parent,
Some(parent_node)
);
for (id, source, target) in [
(include, child_node, parent_node),
(generalization, parent_node, child_node),
] {
let edge = view.edges.iter().find(|edge| edge.id == id).unwrap();
assert_eq!((edge.source, edge.target), (source, target));
}
}