Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
use oa_core::{
|
||||
projection,
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
fn accepted(p: &Project) {
|
||||
let errors: Vec<_> = validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn rejected(p: &Project, code: &str) {
|
||||
assert!(
|
||||
validation::validate(p)
|
||||
.iter()
|
||||
.any(|i| i.severity == Severity::Error && i.code == code),
|
||||
"Expected {code}: {:?}",
|
||||
validation::validate(p)
|
||||
);
|
||||
}
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, name, parent).unwrap()
|
||||
}
|
||||
fn work_pair() -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
|
||||
let sa = add(&mut p, Kind::Function, Layer::Sa, "Classify", None);
|
||||
(p, oa, sa)
|
||||
}
|
||||
fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let t = Trace {
|
||||
id,
|
||||
alias: p.fresh_alias("work_trace", "trace", id),
|
||||
name: "Refinement".into(),
|
||||
description: "Rationale".into(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
}),
|
||||
};
|
||||
p.traces.insert(id, t);
|
||||
id
|
||||
}
|
||||
fn artifact(p: &mut Project, owner: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias("requirement", "artifact", id),
|
||||
name: "REQ-42".into(),
|
||||
description: "Engineering requirement".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Satisfies,
|
||||
uri: "requirements+custom://project/REQ-42?view=full".into(),
|
||||
revision: Some("baseline-2026/September".into()),
|
||||
fragment: Some("verification".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_is_valid_and_preserves_uuid_and_alias_through_json() {
|
||||
let p = Project::demo();
|
||||
accepted(&p);
|
||||
let bytes = serde_json::to_vec(&p).unwrap();
|
||||
assert_eq!(serde_json::from_slice::<Project>(&bytes).unwrap(), p);
|
||||
assert!(
|
||||
p.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.steps()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|id| p.elements[id].kind() == Kind::Occurrence)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn failed_batch_preserves_model_history_revision_and_redo() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Reporter", None);
|
||||
let mut s = Session::new(p);
|
||||
s.execute(Command::Rename(oa, "Updated".into())).unwrap();
|
||||
s.undo();
|
||||
let before = s.project().clone();
|
||||
let revision = s.revision();
|
||||
let result = s.execute_many(&[
|
||||
Command::Rename(oa, "Would be lost".into()),
|
||||
Command::Connect(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Invalid".into(),
|
||||
actor,
|
||||
oa,
|
||||
),
|
||||
]);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.project(), &before);
|
||||
assert_eq!(s.revision(), revision);
|
||||
assert!(s.can_redo());
|
||||
}
|
||||
#[test]
|
||||
fn batch_is_one_undo_and_noop_does_not_change_revision() {
|
||||
let (p, oa, _) = work_pair();
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute_many(&[
|
||||
Command::Rename(oa, "Renamed".into()),
|
||||
Command::Describe(oa, "Detailed".into()),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(s.revision(), 1);
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
assert!(!s.dirty());
|
||||
let revision = s.revision();
|
||||
s.execute_many(&[]).unwrap();
|
||||
assert_eq!(s.revision(), revision);
|
||||
assert!(s.can_redo());
|
||||
}
|
||||
#[test]
|
||||
fn identity_aliases_and_map_keys_share_global_namespace() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(d, oa, Position { x: 0., y: 0. });
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = p.elements[&oa].alias.clone();
|
||||
rejected(&p, "duplicate_alias");
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.alias = "placed_assess".into();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.id = oa;
|
||||
rejected(&p, "duplicate_identity");
|
||||
rejected(&p, "map_identity");
|
||||
}
|
||||
#[test]
|
||||
fn rename_and_collision_insertion_preserve_existing_aliases() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let alias = p.elements[&oa].alias.clone();
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[Command::Rename(oa, "Completely new display name".into())],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.elements[&oa].alias, alias);
|
||||
let first = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
|
||||
let first_alias = p.elements[&first].alias.clone();
|
||||
let second = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
|
||||
assert_eq!(p.elements[&first].alias, first_alias);
|
||||
assert_ne!(p.elements[&second].alias, first_alias);
|
||||
accepted(&p);
|
||||
}
|
||||
#[test]
|
||||
fn nested_work_is_independent_of_leaf_allocation() {
|
||||
let mut p = Project::empty();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
|
||||
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Parent", None);
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(parent));
|
||||
p.relate(RelationKind::Allocation, "Performs", actor, child);
|
||||
accepted(&p);
|
||||
assert_eq!(validation::allocation_summary(&p, parent), Some(actor));
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
execute_batch(
|
||||
&p,
|
||||
&[Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Invalid parent allocation".into(),
|
||||
actor,
|
||||
parent
|
||||
)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
let other = add(
|
||||
&mut p,
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
"Unallocated child",
|
||||
Some(parent),
|
||||
);
|
||||
assert!(p.elements.contains_key(&other));
|
||||
assert_eq!(validation::allocation_summary(&p, parent), None);
|
||||
}
|
||||
#[test]
|
||||
fn parent_cycles_cross_layer_ownership_and_multiple_performers_reject() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
p.elements.get_mut(&oa).unwrap().parent = Some(sa);
|
||||
rejected(&p, "invalid_parent");
|
||||
p.elements.get_mut(&oa).unwrap().parent = None;
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
p.elements.get_mut(&oa).unwrap().parent = Some(child);
|
||||
rejected(&p, "containment_cycle");
|
||||
p.elements.get_mut(&oa).unwrap().parent = None;
|
||||
let a = add(&mut p, Kind::Actor, Layer::Oa, "A", None);
|
||||
let b = add(&mut p, Kind::Actor, Layer::Oa, "B", None);
|
||||
p.relate(RelationKind::Allocation, "A performs", a, child);
|
||||
p.relate(RelationKind::Allocation, "B performs", b, child);
|
||||
rejected(&p, "multiple_performers");
|
||||
}
|
||||
#[test]
|
||||
fn parent_and_endpoint_queries_match_creation_rules() {
|
||||
let mut p = Project::empty();
|
||||
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
assert!(validation::can_parent(
|
||||
&p,
|
||||
Kind::Port,
|
||||
Layer::Sa,
|
||||
Some(system)
|
||||
));
|
||||
assert!(!validation::can_parent(&p, Kind::Port, Layer::Sa, None));
|
||||
assert!(!validation::can_parent(
|
||||
&p,
|
||||
Kind::Port,
|
||||
Layer::Oa,
|
||||
Some(system)
|
||||
));
|
||||
assert!(validation::can_connect(
|
||||
&p,
|
||||
RelationKind::Allocation,
|
||||
system,
|
||||
function
|
||||
));
|
||||
p.relate(RelationKind::Allocation, "Performs", system, function);
|
||||
assert!(!validation::can_connect(
|
||||
&p,
|
||||
RelationKind::Allocation,
|
||||
system,
|
||||
function
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn create_occurrence_attaches_once_and_repeats_exchange_by_distinct_ids() {
|
||||
let p = Project::demo();
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let p = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::AddStep(process, exchange),
|
||||
Command::AddStep(process, exchange),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let steps = p.elements[&process].steps().unwrap();
|
||||
assert_eq!(steps.len(), 3);
|
||||
assert_ne!(steps[0], steps[1]);
|
||||
assert_ne!(steps[1], steps[2]);
|
||||
accepted(&p);
|
||||
let mut bad = p.clone();
|
||||
bad.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(steps[0]);
|
||||
rejected(&bad, "duplicate_reference");
|
||||
}
|
||||
#[test]
|
||||
fn scenario_lifelines_and_occurrences_require_same_context() {
|
||||
let mut p = Project::empty();
|
||||
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Scenario", None);
|
||||
let other = add(&mut p, Kind::Scenario, Layer::Oa, "Other scenario", None);
|
||||
let line = add(&mut p, Kind::Lifeline, Layer::Oa, "Actor line", Some(other));
|
||||
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(actor),
|
||||
};
|
||||
let action = add(&mut p, Kind::Activity, Layer::Oa, "Action", None);
|
||||
let mut occurrence = p
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Action occurrence",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(action),
|
||||
lifeline: Some(line),
|
||||
},
|
||||
precondition: "Before".into(),
|
||||
postcondition: "After".into(),
|
||||
};
|
||||
p.elements.insert(occurrence.id, occurrence);
|
||||
rejected(&p, "lifeline_context");
|
||||
}
|
||||
#[test]
|
||||
fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
let mut p = Project::empty();
|
||||
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
|
||||
let f1 = add(&mut p, Kind::Function, Layer::Sa, "Send", None);
|
||||
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
direction,
|
||||
interface: Some(interface),
|
||||
};
|
||||
id
|
||||
};
|
||||
let out = port(&mut p, "System output", system, PortDirection::Out);
|
||||
let input = port(&mut p, "Actor input", actor, PortDirection::In);
|
||||
let fp1 = port(&mut p, "Function output", f1, PortDirection::Out);
|
||||
let fp2 = port(&mut p, "Function input", f2, PortDirection::In);
|
||||
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary link", out, input);
|
||||
if let RelationBody::BoundaryExchange { items, .. } =
|
||||
&mut p.relations.get_mut(&boundary).unwrap().body
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
let exchange = p.relate(RelationKind::FunctionalExchange, "Message exchange", f1, f2);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: f1,
|
||||
target: f2,
|
||||
source_port: Some(fp1),
|
||||
target_port: Some(fp2),
|
||||
items: vec![item],
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Carried on",
|
||||
boundary,
|
||||
exchange,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides",
|
||||
system,
|
||||
interface,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires",
|
||||
actor,
|
||||
interface,
|
||||
);
|
||||
accepted(&p);
|
||||
if let RelationBody::FunctionalExchange { source_port, .. } =
|
||||
&mut p.relations.get_mut(&exchange).unwrap().body
|
||||
{
|
||||
*source_port = Some(input);
|
||||
}
|
||||
rejected(&p, "functional_port");
|
||||
}
|
||||
#[test]
|
||||
fn state_regions_initial_final_and_typed_actions_validate() {
|
||||
let mut p = Project::empty();
|
||||
let sys = add(&mut p, Kind::System, Layer::Sa, "System", None);
|
||||
let machine = add(
|
||||
&mut p,
|
||||
Kind::StateMachine,
|
||||
Layer::Sa,
|
||||
"Lifecycle",
|
||||
Some(sys),
|
||||
);
|
||||
let region = add(
|
||||
&mut p,
|
||||
Kind::Region,
|
||||
Layer::Sa,
|
||||
"Main region",
|
||||
Some(machine),
|
||||
);
|
||||
let initial = add(
|
||||
&mut p,
|
||||
Kind::Initial,
|
||||
Layer::Sa,
|
||||
"Initial point",
|
||||
Some(region),
|
||||
);
|
||||
let state = add(&mut p, Kind::State, Layer::Sa, "Ready", Some(region));
|
||||
let final_state = add(&mut p, Kind::Final, Layer::Sa, "Finished", Some(region));
|
||||
let work = add(&mut p, Kind::Function, Layer::Sa, "Initialize", None);
|
||||
p.relate(RelationKind::StateTransition, "Begin", initial, state);
|
||||
p.relate(RelationKind::StateTransition, "Finish", state, final_state);
|
||||
p.relate(RelationKind::StateAction, "Entry action", state, work);
|
||||
p.relate(RelationKind::Availability, "Available", work, state);
|
||||
accepted(&p);
|
||||
p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Invalid restart",
|
||||
final_state,
|
||||
state,
|
||||
);
|
||||
rejected(&p, "pseudostate_direction");
|
||||
}
|
||||
#[test]
|
||||
fn artifacts_preserve_arbitrary_uri_and_revision_without_fetch() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let id = artifact(&mut p, oa);
|
||||
accepted(&p);
|
||||
let encoded = serde_json::to_vec(&p).unwrap();
|
||||
let decoded: Project = serde_json::from_slice(&encoded).unwrap();
|
||||
assert_eq!(decoded.artifact_links[&id], p.artifact_links[&id]);
|
||||
p.artifact_links.get_mut(&id).unwrap().uri = "file:///C:/Engineering/REQ%2042.md".into();
|
||||
accepted(&p);
|
||||
p.artifact_links.get_mut(&id).unwrap().uri = "broken%XZ".into();
|
||||
rejected(&p, "invalid_uri");
|
||||
}
|
||||
#[test]
|
||||
fn reidentify_updates_containment_sequences_traces_artifacts_and_views_atomically() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
let link = artifact(&mut p, oa);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(diagram, oa, Position { x: 1.25, y: -0. });
|
||||
let new = Id::new_v4();
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Reidentify(oa, new)).unwrap();
|
||||
assert_eq!(s.project().elements[&child].parent, Some(new));
|
||||
assert_eq!(s.project().traces[&trace].oa, new);
|
||||
assert_eq!(s.project().artifact_links[&link].owner, new);
|
||||
assert_eq!(s.project().diagrams[&diagram].nodes[&node].element, new);
|
||||
assert_eq!(s.project().elements[&new].alias, p.elements[&oa].alias);
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn deleting_source_cascades_owned_records_but_keeps_other_layer_counterpart() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
let link = artifact(&mut p, child);
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Delete(oa)).unwrap();
|
||||
assert!(!s.project().elements.contains_key(&child));
|
||||
assert!(!s.project().traces.contains_key(&trace));
|
||||
assert!(!s.project().artifact_links.contains_key(&link));
|
||||
assert!(s.project().elements.contains_key(&sa));
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn relation_deletion_prunes_owned_occurrences_and_undo_restores_order() {
|
||||
let p = Project::demo();
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Delete(exchange)).unwrap();
|
||||
assert!(s.project().elements[&process].steps().unwrap().is_empty());
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn nested_projection_encloses_children_and_moves_only_selected_diagram() {
|
||||
let mut p = Project::empty();
|
||||
let root = add(&mut p, Kind::Entity, Layer::Oa, "Organization", None);
|
||||
let child = add(&mut p, Kind::Entity, Layer::Oa, "Team", Some(root));
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let parent_node = p.place(d, root, Position { x: 100., y: 100. });
|
||||
let child_node = p.place(d, child, Position { x: 500., y: 400. });
|
||||
let view = projection::project_view(&p, d);
|
||||
let parent = view.nodes.iter().find(|n| n.id == parent_node).unwrap();
|
||||
let nested = view.nodes.iter().find(|n| n.id == child_node).unwrap();
|
||||
assert_eq!(nested.visual_parent, Some(parent_node));
|
||||
assert!(parent.position.x + parent.size.width >= nested.position.x + nested.size.width + 24.);
|
||||
let moved = execute_batch(
|
||||
&p,
|
||||
&[Command::MoveNode {
|
||||
diagram: d,
|
||||
node: parent_node,
|
||||
position: Position { x: 125., y: 150. },
|
||||
with_descendants: true,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
moved.diagrams[&d].nodes[&child_node].position,
|
||||
Position { x: 525., y: 450. }
|
||||
);
|
||||
assert_eq!(moved.elements, p.elements);
|
||||
let arranged = execute_batch(&p, &[Command::Layout(d)]).unwrap();
|
||||
let pn = &arranged.diagrams[&d].nodes[&parent_node];
|
||||
let cn = &arranged.diagrams[&d].nodes[&child_node];
|
||||
assert!(cn.position.x > pn.position.x);
|
||||
assert!(cn.position.y > pn.position.y);
|
||||
assert!(cn.position.y + cn.size.height < pn.position.y + pn.size.height);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_geometry_and_new_payload_fields_are_rejected() {
|
||||
let (mut p, oa, _) = work_pair();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let node = p.place(d, oa, Position { x: f32::NAN, y: 0. });
|
||||
rejected(&p, "invalid_bounds");
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 0.;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size
|
||||
.width = -1.;
|
||||
rejected(&p, "invalid_bounds");
|
||||
assert!(
|
||||
serde_json::from_str::<SharedBody>(
|
||||
r#"{"Port":{"direction":"In","interface":null,"typo":true}}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_str::<Command>(r#"{"Reparent":{"element":"00000000-0000-0000-0000-000000000001","parent":null,"typo":true}}"#).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn bidirectional_transition_creates_reuses_and_updates_one_history_transaction() {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
|
||||
p.elements.get_mut(&oa).unwrap().description = "Operational intent".into();
|
||||
let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let target = plan.changes[0].target;
|
||||
let migrated = apply_transition(&p, &plan).unwrap();
|
||||
assert_eq!(migrated.elements[&target].kind(), Kind::Function);
|
||||
assert_eq!(migrated.traces.len(), 1);
|
||||
let again = plan_transition(&migrated, &request).unwrap();
|
||||
assert!(again.conflicts.is_empty());
|
||||
assert!(again.commands.is_empty());
|
||||
let edited = execute_batch(
|
||||
&migrated,
|
||||
&[Command::Describe(target, "Updated system intent".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let reverse = plan_transition(
|
||||
&edited,
|
||||
&TransitionRequest::new(TransitionDirection::SaToOa, vec![target]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverse.conflicts.is_empty(), "{:?}", reverse.conflicts);
|
||||
let restored = apply_transition(&edited, &reverse).unwrap();
|
||||
assert_eq!(restored.elements[&oa].description, "Updated system intent");
|
||||
assert_eq!(restored.traces.len(), 1);
|
||||
let mut s = Session::new(p.clone());
|
||||
s.execute(Command::Replace(migrated)).unwrap();
|
||||
s.undo();
|
||||
assert_eq!(s.project(), &p);
|
||||
}
|
||||
#[test]
|
||||
fn both_side_transition_conflicts_require_explicit_field_resolution() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
p.elements.get_mut(&sa).unwrap().name = p.elements[&oa].name.clone();
|
||||
let trace = make_trace(&mut p, oa, sa);
|
||||
p.elements.get_mut(&oa).unwrap().name = "New OA name".into();
|
||||
p.elements.get_mut(&sa).unwrap().name = "New SA name".into();
|
||||
assert_eq!(trace_drift(&p, &p.traces[&trace]), Drift::BothChanged);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|c| c.field == Some(TransitionField::Name))
|
||||
);
|
||||
assert!(apply_transition(&p, &plan).is_err());
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: oa,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
let accepted_plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
accepted_plan.conflicts.is_empty(),
|
||||
"{:?}",
|
||||
accepted_plan.conflicts
|
||||
);
|
||||
let updated = apply_transition(&p, &accepted_plan).unwrap();
|
||||
assert_eq!(updated.elements[&sa].name, "New SA name");
|
||||
assert_eq!(
|
||||
trace_drift(&updated, &updated.traces[&trace]),
|
||||
Drift::Unchanged
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn participant_transition_requires_boundary_decision_and_stale_plans_reject() {
|
||||
let mut p = Project::empty();
|
||||
let oa = add(&mut p, Kind::Entity, Layer::Oa, "Operations", None);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: None,
|
||||
target_kind: Some(Kind::System),
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
p.name = "Changed project".into();
|
||||
assert!(apply_transition(&p, &plan).unwrap_err().0.contains("stale"));
|
||||
}
|
||||
#[test]
|
||||
fn explicit_dependencies_map_chain_order_and_repeated_occurrences() {
|
||||
let mut p = Project::demo();
|
||||
p.traces.clear();
|
||||
let process = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Process)
|
||||
.unwrap()
|
||||
.id;
|
||||
let exchange = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::Interaction)
|
||||
.unwrap()
|
||||
.id;
|
||||
let p = execute_batch(&p, &[Command::AddStep(process, exchange)]).unwrap();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![process]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = apply_transition(&p, &plan).unwrap();
|
||||
let chain = plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == process)
|
||||
.unwrap()
|
||||
.target;
|
||||
let steps = result.elements[&chain].steps().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_ne!(steps[0], steps[1]);
|
||||
let relation = |id: Id| match result.elements[&id].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(r), ..
|
||||
},
|
||||
..
|
||||
}) => *r,
|
||||
_ => panic!("exchange occurrence"),
|
||||
};
|
||||
assert_eq!(relation(steps[0]), relation(steps[1]));
|
||||
}
|
||||
#[test]
|
||||
fn many_traces_need_explicit_counterpart_and_layout_does_not_cause_drift() {
|
||||
let (mut p, oa, sa) = work_pair();
|
||||
let first = make_trace(&mut p, oa, sa);
|
||||
let second = add(&mut p, Kind::Function, Layer::Sa, "Another function", None);
|
||||
make_trace(&mut p, oa, second);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty());
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, oa, Position { x: 400., y: 500. });
|
||||
assert_eq!(trace_drift(&p, &p.traces[&first]), Drift::Unchanged);
|
||||
}
|
||||
#[test]
|
||||
fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
let mut p = Project::empty();
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Item", None);
|
||||
let dtype = add(&mut p, Kind::DataType, Layer::Sa, "Count type", None);
|
||||
let field = add(&mut p, Kind::DataField, Layer::Sa, "Count", Some(item));
|
||||
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(dtype),
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: "items".into(),
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
|
||||
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port, items, ..
|
||||
} = &mut p.relations.get_mut(&rel).unwrap().body
|
||||
{
|
||||
*source_port = Some(port);
|
||||
items.push(item);
|
||||
}
|
||||
let dtype_new = Id::new_v4();
|
||||
let port_new = Id::new_v4();
|
||||
let result = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::Reidentify(dtype, dtype_new),
|
||||
Command::Reidentify(port, port_new),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
|
||||
);
|
||||
assert!(
|
||||
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references() {
|
||||
let p = Project::demo();
|
||||
for (id, _) in p.identities() {
|
||||
let replacement = Id::new_v4();
|
||||
let remapped = execute_batch(&p, &[Command::Reidentify(id, replacement)])
|
||||
.unwrap_or_else(|err| panic!("Reidentify {id}: {err}"));
|
||||
let restored = execute_batch(&remapped, &[Command::Reidentify(replacement, id)]).unwrap();
|
||||
assert_eq!(
|
||||
restored, p,
|
||||
"Reidentification must preserve all values and order"
|
||||
);
|
||||
if id != p.id {
|
||||
let deleted = execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
|
||||
accepted(&deleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn shared_roles_remain_valid_without_an_invented_unique_performer() {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Entity, Layer::Oa, "Team A", None);
|
||||
let b = add(&mut p, Kind::Entity, Layer::Oa, "Team B", None);
|
||||
let role = add(&mut p, Kind::Role, Layer::Oa, "Responder", None);
|
||||
let work = add(&mut p, Kind::Activity, Layer::Oa, "Respond", None);
|
||||
p.relate(RelationKind::RoleRealization, "A realizes", a, role);
|
||||
p.relate(
|
||||
RelationKind::RoleAssignment,
|
||||
"Role responsibility",
|
||||
role,
|
||||
work,
|
||||
);
|
||||
assert_eq!(validation::effective_performer(&p, work), Some(a));
|
||||
assert!(
|
||||
!validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unallocated_leaf")
|
||||
);
|
||||
p.relate(RelationKind::RoleRealization, "B realizes", b, role);
|
||||
accepted(&p);
|
||||
assert_eq!(validation::effective_performer(&p, work), None);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "ambiguous_role_performer" && i.severity == Severity::Warning)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn automatic_transition_does_not_accept_arbitrary_target_kinds() {
|
||||
let (p, oa, sa) = work_pair();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: oa,
|
||||
target: None,
|
||||
target_kind: Some(Kind::System),
|
||||
});
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
let mut p = p;
|
||||
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
|
||||
request.mappings[0].target = Some(actor);
|
||||
request.mappings[0].target_kind = None;
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings[0].target = Some(sa);
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: oa,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::TakeSource,
|
||||
});
|
||||
assert!(plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn demo_has_both_layers_complete_contracts_and_no_checks() {
|
||||
let p = Project::demo();
|
||||
assert_eq!(p.elements.len(), 41);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for projection in [
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
assert!(
|
||||
p.diagrams
|
||||
.values()
|
||||
.any(|d| d.layer == layer && d.projection == projection)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(p.traces.len(), 10);
|
||||
assert_eq!(p.artifact_links.len(), 2);
|
||||
assert!(
|
||||
validation::validate(&p).is_empty(),
|
||||
"{:?}",
|
||||
validation::validate(&p)
|
||||
);
|
||||
assert!(p.elements.values().any(|e| e.kind() == Kind::System));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material
|
||||
})
|
||||
)));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy
|
||||
})
|
||||
)));
|
||||
}
|
||||
Reference in New Issue
Block a user