942 lines
33 KiB
Rust
942 lines
33 KiB
Rust
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(),
|
|
semantic: None,
|
|
}),
|
|
};
|
|
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.relate(
|
|
RelationKind::InterfaceItemAllocation,
|
|
"Interface payload",
|
|
interface,
|
|
item,
|
|
);
|
|
let port = |p: &mut Project, name: &str, parent, direction| {
|
|
let id = Id::new_v4();
|
|
let contract = if p.elements[&parent].kind() == Kind::Function {
|
|
if direction == PortDirection::Out {
|
|
PortContract::FunctionOutput {
|
|
outgoing_items: vec![item],
|
|
}
|
|
} else {
|
|
PortContract::FunctionInput {
|
|
incoming_items: vec![item],
|
|
}
|
|
}
|
|
} else {
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation: if direction == PortDirection::Out {
|
|
ComponentPortOrientation::Out
|
|
} else {
|
|
ComponentPortOrientation::In
|
|
},
|
|
}
|
|
};
|
|
p.elements
|
|
.insert(id, make_port(p, id, parent, name, contract).unwrap());
|
|
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: fp1,
|
|
target_port: fp2,
|
|
items: vec![item],
|
|
};
|
|
p.relate(RelationKind::Allocation, "Send allocation", system, f1);
|
|
p.relate(RelationKind::Allocation, "Receive allocation", actor, f2);
|
|
p.relate(RelationKind::PortAllocation, "Output allocation", out, fp1);
|
|
p.relate(RelationKind::PortAllocation, "Input allocation", input, fp2);
|
|
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 = 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(),
|
|
direction: ParameterDirection::Unspecified,
|
|
storage: FieldStorage::Value,
|
|
default: None,
|
|
};
|
|
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
|
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
|
let port = Id::new_v4();
|
|
p.elements.insert(
|
|
port,
|
|
make_port(
|
|
&p,
|
|
port,
|
|
function,
|
|
"Output",
|
|
PortContract::FunctionOutput {
|
|
outgoing_items: vec![item],
|
|
},
|
|
)
|
|
.unwrap(),
|
|
);
|
|
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 = port;
|
|
items.push(item);
|
|
}
|
|
if let RelationBody::FunctionalExchange { target_port, .. } = p.relations[&rel].body
|
|
&& let Some(SharedBody::Port {
|
|
contract: PortContract::FunctionInput { incoming_items },
|
|
..
|
|
}) = p.elements.get_mut(&target_port).unwrap().shared_mut()
|
|
{
|
|
incoming_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: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 = if p
|
|
.relations
|
|
.get(&id)
|
|
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
|
{
|
|
// An explicitly empty interface cannot silently accept the
|
|
// still-carried payload. Removing its definition needs a
|
|
// reviewed edit of that dependent contract in the same batch.
|
|
let mut session = Session::new(p.clone());
|
|
assert!(
|
|
session
|
|
.execute(Command::Delete(id))
|
|
.unwrap_err()
|
|
.0
|
|
.contains("port_interface_items")
|
|
);
|
|
assert_eq!(session.project(), &p);
|
|
assert_eq!(session.revision(), 0);
|
|
let item = p.relations[&id].target();
|
|
let mut commands = vec![Command::Delete(id)];
|
|
commands.extend(
|
|
p.relations
|
|
.values()
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::BoundaryExchange
|
|
&& r.body.items().contains(&item)
|
|
})
|
|
.map(|r| Command::Delete(r.id)),
|
|
);
|
|
execute_batch(&p, &commands).unwrap()
|
|
} else {
|
|
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,
|
|
..
|
|
})
|
|
)));
|
|
}
|