515 lines
19 KiB
Rust
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));
|
|
}
|
|
}
|