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

520 lines
17 KiB
Rust

use oa_core::{
references,
transitions::*,
validation::{self, Severity},
*,
};
fn kinds(layer: Layer) -> (Kind, Kind) {
match layer {
Layer::Oa => (Kind::Process, Kind::Activity),
Layer::Sa => (Kind::FunctionalChain, Kind::Function),
}
}
fn occurrence(p: &Project, owner: Id, name: &str, body: OccurrenceBody) -> Element {
let mut element = p
.make_element(
Kind::Occurrence,
p.elements[&owner].layer(),
name,
Some(owner),
)
.unwrap();
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
element
}
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let element = occurrence(p, owner, name, body);
let id = element.id;
p.elements.insert(id, element);
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn action(p: &mut Project, owner: Id, name: &str) -> Id {
let layer = p.elements[&owner].layer();
let work = p.add_element(kinds(layer).1, layer, name, None).unwrap();
let id = add_occurrence(
p,
owner,
&format!("Perform {name}"),
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
);
p.elements.get_mut(&id).unwrap().description =
format!("Contribution within {}", p.elements[&owner].name);
id
}
fn link(p: &mut Project, source: Id, target: Id, guard: &str) -> Id {
let id = p.relate(
RelationKind::SequenceLink,
"Next contribution",
source,
target,
);
if let RelationBody::SequenceLink { guard: value, .. } =
&mut p.relations.get_mut(&id).unwrap().body
{
*value = guard.into();
}
id
}
fn branch_fixture(layer: Layer) -> (Project, Id, Id) {
let mut p = Project::empty();
let main = p
.add_element(kinds(layer).0, layer, "Respond to distress", None)
.unwrap();
let reusable = p
.add_element(kinds(layer).0, layer, "Prepare resources", None)
.unwrap();
action(&mut p, reusable, "Mobilize responder");
let start = action(&mut p, main, "Assess distress");
let fork = add_occurrence(
&mut p,
main,
"Prepare in parallel",
OccurrenceBody::Control {
kind: ControlKind::Fork,
},
);
let locate = action(&mut p, main, "Locate vessel");
let reuse = add_occurrence(
&mut p,
main,
"Use preparations",
OccurrenceBody::SequenceReference {
sequence: Some(reusable),
},
);
let join = add_occurrence(
&mut p,
main,
"Both preparations ready",
OccurrenceBody::Control {
kind: ControlKind::Join,
},
);
let choice = add_occurrence(
&mut p,
main,
"Select response",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let direct = action(&mut p, main, "Dispatch rescue");
let fallback = action(&mut p, main, "Request assistance");
let merge = add_occurrence(
&mut p,
main,
"Response selected",
OccurrenceBody::Control {
kind: ControlKind::Merge,
},
);
let end = action(&mut p, main, "Confirm response");
for (source, target, guard) in [
(start, fork, ""),
(fork, locate, ""),
(fork, reuse, ""),
(locate, join, ""),
(reuse, join, ""),
(join, choice, ""),
(choice, direct, "available_resources > 0"),
(choice, fallback, "else"),
(direct, merge, ""),
(fallback, merge, ""),
(merge, end, ""),
] {
link(&mut p, source, target, guard);
}
(p, main, reusable)
}
fn errors(p: &Project) -> Vec<validation::Issue> {
validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect()
}
fn coherent_path(p: &Project) {
// These contextual-path fixtures deliberately leave the global system boundary
// and performer allocation open.
let unexpected: Vec<_> = validation::validate(p)
.into_iter()
.filter(|issue| {
issue.severity != Severity::Warning
|| !matches!(issue.code.as_str(), "unallocated_leaf" | "missing_system")
})
.collect();
assert!(unexpected.is_empty(), "{unexpected:?}");
}
#[test]
fn branching_reusable_processes_transition_as_contextual_topology_in_both_directions() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, main, reusable) = branch_fixture(direction.source_layer());
coherent_path(&p);
let mut request = TransitionRequest::new(direction, vec![main]);
request.scope = DependencyScope::Required;
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let mapped = |id| {
plan.changes
.iter()
.find(|change| change.source == id)
.unwrap()
.target
};
let candidate = apply_transition(&p, &plan).unwrap();
assert_eq!(
candidate.elements[&mapped(main)].kind(),
kinds(direction.target_layer()).0
);
assert_eq!(
candidate.elements[&mapped(reusable)].kind(),
kinds(direction.target_layer()).0
);
for element in p.elements.values() {
let counterpart = &candidate.elements[&mapped(element.id)];
assert_eq!(counterpart.description, element.description);
let expected: Vec<_> = references::element_references(element)
.into_iter()
.map(mapped)
.collect();
assert_eq!(references::element_references(counterpart), expected);
if element.kind() == Kind::Occurrence {
let trace = candidate
.traces
.values()
.find(|trace| trace.oa == element.id || trace.sa == element.id)
.unwrap();
assert_eq!(trace.kind, TraceKind::Sequence);
}
}
for relation in p.relations.values() {
let counterpart = &candidate.relations[&mapped(relation.id)];
let mut expected = relation.body.clone();
expected.set_endpoints(mapped(relation.source()), mapped(relation.target()));
assert_eq!(counterpart.body, expected);
}
assert!(
candidate
.relations
.values()
.all(|r| r.kind() == RelationKind::SequenceLink),
"contextual precedence never fabricates global exchanges"
);
let mut session = Session::new(p.clone());
session.execute_many(&plan.commands).unwrap();
assert_eq!(session.project(), &candidate);
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &candidate);
let mut reverse = TransitionRequest::new(
if direction == TransitionDirection::OaToSa {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
vec![mapped(main)],
);
reverse.scope = DependencyScope::Required;
let repeat = plan_transition(&candidate, &reverse).unwrap();
assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts);
assert!(repeat.commands.is_empty());
}
}
#[test]
fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let owner = p
.add_element(kinds(layer).0, layer, "Context", None)
.unwrap();
let a = action(&mut p, owner, "A");
let b = action(&mut p, owner, "B");
let wrong = action(&mut p, owner, "Wrong");
let work = |p: &Project, id| match p.elements[&id].shared().unwrap() {
SharedBody::Occurrence {
body:
OccurrenceBody::Action {
action: Some(work), ..
},
..
} => *work,
_ => unreachable!(),
};
let global = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"A sends to B",
work(&p, a),
work(&p, b),
);
let exchange = add_occurrence(
&mut p,
owner,
"Contextual message",
OccurrenceBody::Exchange {
exchange: Some(global),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
},
);
let other = p
.add_element(kinds(layer).0, layer, "Other context", None)
.unwrap();
let foreign = action(&mut p, other, "Foreign");
for (source, target) in [(a, exchange), (exchange, b)] {
assert!(validation::can_connect(
&p,
RelationKind::SequenceLink,
source,
target
));
}
for (source, target) in [(wrong, exchange), (exchange, wrong), (a, foreign), (a, a)] {
assert!(!validation::can_connect(
&p,
RelationKind::SequenceLink,
source,
target
));
assert!(
execute_batch(
&p,
&[Command::Connect(
RelationKind::SequenceLink,
"Invalid".into(),
source,
target
)]
)
.is_err()
);
}
let original_exchange = p.relations[&global].clone();
let accepted = execute_batch(
&p,
&[
Command::Connect(RelationKind::SequenceLink, "Before".into(), a, exchange),
Command::Connect(RelationKind::SequenceLink, "After".into(), exchange, b),
],
)
.unwrap();
assert_eq!(accepted.relations[&global], original_exchange);
let scenario = p
.add_element(Kind::Scenario, layer, "Timeline", None)
.unwrap();
let invalid = occurrence(
&p,
scenario,
"Wrong control context",
OccurrenceBody::Control {
kind: ControlKind::Fork,
},
);
assert!(
execute_batch(&p, &[Command::CreateElement(invalid)])
.unwrap_err()
.0
.contains("sequence_contribution_owner")
);
}
}
#[test]
fn reuse_rejects_recursive_or_cross_layer_targets_and_deleted_targets_become_drafts() {
let mut p = Project::empty();
let a = p.add_element(Kind::Process, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Process, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Process, Layer::Oa, "C", None).unwrap();
let use_b = add_occurrence(
&mut p,
a,
"Reuse B",
OccurrenceBody::SequenceReference { sequence: Some(b) },
);
add_occurrence(
&mut p,
b,
"Reuse C",
OccurrenceBody::SequenceReference { sequence: Some(c) },
);
let foreign = p
.add_element(Kind::FunctionalChain, Layer::Sa, "SA sequence", None)
.unwrap();
let mut session = Session::new(p.clone());
for target in [a, c, foreign] {
let invalid = occurrence(
session.project(),
c,
"Invalid reuse",
OccurrenceBody::SequenceReference {
sequence: Some(target),
},
);
assert!(session.execute(Command::CreateElement(invalid)).is_err());
assert_eq!(session.project(), &p);
}
let new_b = Id::new_v4();
session.execute(Command::Reidentify(b, new_b)).unwrap();
assert!(references::element_references(&session.project().elements[&use_b]).contains(&new_b));
let reidentified = session.project().clone();
session.execute(Command::Delete(new_b)).unwrap();
assert!(session.project().elements.contains_key(&use_b));
assert!(matches!(
session.project().elements[&use_b].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: None },
..
})
));
assert!(session.project().elements.contains_key(&c));
assert!(errors(session.project()).is_empty());
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.element == Some(use_b) && issue.code == "incomplete_reference")
);
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn loops_are_allowed_while_missing_guards_and_disconnected_paths_are_diagnosed() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Process, Layer::Oa, "Retry until ready", None)
.unwrap();
let start = action(&mut p, owner, "Start");
let gate = add_occurrence(
&mut p,
owner,
"Ready?",
OccurrenceBody::Control {
kind: ControlKind::Loop,
},
);
let repeat = action(&mut p, owner, "Retry");
let end = action(&mut p, owner, "Finish");
link(&mut p, start, gate, "");
link(&mut p, gate, repeat, "!ready");
link(&mut p, repeat, gate, "");
let exit = link(&mut p, gate, end, "ready");
coherent_path(&p);
if let RelationBody::SequenceLink { guard, .. } = &mut p.relations.get_mut(&exit).unwrap().body
{
guard.clear();
}
assert!(errors(&p).is_empty());
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(exit) && i.code == "missing_sequence_guard")
);
let island = action(&mut p, owner, "Disconnected work");
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(island) && i.code == "isolated_contribution")
);
p.relations.remove(&exit);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(gate) && i.code == "control_cardinality")
);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(gate) && i.code == "nonterminating_contribution")
);
}
#[test]
fn topology_identity_edits_and_deletions_preserve_remaining_paths_and_undo() {
let (p, main, _) = branch_fixture(Layer::Sa);
for id in p.elements.keys().chain(p.relations.keys()).copied() {
let mut session = Session::new(p.clone());
let replacement = Id::new_v4();
session
.execute(Command::Reidentify(id, replacement))
.unwrap();
let reidentified = session.project().clone();
assert!(
!reidentified
.elements
.values()
.any(|e| references::element_references(e).contains(&id))
);
assert!(
!reidentified
.relations
.values()
.any(|r| references::relation_references(r).contains(&id))
);
session.execute(Command::Delete(replacement)).unwrap();
assert!(errors(session.project()).is_empty());
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
}
// List order is presentation order: changing it leaves explicit links untouched.
let mut session = Session::new(p.clone());
session.execute(Command::MoveStep(main, 0, 4)).unwrap();
assert_eq!(session.project().relations, p.relations);
coherent_path(session.project());
}
#[test]
fn legacy_linear_exchange_only_sequences_keep_their_existing_meaning() {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
let owner = p
.add_element(Kind::Process, Layer::Oa, "Legacy order", None)
.unwrap();
let ab = p.relate(RelationKind::Interaction, "AB", a, b);
let ca = p.relate(RelationKind::Interaction, "CA", c, a);
let mut session = Session::new(p);
session.execute(Command::AddStep(owner, ab)).unwrap();
session.execute(Command::AddStep(owner, ca)).unwrap();
let issues = validation::validate(session.project());
assert!(issues.iter().any(|i| i.code == "sequence_gap"));
assert!(
!issues
.iter()
.any(|i| i.code == "isolated_contribution" || i.code == "sequence_starts")
);
assert!(
session
.project()
.relations
.values()
.all(|r| r.kind() != RelationKind::SequenceLink)
);
}