Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user