520 lines
17 KiB
Rust
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)
|
|
);
|
|
}
|